Repository: alibaba/OpenSandbox Branch: main Commit: 4cfdce25f870 Files: 1108 Total size: 5.8 MB Directory structure: gitextract_ee6cl8k3/ ├── .github/ │ ├── CODEOWNERS │ ├── ISSUE_TEMPLATE/ │ │ ├── FEATURE_REQUEST.md │ │ └── config.yml │ ├── pull_request_template.md │ └── workflows/ │ ├── deploy-docs-pages.yml │ ├── egress-test.yaml.yml │ ├── execd-test.yml │ ├── ingress-test.yaml │ ├── publish-components.yml │ ├── publish-csharp-sdks.yml │ ├── publish-helm-chart.yml │ ├── publish-java-sdks.yml │ ├── publish-js-sdks.yml │ ├── publish-python-sdks.yml │ ├── publish-server.yml │ ├── real-e2e.yml │ ├── sandbox-k8s-e2e.yml │ ├── sandbox-k8s-test.yml │ ├── sdk-tests.yml │ ├── server-test.yml │ └── verify-license.yml ├── .gitignore ├── .pre-commit-config.yaml ├── AGENTS.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── cli/ │ ├── README.md │ ├── pyproject.toml │ ├── src/ │ │ └── opensandbox_cli/ │ │ ├── __init__.py │ │ ├── __main__.py │ │ ├── client.py │ │ ├── commands/ │ │ │ ├── __init__.py │ │ │ ├── code.py │ │ │ ├── command.py │ │ │ ├── config_cmd.py │ │ │ ├── file.py │ │ │ └── sandbox.py │ │ ├── config.py │ │ ├── main.py │ │ ├── output.py │ │ └── utils.py │ └── tests/ │ ├── __init__.py │ ├── conftest.py │ ├── test_cli_help.py │ ├── test_commands.py │ ├── test_config.py │ ├── test_output.py │ ├── test_resolve_id.py │ └── test_utils.py ├── components/ │ ├── egress/ │ │ ├── Dockerfile │ │ ├── README.md │ │ ├── TODO.md │ │ ├── build.sh │ │ ├── docs/ │ │ │ └── benchmark.md │ │ ├── go.mod │ │ ├── go.sum │ │ ├── main.go │ │ ├── nameserver.go │ │ ├── nameserver_test.go │ │ ├── nft.go │ │ ├── pkg/ │ │ │ ├── constants/ │ │ │ │ ├── configuration.go │ │ │ │ └── constants.go │ │ │ ├── dnsproxy/ │ │ │ │ ├── exempt.go │ │ │ │ ├── exempt_test.go │ │ │ │ ├── proxy.go │ │ │ │ ├── proxy_linux.go │ │ │ │ ├── proxy_other.go │ │ │ │ └── proxy_test.go │ │ │ ├── events/ │ │ │ │ ├── broadcaster.go │ │ │ │ ├── events_test.go │ │ │ │ └── webhook.go │ │ │ ├── iptables/ │ │ │ │ └── redirect.go │ │ │ ├── log/ │ │ │ │ └── logger.go │ │ │ ├── nftables/ │ │ │ │ ├── dynamic.go │ │ │ │ ├── manager.go │ │ │ │ └── manager_test.go │ │ │ └── policy/ │ │ │ ├── policy.go │ │ │ └── policy_test.go │ │ ├── policy_server.go │ │ ├── policy_server_test.go │ │ └── tests/ │ │ ├── bench-dns-nft.sh │ │ ├── egress-in-webhook.sh │ │ ├── hostname.txt │ │ ├── smoke-dns.sh │ │ ├── smoke-dynamic-ip.sh │ │ ├── smoke-nft.sh │ │ └── webhook-server.py │ ├── execd/ │ │ ├── .golangci.yml │ │ ├── DEVELOPMENT.md │ │ ├── Dockerfile │ │ ├── Makefile │ │ ├── README.md │ │ ├── README_zh.md │ │ ├── bootstrap.sh │ │ ├── build.sh │ │ ├── go.mod │ │ ├── go.sum │ │ ├── main.go │ │ ├── pkg/ │ │ │ ├── flag/ │ │ │ │ ├── flags.go │ │ │ │ └── parser.go │ │ │ ├── jupyter/ │ │ │ │ ├── auth/ │ │ │ │ │ ├── auth.go │ │ │ │ │ ├── auth_test.go │ │ │ │ │ ├── client.go │ │ │ │ │ └── types.go │ │ │ │ ├── client.go │ │ │ │ ├── debug_integration_test.go │ │ │ │ ├── execute/ │ │ │ │ │ ├── events.json │ │ │ │ │ ├── execute.go │ │ │ │ │ ├── execute_test.go │ │ │ │ │ ├── executor.go │ │ │ │ │ ├── types.go │ │ │ │ │ └── zz_generated.deepcopy.go │ │ │ │ ├── integration_test.go │ │ │ │ ├── kernel/ │ │ │ │ │ ├── kernel.go │ │ │ │ │ ├── kernelspecs.json │ │ │ │ │ └── types.go │ │ │ │ ├── live_integration_test.go │ │ │ │ ├── session/ │ │ │ │ │ ├── session.go │ │ │ │ │ ├── session_test.go │ │ │ │ │ ├── sessions.json │ │ │ │ │ └── types.go │ │ │ │ └── transport.go │ │ │ ├── log/ │ │ │ │ └── log.go │ │ │ ├── runtime/ │ │ │ │ ├── bash_session.go │ │ │ │ ├── bash_session_test.go │ │ │ │ ├── bash_session_windows.go │ │ │ │ ├── command.go │ │ │ │ ├── command_common.go │ │ │ │ ├── command_status.go │ │ │ │ ├── command_status_test.go │ │ │ │ ├── command_test.go │ │ │ │ ├── command_windows.go │ │ │ │ ├── context.go │ │ │ │ ├── context_test.go │ │ │ │ ├── ctrl.go │ │ │ │ ├── env.go │ │ │ │ ├── env_test.go │ │ │ │ ├── errors.go │ │ │ │ ├── helpers_test.go │ │ │ │ ├── interrupt.go │ │ │ │ ├── interrupt_windows.go │ │ │ │ ├── jupyter.go │ │ │ │ ├── language.go │ │ │ │ ├── sql.go │ │ │ │ ├── sql_test.go │ │ │ │ ├── types.go │ │ │ │ └── types_test.go │ │ │ ├── util/ │ │ │ │ ├── glob/ │ │ │ │ │ ├── index.go │ │ │ │ │ ├── match.go │ │ │ │ │ ├── match_benchmark_test.go │ │ │ │ │ ├── match_test.go │ │ │ │ │ └── pattern.go │ │ │ │ └── safego/ │ │ │ │ ├── safe.go │ │ │ │ └── safe_test.go │ │ │ └── web/ │ │ │ ├── controller/ │ │ │ │ ├── basic.go │ │ │ │ ├── basic_test.go │ │ │ │ ├── codeinterpreting.go │ │ │ │ ├── codeinterpreting_test.go │ │ │ │ ├── command.go │ │ │ │ ├── command_test.go │ │ │ │ ├── filesystem.go │ │ │ │ ├── filesystem_download.go │ │ │ │ ├── filesystem_test.go │ │ │ │ ├── filesystem_upload.go │ │ │ │ ├── filesystem_windows.go │ │ │ │ ├── metric.go │ │ │ │ ├── metric_test.go │ │ │ │ ├── mock_test.go │ │ │ │ ├── ping.go │ │ │ │ ├── sse.go │ │ │ │ ├── syscall_linux.go │ │ │ │ ├── syscall_others.go │ │ │ │ ├── test_helpers.go │ │ │ │ ├── utils.go │ │ │ │ ├── utils_test.go │ │ │ │ └── utils_windows.go │ │ │ ├── model/ │ │ │ │ ├── codeinterpreting.go │ │ │ │ ├── codeinterpreting_test.go │ │ │ │ ├── command.go │ │ │ │ ├── error.go │ │ │ │ ├── filesystem.go │ │ │ │ ├── header.go │ │ │ │ ├── metric.go │ │ │ │ └── session.go │ │ │ ├── proxy.go │ │ │ └── router.go │ │ └── tests/ │ │ ├── jupyter.sh │ │ ├── smoke.sh │ │ └── smoke_api.py │ ├── ingress/ │ │ ├── .golangci.yml │ │ ├── DEVELOPMENT.md │ │ ├── Dockerfile │ │ ├── Makefile │ │ ├── README.md │ │ ├── build.sh │ │ ├── go.mod │ │ ├── go.sum │ │ ├── main.go │ │ └── pkg/ │ │ ├── flag/ │ │ │ ├── flags.go │ │ │ └── parser.go │ │ ├── proxy/ │ │ │ ├── header.go │ │ │ ├── healthz.go │ │ │ ├── healthz_test.go │ │ │ ├── host.go │ │ │ ├── http.go │ │ │ ├── http_test.go │ │ │ ├── logger.go │ │ │ ├── proxy.go │ │ │ ├── proxy_test.go │ │ │ ├── websocket.go │ │ │ └── websocket_test.go │ │ ├── renewintent/ │ │ │ ├── intent.go │ │ │ ├── intent_test.go │ │ │ ├── publisher.go │ │ │ ├── redis.go │ │ │ └── redis_bench_test.go │ │ └── sandbox/ │ │ ├── agent_sandbox_provider.go │ │ ├── agent_sandbox_provider_test.go │ │ ├── batchsandbox_provider.go │ │ ├── batchsandbox_provider_test.go │ │ ├── errors_test.go │ │ ├── factory.go │ │ └── provider.go │ └── internal/ │ ├── go.mod │ ├── go.sum │ ├── logger/ │ │ ├── logger.go │ │ └── zap.go │ └── version/ │ └── version.go ├── docs/ │ ├── .nvmrc │ ├── .vitepress/ │ │ ├── config.mts │ │ ├── scripts/ │ │ │ └── docs-manifest.mjs │ │ └── theme/ │ │ ├── index.ts │ │ └── styles.css │ ├── README.md │ ├── README_zh.md │ ├── RELEASE_NOTE_TEMPLATE.md │ ├── architecture.md │ ├── index.md │ ├── manual-cleanup-refactor-guide.md │ ├── package.json │ ├── secure-container.md │ ├── single_host_network.md │ └── zh/ │ └── index.md ├── examples/ │ ├── README.md │ ├── agent-sandbox/ │ │ ├── README.md │ │ └── main.py │ ├── aio-sandbox/ │ │ ├── README.md │ │ └── main.py │ ├── chrome/ │ │ ├── Dockerfile │ │ ├── README.md │ │ ├── build.sh │ │ ├── chrome.sh │ │ ├── go.mod │ │ ├── go.sum │ │ ├── main.go │ │ └── main.py │ ├── claude-code/ │ │ ├── README.md │ │ └── main.py │ ├── code-interpreter/ │ │ ├── README.md │ │ ├── main.py │ │ └── main_use_pool.py │ ├── codex-cli/ │ │ ├── README.md │ │ └── main.py │ ├── desktop/ │ │ ├── Dockerfile │ │ ├── README.md │ │ ├── build.sh │ │ └── main.py │ ├── docker-ossfs-volume-mount/ │ │ ├── README.md │ │ ├── README_zh.md │ │ └── main.py │ ├── docker-pvc-volume-mount/ │ │ ├── README.md │ │ ├── README_zh.md │ │ └── main.py │ ├── gemini-cli/ │ │ ├── README.md │ │ └── main.py │ ├── google-adk/ │ │ ├── README.md │ │ └── main.py │ ├── host-volume-mount/ │ │ ├── README.md │ │ ├── README_zh.md │ │ └── main.py │ ├── kimi-cli/ │ │ ├── README.md │ │ └── main.py │ ├── kubernetes-pvc-volume-mount/ │ │ ├── README.md │ │ └── main.py │ ├── langgraph/ │ │ ├── README.md │ │ └── main.py │ ├── nullclaw/ │ │ ├── README.md │ │ └── main.py │ ├── openclaw/ │ │ ├── README.md │ │ ├── README_zh.md │ │ └── main.py │ ├── playwright/ │ │ ├── Dockerfile │ │ ├── README.md │ │ ├── build.sh │ │ └── main.py │ ├── rl-training/ │ │ ├── README.md │ │ ├── main.py │ │ └── requirements.txt │ └── vscode/ │ ├── Dockerfile │ ├── README.md │ ├── build.sh │ └── main.py ├── kubernetes/ │ ├── .golangci.yml │ ├── Dockerfile │ ├── Dockerfile.debug │ ├── Makefile │ ├── PROJECT │ ├── README-ZH.md │ ├── README.md │ ├── apis/ │ │ └── sandbox/ │ │ └── v1alpha1/ │ │ ├── batchsandbox_types.go │ │ ├── doc.go │ │ ├── groupversion_info.go │ │ ├── pool_types.go │ │ └── zz_generated.deepcopy.go │ ├── build.sh │ ├── charts/ │ │ ├── opensandbox-controller/ │ │ │ ├── .helmignore │ │ │ ├── Chart.yaml │ │ │ ├── README.md │ │ │ ├── templates/ │ │ │ │ ├── NOTES.txt │ │ │ │ ├── _helpers.tpl │ │ │ │ ├── clusterrole.yaml │ │ │ │ ├── clusterrolebinding.yaml │ │ │ │ ├── crds/ │ │ │ │ │ ├── batchsandboxes.yaml │ │ │ │ │ └── pools.yaml │ │ │ │ ├── deployment.yaml │ │ │ │ └── serviceaccount.yaml │ │ │ └── values.yaml │ │ └── opensandbox-server/ │ │ ├── .helmignore │ │ ├── Chart.yaml │ │ ├── README.md │ │ ├── templates/ │ │ │ ├── NOTES.txt │ │ │ ├── _helpers.tpl │ │ │ ├── ingress-gateway.yaml │ │ │ └── server.yaml │ │ └── values.yaml │ ├── cmd/ │ │ ├── controller/ │ │ │ └── main.go │ │ └── task-executor/ │ │ └── main.go │ ├── config/ │ │ ├── crd/ │ │ │ ├── bases/ │ │ │ │ ├── sandbox.opensandbox.io_batchsandboxes.yaml │ │ │ │ └── sandbox.opensandbox.io_pools.yaml │ │ │ ├── kustomization.yaml │ │ │ └── kustomizeconfig.yaml │ │ ├── default/ │ │ │ ├── cert_metrics_manager_patch.yaml │ │ │ ├── kustomization.yaml │ │ │ ├── manager_metrics_patch.yaml │ │ │ └── metrics_service.yaml │ │ ├── manager/ │ │ │ ├── kustomization.yaml │ │ │ └── manager.yaml │ │ ├── manifests/ │ │ │ └── kustomization.yaml │ │ ├── network-policy/ │ │ │ ├── allow-metrics-traffic.yaml │ │ │ └── kustomization.yaml │ │ ├── prometheus/ │ │ │ ├── kustomization.yaml │ │ │ ├── monitor.yaml │ │ │ └── monitor_tls_patch.yaml │ │ ├── rbac/ │ │ │ ├── batchsandbox_admin_role.yaml │ │ │ ├── batchsandbox_editor_role.yaml │ │ │ ├── batchsandbox_viewer_role.yaml │ │ │ ├── kustomization.yaml │ │ │ ├── leader_election_role.yaml │ │ │ ├── leader_election_role_binding.yaml │ │ │ ├── metrics_auth_role.yaml │ │ │ ├── metrics_auth_role_binding.yaml │ │ │ ├── metrics_reader_role.yaml │ │ │ ├── pool_admin_role.yaml │ │ │ ├── pool_editor_role.yaml │ │ │ ├── pool_viewer_role.yaml │ │ │ ├── role.yaml │ │ │ ├── role_binding.yaml │ │ │ └── service_account.yaml │ │ ├── samples/ │ │ │ ├── kustomization.yaml │ │ │ ├── sandbox_v1alpha1_batchsandbox-with-task.yaml │ │ │ ├── sandbox_v1alpha1_batchsandbox.yaml │ │ │ ├── sandbox_v1alpha1_pool.yaml │ │ │ └── sandbox_v1alpha1_pooled_batchsandbox.yaml │ │ └── scorecard/ │ │ ├── bases/ │ │ │ └── config.yaml │ │ ├── kustomization.yaml │ │ └── patches/ │ │ ├── basic.config.yaml │ │ └── olm.config.yaml │ ├── docs/ │ │ ├── BUILD-IMAGES.md │ │ ├── HELM-DEPLOYMENT.md │ │ └── logging.md │ ├── examples/ │ │ ├── controller/ │ │ │ ├── README-ZH.md │ │ │ ├── README.md │ │ │ └── main.go │ │ └── task-executor/ │ │ ├── README.md │ │ ├── README_zh-CN.md │ │ └── main.go │ ├── go.mod │ ├── go.sum │ ├── hack/ │ │ ├── boilerplate.go.txt │ │ ├── debug-task.sh │ │ ├── pool-perf.py │ │ └── update-codegen.sh │ ├── internal/ │ │ ├── controller/ │ │ │ ├── allocator.go │ │ │ ├── allocator_mock.go │ │ │ ├── allocator_test.go │ │ │ ├── apis.go │ │ │ ├── batchsandbox_controller.go │ │ │ ├── batchsandbox_controller_test.go │ │ │ ├── pool_controller.go │ │ │ ├── pool_controller_test.go │ │ │ ├── strategy/ │ │ │ │ ├── pool_strategy.go │ │ │ │ ├── pool_strategy_default.go │ │ │ │ ├── pool_strategy_factory.go │ │ │ │ ├── pool_strategy_test.go │ │ │ │ ├── task_scheduling_strategy.go │ │ │ │ ├── task_scheduling_strategy_default.go │ │ │ │ ├── task_scheduling_strategy_default_test.go │ │ │ │ └── task_scheduling_strategy_factory.go │ │ │ └── suite_test.go │ │ ├── scheduler/ │ │ │ ├── default_scheduler.go │ │ │ ├── default_scheduler_mock.go │ │ │ ├── default_scheduler_test.go │ │ │ ├── interface.go │ │ │ ├── mock/ │ │ │ │ ├── interface.go │ │ │ │ └── types.go │ │ │ ├── recovery.go │ │ │ ├── recovery_test.go │ │ │ ├── status_collector.go │ │ │ ├── status_collector_mock.go │ │ │ └── types.go │ │ ├── task-executor/ │ │ │ ├── config/ │ │ │ │ └── config.go │ │ │ ├── manager/ │ │ │ │ ├── interface.go │ │ │ │ ├── task_manager.go │ │ │ │ └── task_manager_test.go │ │ │ ├── runtime/ │ │ │ │ ├── composite.go │ │ │ │ ├── container.go │ │ │ │ ├── interface.go │ │ │ │ ├── process.go │ │ │ │ └── process_test.go │ │ │ ├── server/ │ │ │ │ ├── handler.go │ │ │ │ ├── handler_test.go │ │ │ │ └── router.go │ │ │ ├── storage/ │ │ │ │ ├── file_store.go │ │ │ │ ├── file_store_test.go │ │ │ │ └── interface.go │ │ │ ├── types/ │ │ │ │ └── task.go │ │ │ └── utils/ │ │ │ ├── pathutil.go │ │ │ └── pathutil_test.go │ │ └── utils/ │ │ ├── controller/ │ │ │ └── util.go │ │ ├── expectations/ │ │ │ ├── init.go │ │ │ ├── resource_version_expectation.go │ │ │ ├── resource_version_expectation_test.go │ │ │ ├── scale_expectations.go │ │ │ └── scale_expectations_test.go │ │ ├── fieldindex/ │ │ │ └── register.go │ │ ├── finalizer.go │ │ ├── helper.go │ │ ├── json.go │ │ ├── logging/ │ │ │ └── logger.go │ │ ├── pod.go │ │ ├── pod_test.go │ │ └── requeueduration/ │ │ └── duration.go │ ├── pkg/ │ │ ├── client/ │ │ │ ├── clientset/ │ │ │ │ └── versioned/ │ │ │ │ ├── clientset.go │ │ │ │ ├── fake/ │ │ │ │ │ ├── clientset_generated.go │ │ │ │ │ ├── doc.go │ │ │ │ │ └── register.go │ │ │ │ ├── scheme/ │ │ │ │ │ ├── doc.go │ │ │ │ │ └── register.go │ │ │ │ └── typed/ │ │ │ │ └── sandbox/ │ │ │ │ └── v1alpha1/ │ │ │ │ ├── batchsandbox.go │ │ │ │ ├── doc.go │ │ │ │ ├── fake/ │ │ │ │ │ ├── doc.go │ │ │ │ │ ├── fake_batchsandbox.go │ │ │ │ │ ├── fake_pool.go │ │ │ │ │ └── fake_sandbox_client.go │ │ │ │ ├── generated_expansion.go │ │ │ │ ├── pool.go │ │ │ │ └── sandbox_client.go │ │ │ ├── informers/ │ │ │ │ └── externalversions/ │ │ │ │ ├── factory.go │ │ │ │ ├── generic.go │ │ │ │ ├── internalinterfaces/ │ │ │ │ │ └── factory_interfaces.go │ │ │ │ └── sandbox/ │ │ │ │ ├── interface.go │ │ │ │ └── v1alpha1/ │ │ │ │ ├── batchsandbox.go │ │ │ │ ├── interface.go │ │ │ │ └── pool.go │ │ │ └── listers/ │ │ │ └── sandbox/ │ │ │ └── v1alpha1/ │ │ │ ├── batchsandbox.go │ │ │ ├── expansion_generated.go │ │ │ └── pool.go │ │ ├── task-executor/ │ │ │ ├── client.go │ │ │ └── types.go │ │ └── utils/ │ │ ├── endpoints.go │ │ └── endpoints_test.go │ └── test/ │ ├── e2e/ │ │ ├── e2e_suite_test.go │ │ ├── e2e_test.go │ │ └── testdata/ │ │ ├── batchsandbox-non-pooled-expire.yaml │ │ ├── batchsandbox-non-pooled.yaml │ │ ├── batchsandbox-pooled-no-expire.yaml │ │ ├── batchsandbox-pooled.yaml │ │ ├── batchsandbox-with-process-task.yaml │ │ ├── pool-basic.yaml │ │ ├── pool-with-env.yaml │ │ ├── pool-with-task-executor.yaml │ │ └── runtimeclass/ │ │ └── gvisor.yaml │ ├── e2e_runtime/ │ │ └── gvisor/ │ │ ├── gvisor_test.go │ │ ├── suite_test.go │ │ └── testdata/ │ │ ├── gvisor.yaml.tmpl │ │ └── runtimeclass.yaml │ ├── e2e_task/ │ │ ├── suite_test.go │ │ └── task_e2e_test.go │ └── utils/ │ ├── image.go │ └── utils.go ├── oseps/ │ ├── 0001-fqdn-based-egress-control.md │ ├── 0002-kubernetes-sigs-agent-sandbox-support.md │ ├── 0003-volume-and-volumebinding-support.md │ ├── 0004-secure-container-runtime.md │ ├── 0005-client-side-sandbox-pool.md │ ├── 0006-developer-console.md │ ├── 0007-fast-sandbox-runtime-support.md │ ├── 0008-pause-resume-rootfs-snapshot.md │ ├── 0009-auto-renew-sandbox-on-ingress-access.md │ ├── 0010-opentelemetry-instrumentation.md │ ├── CONTRIBUTING.md │ ├── README.md │ ├── init-osep.sh │ └── osep-template.md.template ├── sandboxes/ │ └── code-interpreter/ │ ├── Dockerfile │ ├── Dockerfile_base │ ├── README.md │ ├── README_zh.md │ ├── build.sh │ └── scripts/ │ ├── code-interpreter-env.sh │ ├── code-interpreter.sh │ └── jupyter_notebook_config.py ├── scripts/ │ ├── add-license.sh │ ├── bump-component-version.sh │ ├── csharp-e2e.sh │ ├── java-e2e.sh │ ├── javascript-e2e.sh │ ├── python-e2e.sh │ ├── spec-doc/ │ │ ├── generate-spec.js │ │ └── index.html │ └── verify-license.sh ├── sdks/ │ ├── Directory.Build.props │ ├── code-interpreter/ │ │ ├── csharp/ │ │ │ ├── OpenSandbox.CodeInterpreter.sln │ │ │ ├── README.md │ │ │ ├── README_zh.md │ │ │ ├── src/ │ │ │ │ └── OpenSandbox.CodeInterpreter/ │ │ │ │ ├── Adapters/ │ │ │ │ │ └── CodesAdapter.cs │ │ │ │ ├── CodeInterpreter.cs │ │ │ │ ├── Factory/ │ │ │ │ │ ├── DefaultCodeInterpreterAdapterFactory.cs │ │ │ │ │ └── ICodeInterpreterAdapterFactory.cs │ │ │ │ ├── Models/ │ │ │ │ │ └── CodeModels.cs │ │ │ │ ├── OpenSandbox.CodeInterpreter.csproj │ │ │ │ └── Services/ │ │ │ │ └── ICodes.cs │ │ │ └── tests/ │ │ │ └── OpenSandbox.CodeInterpreter.Tests/ │ │ │ ├── CodeInterpreterTests.cs │ │ │ ├── CodesAdapterTests.cs │ │ │ ├── FactoryTests.cs │ │ │ ├── ModelsTests.cs │ │ │ └── OpenSandbox.CodeInterpreter.Tests.csproj │ │ ├── javascript/ │ │ │ ├── .nvmrc │ │ │ ├── README.md │ │ │ ├── README_zh.md │ │ │ ├── eslint.config.mjs │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── adapters/ │ │ │ │ │ ├── codesAdapter.ts │ │ │ │ │ ├── openapiError.ts │ │ │ │ │ └── sse.ts │ │ │ │ ├── factory/ │ │ │ │ │ ├── adapterFactory.ts │ │ │ │ │ └── defaultAdapterFactory.ts │ │ │ │ ├── index.ts │ │ │ │ ├── interpreter.ts │ │ │ │ ├── models.ts │ │ │ │ └── services/ │ │ │ │ └── codes.ts │ │ │ ├── tests/ │ │ │ │ ├── defaultAdapterFactory.headers.test.mjs │ │ │ │ └── interpreter.headers.test.mjs │ │ │ ├── tsconfig.json │ │ │ └── tsup.config.ts │ │ ├── kotlin/ │ │ │ ├── LICENSE │ │ │ ├── README.md │ │ │ ├── README_zh.md │ │ │ ├── build.gradle.kts │ │ │ ├── code-interpreter/ │ │ │ │ ├── build.gradle.kts │ │ │ │ └── src/ │ │ │ │ ├── main/ │ │ │ │ │ └── kotlin/ │ │ │ │ │ └── com/ │ │ │ │ │ └── alibaba/ │ │ │ │ │ └── opensandbox/ │ │ │ │ │ └── codeinterpreter/ │ │ │ │ │ ├── CodeInterpreter.kt │ │ │ │ │ ├── domain/ │ │ │ │ │ │ ├── models/ │ │ │ │ │ │ │ └── execd/ │ │ │ │ │ │ │ └── executions/ │ │ │ │ │ │ │ └── CodeModels.kt │ │ │ │ │ │ └── services/ │ │ │ │ │ │ └── Codes.kt │ │ │ │ │ └── infrastructure/ │ │ │ │ │ ├── adapters/ │ │ │ │ │ │ ├── converter/ │ │ │ │ │ │ │ └── CodeExecutionConverter.kt │ │ │ │ │ │ └── service/ │ │ │ │ │ │ └── CodesAdapter.kt │ │ │ │ │ └── factory/ │ │ │ │ │ └── AdapterFactory.kt │ │ │ │ └── test/ │ │ │ │ └── kotlin/ │ │ │ │ └── com/ │ │ │ │ └── alibaba/ │ │ │ │ └── opensandbox/ │ │ │ │ └── codeinterpreter/ │ │ │ │ ├── CodeInterpreterTest.kt │ │ │ │ └── infrastructure/ │ │ │ │ └── adapters/ │ │ │ │ └── service/ │ │ │ │ └── CodesAdapterTest.kt │ │ │ ├── code-interpreter-bom/ │ │ │ │ └── build.gradle.kts │ │ │ ├── gradle/ │ │ │ │ ├── libs.versions.toml │ │ │ │ └── wrapper/ │ │ │ │ ├── gradle-wrapper.jar │ │ │ │ └── gradle-wrapper.properties │ │ │ ├── gradle.properties │ │ │ ├── gradlew │ │ │ └── settings.gradle.kts │ │ └── python/ │ │ ├── LICENSE │ │ ├── Makefile │ │ ├── README.md │ │ ├── README_zh.md │ │ ├── pyproject.toml │ │ ├── src/ │ │ │ └── code_interpreter/ │ │ │ ├── __init__.py │ │ │ ├── adapters/ │ │ │ │ ├── __init__.py │ │ │ │ ├── code_adapter.py │ │ │ │ ├── converter/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ └── code_execution_converter.py │ │ │ │ └── factory.py │ │ │ ├── code_interpreter.py │ │ │ ├── models/ │ │ │ │ ├── __init__.py │ │ │ │ ├── code.py │ │ │ │ └── code_sync.py │ │ │ ├── py.typed │ │ │ ├── services/ │ │ │ │ ├── __init__.py │ │ │ │ └── code.py │ │ │ └── sync/ │ │ │ ├── __init__.py │ │ │ ├── adapters/ │ │ │ │ ├── __init__.py │ │ │ │ ├── code_adapter.py │ │ │ │ └── factory.py │ │ │ ├── code_interpreter.py │ │ │ └── services/ │ │ │ ├── __init__.py │ │ │ └── code.py │ │ └── tests/ │ │ ├── test_adapter_eager_init.py │ │ ├── test_code_interpreter_create_and_delegation.py │ │ ├── test_code_service_adapter_openapi_calls.py │ │ └── test_code_service_adapter_streaming.py │ ├── eslint.base.mjs │ ├── mcp/ │ │ └── sandbox/ │ │ └── python/ │ │ ├── LICENSE │ │ ├── README.md │ │ ├── README_zh.md │ │ ├── pyproject.toml │ │ └── src/ │ │ └── opensandbox_mcp/ │ │ ├── __init__.py │ │ ├── __main__.py │ │ ├── py.typed │ │ └── server.py │ ├── package.json │ ├── pnpm-workspace.yaml │ ├── sandbox/ │ │ ├── csharp/ │ │ │ ├── .editorconfig │ │ │ ├── Directory.Build.props │ │ │ ├── OpenSandbox.sln │ │ │ ├── OpenSandbox.sln.DotSettings.user │ │ │ ├── README.md │ │ │ ├── README_zh.md │ │ │ ├── src/ │ │ │ │ └── OpenSandbox/ │ │ │ │ ├── Adapters/ │ │ │ │ │ ├── CommandsAdapter.cs │ │ │ │ │ ├── EgressAdapter.cs │ │ │ │ │ ├── FilesystemAdapter.cs │ │ │ │ │ ├── HealthAdapter.cs │ │ │ │ │ ├── MetricsAdapter.cs │ │ │ │ │ ├── SandboxesAdapter.cs │ │ │ │ │ └── SseParser.cs │ │ │ │ ├── Config/ │ │ │ │ │ ├── ConnectionConfig.cs │ │ │ │ │ └── DiagnosticsOptions.cs │ │ │ │ ├── Core/ │ │ │ │ │ ├── Constants.cs │ │ │ │ │ └── Exceptions.cs │ │ │ │ ├── Factory/ │ │ │ │ │ ├── DefaultAdapterFactory.cs │ │ │ │ │ └── IAdapterFactory.cs │ │ │ │ ├── HttpClientProvider.cs │ │ │ │ ├── Internal/ │ │ │ │ │ ├── ExecutionEventDispatcher.cs │ │ │ │ │ └── HttpClientWrapper.cs │ │ │ │ ├── Models/ │ │ │ │ │ ├── Execd.cs │ │ │ │ │ ├── Execution.cs │ │ │ │ │ ├── Filesystem.cs │ │ │ │ │ └── Sandboxes.cs │ │ │ │ ├── OpenSandbox.csproj │ │ │ │ ├── Options.cs │ │ │ │ ├── Sandbox.cs │ │ │ │ ├── SandboxManager.cs │ │ │ │ └── Services/ │ │ │ │ ├── IEgress.cs │ │ │ │ ├── IExecdCommands.cs │ │ │ │ ├── IExecdHealth.cs │ │ │ │ ├── IExecdMetrics.cs │ │ │ │ ├── ISandboxFiles.cs │ │ │ │ └── ISandboxes.cs │ │ │ └── tests/ │ │ │ └── OpenSandbox.Tests/ │ │ │ ├── CommandsAdapterTests.cs │ │ │ ├── ConnectionConfigTests.cs │ │ │ ├── ConstantsTests.cs │ │ │ ├── ExceptionTests.cs │ │ │ ├── ModelsTests.cs │ │ │ ├── OpenSandbox.Tests.csproj │ │ │ ├── OptionsTests.cs │ │ │ ├── SandboxEgressLifecycleTests.cs │ │ │ ├── SandboxReadinessDiagnosticsTests.cs │ │ │ ├── SandboxesAdapterTests.cs │ │ │ └── SseParserTests.cs │ │ ├── javascript/ │ │ │ ├── .nvmrc │ │ │ ├── README.md │ │ │ ├── README_zh.md │ │ │ ├── eslint.config.mjs │ │ │ ├── package.json │ │ │ ├── scripts/ │ │ │ │ └── generate-api.mjs │ │ │ ├── src/ │ │ │ │ ├── adapters/ │ │ │ │ │ ├── commandsAdapter.ts │ │ │ │ │ ├── egressAdapter.ts │ │ │ │ │ ├── filesystemAdapter.ts │ │ │ │ │ ├── healthAdapter.ts │ │ │ │ │ ├── metricsAdapter.ts │ │ │ │ │ ├── openapiError.ts │ │ │ │ │ ├── sandboxesAdapter.ts │ │ │ │ │ └── sse.ts │ │ │ │ ├── api/ │ │ │ │ │ ├── egress.ts │ │ │ │ │ ├── execd.ts │ │ │ │ │ └── lifecycle.ts │ │ │ │ ├── config/ │ │ │ │ │ └── connection.ts │ │ │ │ ├── core/ │ │ │ │ │ ├── constants.ts │ │ │ │ │ └── exceptions.ts │ │ │ │ ├── factory/ │ │ │ │ │ ├── adapterFactory.ts │ │ │ │ │ └── defaultAdapterFactory.ts │ │ │ │ ├── index.ts │ │ │ │ ├── internal.ts │ │ │ │ ├── manager.ts │ │ │ │ ├── models/ │ │ │ │ │ ├── execd.ts │ │ │ │ │ ├── execution.ts │ │ │ │ │ ├── executionEventDispatcher.ts │ │ │ │ │ ├── filesystem.ts │ │ │ │ │ └── sandboxes.ts │ │ │ │ ├── openapi/ │ │ │ │ │ ├── egressClient.ts │ │ │ │ │ ├── execdClient.ts │ │ │ │ │ └── lifecycleClient.ts │ │ │ │ ├── sandbox.ts │ │ │ │ └── services/ │ │ │ │ ├── egress.ts │ │ │ │ ├── execdCommands.ts │ │ │ │ ├── execdHealth.ts │ │ │ │ ├── execdMetrics.ts │ │ │ │ ├── filesystem.ts │ │ │ │ └── sandboxes.ts │ │ │ ├── tests/ │ │ │ │ └── sandbox.create.test.mjs │ │ │ ├── tsconfig.json │ │ │ └── tsup.config.ts │ │ ├── kotlin/ │ │ │ ├── LICENSE │ │ │ ├── README.md │ │ │ ├── README_zh.md │ │ │ ├── build.gradle.kts │ │ │ ├── gradle/ │ │ │ │ ├── libs.versions.toml │ │ │ │ └── wrapper/ │ │ │ │ ├── gradle-wrapper.jar │ │ │ │ └── gradle-wrapper.properties │ │ │ ├── gradle.properties │ │ │ ├── gradlew │ │ │ ├── sandbox/ │ │ │ │ ├── Module.md │ │ │ │ ├── build.gradle.kts │ │ │ │ └── src/ │ │ │ │ ├── main/ │ │ │ │ │ └── kotlin/ │ │ │ │ │ └── com/ │ │ │ │ │ └── alibaba/ │ │ │ │ │ └── opensandbox/ │ │ │ │ │ └── sandbox/ │ │ │ │ │ ├── HttpClientProvider.kt │ │ │ │ │ ├── Sandbox.kt │ │ │ │ │ ├── SandboxManager.kt │ │ │ │ │ ├── config/ │ │ │ │ │ │ └── ConnectionConfig.kt │ │ │ │ │ ├── domain/ │ │ │ │ │ │ ├── exceptions/ │ │ │ │ │ │ │ └── SandboxException.kt │ │ │ │ │ │ ├── models/ │ │ │ │ │ │ │ ├── execd/ │ │ │ │ │ │ │ │ ├── Constants.kt │ │ │ │ │ │ │ │ ├── executions/ │ │ │ │ │ │ │ │ │ ├── CommandModels.kt │ │ │ │ │ │ │ │ │ ├── ExecutionModels.kt │ │ │ │ │ │ │ │ │ └── RunCommandRequest.kt │ │ │ │ │ │ │ │ └── filesystem/ │ │ │ │ │ │ │ │ └── FilesystemModels.kt │ │ │ │ │ │ │ └── sandboxes/ │ │ │ │ │ │ │ └── SandboxModels.kt │ │ │ │ │ │ └── services/ │ │ │ │ │ │ ├── Commands.kt │ │ │ │ │ │ ├── Egress.kt │ │ │ │ │ │ ├── Filesystem.kt │ │ │ │ │ │ ├── Health.kt │ │ │ │ │ │ ├── Metrics.kt │ │ │ │ │ │ └── Sandboxes.kt │ │ │ │ │ └── infrastructure/ │ │ │ │ │ ├── adapters/ │ │ │ │ │ │ ├── converter/ │ │ │ │ │ │ │ ├── ExceptionConverter.kt │ │ │ │ │ │ │ ├── ExecutionConverter.kt │ │ │ │ │ │ │ ├── ExecutionEventDispatcher.kt │ │ │ │ │ │ │ ├── FilesystemConverter.kt │ │ │ │ │ │ │ ├── SandboxModelConverter.kt │ │ │ │ │ │ │ └── Serializer.kt │ │ │ │ │ │ └── service/ │ │ │ │ │ │ ├── CommandsAdapter.kt │ │ │ │ │ │ ├── EgressAdapter.kt │ │ │ │ │ │ ├── FilesystemAdapter.kt │ │ │ │ │ │ ├── HealthAdapter.kt │ │ │ │ │ │ ├── MetricsAdapter.kt │ │ │ │ │ │ └── SandboxesAdapter.kt │ │ │ │ │ └── factory/ │ │ │ │ │ └── AdapterFactory.kt │ │ │ │ └── test/ │ │ │ │ └── kotlin/ │ │ │ │ └── com/ │ │ │ │ └── alibaba/ │ │ │ │ └── opensandbox/ │ │ │ │ └── sandbox/ │ │ │ │ ├── SandboxManagerTest.kt │ │ │ │ ├── SandboxTest.kt │ │ │ │ ├── domain/ │ │ │ │ │ ├── exceptions/ │ │ │ │ │ │ └── SandboxExceptionCompatibilityTest.kt │ │ │ │ │ └── models/ │ │ │ │ │ └── VolumeModelsTest.kt │ │ │ │ └── infrastructure/ │ │ │ │ └── adapters/ │ │ │ │ └── service/ │ │ │ │ ├── CommandsAdapterTest.kt │ │ │ │ └── SandboxesAdapterTest.kt │ │ │ ├── sandbox-api/ │ │ │ │ ├── build.gradle.kts │ │ │ │ └── src/ │ │ │ │ └── main/ │ │ │ │ └── kotlin/ │ │ │ │ └── com/ │ │ │ │ └── alibaba/ │ │ │ │ └── opensandbox/ │ │ │ │ └── sandbox/ │ │ │ │ └── api/ │ │ │ │ ├── models/ │ │ │ │ │ └── execd/ │ │ │ │ │ └── ExecutionModels.kt │ │ │ │ └── openapitools.json │ │ │ ├── sandbox-bom/ │ │ │ │ └── build.gradle.kts │ │ │ └── settings.gradle.kts │ │ └── python/ │ │ ├── LICENSE │ │ ├── Makefile │ │ ├── README.md │ │ ├── README_zh.md │ │ ├── pyproject.toml │ │ ├── scripts/ │ │ │ ├── generate_api.py │ │ │ ├── openapi_egress_config.yaml │ │ │ ├── openapi_execd_config.yaml │ │ │ └── openapi_lifecycle_config.yaml │ │ ├── src/ │ │ │ └── opensandbox/ │ │ │ ├── __init__.py │ │ │ ├── adapters/ │ │ │ │ ├── __init__.py │ │ │ │ ├── command_adapter.py │ │ │ │ ├── converter/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── command_model_converter.py │ │ │ │ │ ├── event_node.py │ │ │ │ │ ├── exception_converter.py │ │ │ │ │ ├── execution_converter.py │ │ │ │ │ ├── execution_event_dispatcher.py │ │ │ │ │ ├── filesystem_model_converter.py │ │ │ │ │ ├── metrics_model_converter.py │ │ │ │ │ ├── response_handler.py │ │ │ │ │ └── sandbox_model_converter.py │ │ │ │ ├── egress_adapter.py │ │ │ │ ├── factory.py │ │ │ │ ├── filesystem_adapter.py │ │ │ │ ├── health_adapter.py │ │ │ │ ├── metrics_adapter.py │ │ │ │ └── sandboxes_adapter.py │ │ │ ├── api/ │ │ │ │ ├── __init__.py │ │ │ │ ├── egress/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── api/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ └── policy/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── get_policy.py │ │ │ │ │ │ └── patch_policy.py │ │ │ │ │ ├── client.py │ │ │ │ │ ├── errors.py │ │ │ │ │ ├── models/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── network_policy.py │ │ │ │ │ │ ├── network_policy_default_action.py │ │ │ │ │ │ ├── network_rule.py │ │ │ │ │ │ ├── network_rule_action.py │ │ │ │ │ │ └── policy_status_response.py │ │ │ │ │ ├── py.typed │ │ │ │ │ └── types.py │ │ │ │ ├── execd/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── api/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── code_interpreting/ │ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ │ ├── create_code_context.py │ │ │ │ │ │ │ ├── delete_context.py │ │ │ │ │ │ │ ├── delete_contexts_by_language.py │ │ │ │ │ │ │ ├── get_context.py │ │ │ │ │ │ │ ├── interrupt_code.py │ │ │ │ │ │ │ ├── list_contexts.py │ │ │ │ │ │ │ └── run_code.py │ │ │ │ │ │ ├── command/ │ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ │ ├── get_background_command_logs.py │ │ │ │ │ │ │ ├── get_command_status.py │ │ │ │ │ │ │ ├── interrupt_command.py │ │ │ │ │ │ │ └── run_command.py │ │ │ │ │ │ ├── filesystem/ │ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ │ ├── chmod_files.py │ │ │ │ │ │ │ ├── download_file.py │ │ │ │ │ │ │ ├── get_files_info.py │ │ │ │ │ │ │ ├── make_dirs.py │ │ │ │ │ │ │ ├── remove_dirs.py │ │ │ │ │ │ │ ├── remove_files.py │ │ │ │ │ │ │ ├── rename_files.py │ │ │ │ │ │ │ ├── replace_content.py │ │ │ │ │ │ │ ├── search_files.py │ │ │ │ │ │ │ └── upload_file.py │ │ │ │ │ │ ├── health/ │ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ │ └── ping.py │ │ │ │ │ │ └── metric/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── get_metrics.py │ │ │ │ │ │ └── watch_metrics.py │ │ │ │ │ ├── client.py │ │ │ │ │ ├── errors.py │ │ │ │ │ ├── models/ │ │ │ │ │ │ ├── __init__.py │ │ │ │ │ │ ├── chmod_files_body.py │ │ │ │ │ │ ├── code_context.py │ │ │ │ │ │ ├── code_context_request.py │ │ │ │ │ │ ├── command_status_response.py │ │ │ │ │ │ ├── error_response.py │ │ │ │ │ │ ├── file_info.py │ │ │ │ │ │ ├── file_metadata.py │ │ │ │ │ │ ├── get_files_info_response_200.py │ │ │ │ │ │ ├── make_dirs_body.py │ │ │ │ │ │ ├── metrics.py │ │ │ │ │ │ ├── permission.py │ │ │ │ │ │ ├── rename_file_item.py │ │ │ │ │ │ ├── replace_content_body.py │ │ │ │ │ │ ├── replace_file_content_item.py │ │ │ │ │ │ ├── run_code_request.py │ │ │ │ │ │ ├── run_command_request.py │ │ │ │ │ │ ├── run_command_request_envs.py │ │ │ │ │ │ ├── server_stream_event.py │ │ │ │ │ │ ├── server_stream_event_error.py │ │ │ │ │ │ ├── server_stream_event_results.py │ │ │ │ │ │ ├── server_stream_event_type.py │ │ │ │ │ │ └── upload_file_body.py │ │ │ │ │ ├── py.typed │ │ │ │ │ └── types.py │ │ │ │ └── lifecycle/ │ │ │ │ ├── __init__.py │ │ │ │ ├── api/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ └── sandboxes/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── delete_sandboxes_sandbox_id.py │ │ │ │ │ ├── get_sandboxes.py │ │ │ │ │ ├── get_sandboxes_sandbox_id.py │ │ │ │ │ ├── get_sandboxes_sandbox_id_endpoints_port.py │ │ │ │ │ ├── post_sandboxes.py │ │ │ │ │ ├── post_sandboxes_sandbox_id_pause.py │ │ │ │ │ ├── post_sandboxes_sandbox_id_renew_expiration.py │ │ │ │ │ └── post_sandboxes_sandbox_id_resume.py │ │ │ │ ├── client.py │ │ │ │ ├── errors.py │ │ │ │ ├── models/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── create_sandbox_request.py │ │ │ │ │ ├── create_sandbox_request_env.py │ │ │ │ │ ├── create_sandbox_request_extensions.py │ │ │ │ │ ├── create_sandbox_request_metadata.py │ │ │ │ │ ├── create_sandbox_response.py │ │ │ │ │ ├── create_sandbox_response_metadata.py │ │ │ │ │ ├── endpoint.py │ │ │ │ │ ├── endpoint_headers.py │ │ │ │ │ ├── error_response.py │ │ │ │ │ ├── host.py │ │ │ │ │ ├── image_spec.py │ │ │ │ │ ├── image_spec_auth.py │ │ │ │ │ ├── list_sandboxes_response.py │ │ │ │ │ ├── network_policy.py │ │ │ │ │ ├── network_policy_default_action.py │ │ │ │ │ ├── network_rule.py │ │ │ │ │ ├── network_rule_action.py │ │ │ │ │ ├── ossfs.py │ │ │ │ │ ├── ossfs_version.py │ │ │ │ │ ├── pagination_info.py │ │ │ │ │ ├── pvc.py │ │ │ │ │ ├── renew_sandbox_expiration_request.py │ │ │ │ │ ├── renew_sandbox_expiration_response.py │ │ │ │ │ ├── resource_limits.py │ │ │ │ │ ├── sandbox.py │ │ │ │ │ ├── sandbox_metadata.py │ │ │ │ │ ├── sandbox_status.py │ │ │ │ │ └── volume.py │ │ │ │ ├── py.typed │ │ │ │ └── types.py │ │ │ ├── config/ │ │ │ │ ├── __init__.py │ │ │ │ ├── connection.py │ │ │ │ └── connection_sync.py │ │ │ ├── constants.py │ │ │ ├── exceptions/ │ │ │ │ ├── __init__.py │ │ │ │ └── sandbox.py │ │ │ ├── manager.py │ │ │ ├── models/ │ │ │ │ ├── __init__.py │ │ │ │ ├── execd.py │ │ │ │ ├── execd_sync.py │ │ │ │ ├── filesystem.py │ │ │ │ └── sandboxes.py │ │ │ ├── py.typed │ │ │ ├── sandbox.py │ │ │ ├── services/ │ │ │ │ ├── __init__.py │ │ │ │ ├── command.py │ │ │ │ ├── egress.py │ │ │ │ ├── filesystem.py │ │ │ │ ├── health.py │ │ │ │ ├── metrics.py │ │ │ │ └── sandbox.py │ │ │ └── sync/ │ │ │ ├── __init__.py │ │ │ ├── adapters/ │ │ │ │ ├── __init__.py │ │ │ │ ├── command_adapter.py │ │ │ │ ├── converter/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ └── execution_event_dispatcher.py │ │ │ │ ├── egress_adapter.py │ │ │ │ ├── factory.py │ │ │ │ ├── filesystem_adapter.py │ │ │ │ ├── health_adapter.py │ │ │ │ ├── metrics_adapter.py │ │ │ │ └── sandboxes_adapter.py │ │ │ ├── manager.py │ │ │ ├── sandbox.py │ │ │ └── services/ │ │ │ ├── __init__.py │ │ │ ├── command.py │ │ │ ├── egress.py │ │ │ ├── filesystem.py │ │ │ ├── health.py │ │ │ ├── metrics.py │ │ │ └── sandbox.py │ │ └── tests/ │ │ ├── test_adapters_eager_init.py │ │ ├── test_command_service_adapter_streaming.py │ │ ├── test_command_service_sse_client_config.py │ │ ├── test_connection_config.py │ │ ├── test_connection_config_env_and_timeout.py │ │ ├── test_converters_and_error_handling.py │ │ ├── test_filesystem_search_error_handling.py │ │ ├── test_models_stability.py │ │ ├── test_sandbox_business_logic.py │ │ ├── test_sandbox_close_and_connect_validation.py │ │ ├── test_sandbox_manager_business_logic.py │ │ ├── test_sandbox_manager_sync_business_logic.py │ │ ├── test_sandbox_service_adapter_lifecycle.py │ │ └── test_sandbox_sync_business_logic.py │ └── tsconfig.base.json ├── server/ │ ├── .python-version │ ├── DEVELOPMENT.md │ ├── Dockerfile │ ├── LICENSE │ ├── README.md │ ├── README_zh.md │ ├── TROUBLESHOOTING.md │ ├── TROUBLESHOOTING_zh.md │ ├── build.sh │ ├── docker-compose.example.yaml │ ├── example.batchsandbox-template.yaml │ ├── example.config.k8s.toml │ ├── example.config.k8s.zh.toml │ ├── example.config.toml │ ├── example.config.zh.toml │ ├── pyproject.toml │ ├── src/ │ │ ├── __init__.py │ │ ├── api/ │ │ │ ├── __init__.py │ │ │ ├── lifecycle.py │ │ │ └── schema.py │ │ ├── cli.py │ │ ├── config.py │ │ ├── main.py │ │ ├── middleware/ │ │ │ ├── __init__.py │ │ │ ├── auth.py │ │ │ └── request_id.py │ │ ├── py.typed │ │ └── services/ │ │ ├── __init__.py │ │ ├── constants.py │ │ ├── docker.py │ │ ├── endpoint_auth.py │ │ ├── factory.py │ │ ├── helpers.py │ │ ├── k8s/ │ │ │ ├── __init__.py │ │ │ ├── agent_sandbox_provider.py │ │ │ ├── agent_sandbox_template.py │ │ │ ├── batchsandbox_provider.py │ │ │ ├── batchsandbox_template.py │ │ │ ├── client.py │ │ │ ├── egress_helper.py │ │ │ ├── image_pull_secret_helper.py │ │ │ ├── informer.py │ │ │ ├── kubernetes_service.py │ │ │ ├── provider_factory.py │ │ │ ├── rate_limiter.py │ │ │ ├── security_context.py │ │ │ ├── template_manager.py │ │ │ ├── volume_helper.py │ │ │ └── workload_provider.py │ │ ├── ossfs_mixin.py │ │ ├── runtime_resolver.py │ │ ├── sandbox_service.py │ │ └── validators.py │ └── tests/ │ ├── __init__.py │ ├── conftest.py │ ├── k8s/ │ │ ├── __init__.py │ │ ├── conftest.py │ │ ├── fixtures/ │ │ │ ├── __init__.py │ │ │ └── k8s_fixtures.py │ │ ├── test_agent_sandbox_provider.py │ │ ├── test_agent_sandbox_template.py │ │ ├── test_batchsandbox_provider.py │ │ ├── test_batchsandbox_template.py │ │ ├── test_egress_helper.py │ │ ├── test_image_pull_secret_helper.py │ │ ├── test_informer.py │ │ ├── test_k8s_client.py │ │ ├── test_kubernetes_service.py │ │ ├── test_provider_factory.py │ │ └── test_rate_limiter.py │ ├── smoke.sh │ ├── test_agent_sandbox_service.py │ ├── test_auth_middleware.py │ ├── test_config.py │ ├── test_docker_endpoint.py │ ├── test_docker_path_fix.py │ ├── test_docker_service.py │ ├── test_endpoint.py │ ├── test_endpoint_auth.py │ ├── test_helpers.py │ ├── test_ingress.py │ ├── test_routes.py │ ├── test_routes_create_delete.py │ ├── test_routes_endpoint_behavior.py │ ├── test_routes_get_sandbox.py │ ├── test_routes_list_sandboxes.py │ ├── test_routes_pause_resume.py │ ├── test_routes_proxy.py │ ├── test_routes_renew_expiration.py │ ├── test_schema.py │ ├── test_validators.py │ └── testdata/ │ ├── config.toml │ └── k8s_config.toml ├── specs/ │ ├── README.md │ ├── README_zh.md │ ├── egress-api.yaml │ ├── execd-api.yaml │ └── sandbox-lifecycle.yml └── tests/ ├── csharp/ │ └── OpenSandbox.E2ETests/ │ ├── CodeInterpreterE2ETests.cs │ ├── E2ETestFixture.cs │ ├── OpenSandbox.E2ETests.csproj │ ├── SandboxE2ETests.cs │ └── SandboxManagerE2ETests.cs ├── java/ │ ├── build.gradle.kts │ ├── gradle/ │ │ ├── libs.versions.toml │ │ └── wrapper/ │ │ ├── gradle-wrapper.jar │ │ └── gradle-wrapper.properties │ ├── gradle.properties │ ├── gradlew │ ├── settings.gradle.kts │ └── src/ │ └── test/ │ ├── java/ │ │ └── com/ │ │ └── alibaba/ │ │ └── opensandbox/ │ │ └── e2e/ │ │ ├── BaseE2ETest.java │ │ ├── CodeInterpreterE2ETest.java │ │ ├── SandboxE2ETest.java │ │ └── SandboxManagerE2ETest.java │ └── resources/ │ └── test.properties ├── javascript/ │ ├── README.md │ ├── eslint.config.mjs │ ├── package.json │ ├── tests/ │ │ ├── base_e2e.ts │ │ ├── test_code_interpreter_e2e.test.ts │ │ ├── test_sandbox_e2e.test.ts │ │ ├── test_sandbox_manager_e2e.test.ts │ │ └── test_wait_until_ready_diagnostics.test.ts │ ├── tsconfig.json │ └── vitest.config.ts └── python/ ├── Makefile ├── README.md ├── pyproject.toml └── tests/ ├── __init__.py ├── base_e2e_test.py ├── test_code_interpreter_e2e.py ├── test_code_interpreter_e2e_sync.py ├── test_sandbox_e2e.py ├── test_sandbox_e2e_sync.py ├── test_sandbox_manager_e2e.py └── test_sandbox_manager_e2e_sync.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/CODEOWNERS ================================================ # CODEOWNERS for OpenSandbox # Rules are evaluated top-to-bottom; the last matching pattern wins. # Default owners (fallback for files not matched by specific rules) * @jwx0925 @hittyt @hellomypastor @Pangjiping @ninan-nn # Control plane (server) /server/ @Pangjiping @hittyt @jwx0925 @Generalwin @ninan-nn # Runtime agent (execd) and sandbox images /components/execd/ @Pangjiping @hittyt @ninan-nn /components/ingress/ @Pangjiping @hittyt @Generalwin @Spground /components/egress/ @Pangjiping @hittyt @jwx0925 /sandboxes/ @Pangjiping @ninan-nn @jwx0925 @hittyt @hellomypastor # Kubernetes controller /kubernetes/ @Spground @Generalwin @fengcone @kevinlynx @ninan-nn @hittyt @Pangjiping # SDKs /sdks/ @ninan-nn @jwx0925 @hittyt @hellomypastor # Specs and docs /specs/ @jwx0925 @hittyt @ninan-nn # OpenSandbox Enhancement Proposals /oseps/ @Spground @Generalwin @fengcone @kevinlynx @Pangjiping @ninan-nn @jwx0925 @hittyt ================================================ FILE: .github/ISSUE_TEMPLATE/FEATURE_REQUEST.md ================================================ --- name: Feature Request about: Suggest an idea for OpenSandbox title: '' labels: '' assignees: '' --- ## Why do you need it? Is your feature request related to a problem? Please describe in details ## How could it be? A clear and concise description of what you want to happen. You can explain more about input of the feature, and output of it. ## Other related information Add any other context or screenshots about the feature request here. ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: Any Questions or Suggestions? url: https://github.com/alibaba/OpenSandbox/issues about: Please ask and answer questions here. ================================================ FILE: .github/pull_request_template.md ================================================ # Summary - What is changing and why? # Testing - [ ] Not run (explain why) - [ ] Unit tests - [ ] Integration tests - [ ] e2e / manual verification # Breaking Changes - [ ] None - [ ] Yes (describe impact and migration path) # Checklist - [ ] Linked Issue or clearly described motivation - [ ] Added/updated docs (if needed) - [ ] Added/updated tests (if needed) - [ ] Security impact considered - [ ] Backward compatibility considered ================================================ FILE: .github/workflows/deploy-docs-pages.yml ================================================ name: Deploy Docs Pages on: push: branches: - main paths: - "docs/**" - "specs/**" - "scripts/spec-doc/**" - "README.md" - "CONTRIBUTING.md" - "CODE_OF_CONDUCT.md" - "server/**/README*.md" - "server/**/DEVELOPMENT.md" - "components/**/README*.md" - "components/**/DEVELOPMENT.md" - "sdks/**/README*.md" - "sandboxes/**/README*.md" - "kubernetes/**/README*.md" - "examples/**/README*.md" - "specs/**/README*.md" - "oseps/**/*.md" workflow_dispatch: permissions: contents: read pages: write id-token: write concurrency: group: pages cancel-in-progress: false jobs: build: runs-on: ubuntu-latest environment: name: github-pages steps: - name: Checkout uses: actions/checkout@v6 - name: Setup Pages id: pages uses: actions/configure-pages@v5 - name: Setup Node 22 uses: actions/setup-node@v6 with: node-version: "22" - name: Setup pnpm uses: pnpm/action-setup@v4 with: version: 9.15.0 - name: Enable corepack run: corepack enable - name: Install docs dependencies working-directory: docs run: pnpm install --frozen-lockfile - name: Build docs working-directory: docs env: # Use root base when custom domain is configured via CNAME. DOCS_BASE: ${{ hashFiles('docs/public/CNAME') != '' && '/' || steps.pages.outputs.base_path }} run: pnpm docs:build - name: Upload artifact uses: actions/upload-pages-artifact@v4 with: path: docs/.vitepress/dist deploy: runs-on: ubuntu-latest needs: build environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 ================================================ FILE: .github/workflows/egress-test.yaml.yml ================================================ name: Egress Tests on: pull_request: branches: [ main ] paths: - 'components/egress/**' - 'components/internal/**' permissions: contents: read concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: test: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v6 - name: Set up Go uses: actions/setup-go@v6 with: go-version: '1.24.0' - name: Run Build working-directory: components/egress run: | go vet ./... go build . - name: Run tests working-directory: components/egress run: | go test ./... smoke: runs-on: self-hosted steps: - name: Checkout code uses: actions/checkout@v6 - name: Run dns test working-directory: components/egress run: | chmod +x tests/smoke-dns.sh ./tests/smoke-dns.sh - name: Run nft test working-directory: components/egress run: | chmod +x tests/smoke-nft.sh ./tests/smoke-nft.sh - name: Run dynamic ip test working-directory: components/egress run: | chmod +x tests/smoke-dynamic-ip.sh ./tests/smoke-dynamic-ip.sh bench: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v6 - name: Run bench test working-directory: components/egress run: | chmod +x tests/bench-dns-nft.sh ./tests/bench-dns-nft.sh env: BENCH_SAMPLE_SIZE: "20" - name: Upload egress logs if: always() uses: actions/upload-artifact@v7 with: name: egress-log-for-bench path: /tmp/egress-logs/ retention-days: 5 ================================================ FILE: .github/workflows/execd-test.yml ================================================ name: Execd Tests on: pull_request: branches: [ main ] paths: - 'components/execd/**' - 'components/internal/**' permissions: contents: read concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: test: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v6 - name: Set up Go uses: actions/setup-go@v6 with: go-version: '1.24.0' - name: Run golint run: | cd components/execd make golint - name: Build (Multi platform compile) run: | cd components/execd # make multi-build - name: Run tests with coverage run: | cd components/execd go test -v -coverpkg=./... -coverprofile=coverage.out -covermode=atomic ./pkg/... - name: Calculate coverage and generate summary id: coverage run: | cd components/execd # Extract total coverage percentage TOTAL_COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}') echo "total_coverage=$TOTAL_COVERAGE" >> $GITHUB_OUTPUT # Generate GitHub Actions job summary echo "## 📊 execd Test Coverage Report" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "**Total Line Coverage:** $TOTAL_COVERAGE" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "Coverage report generated for commit \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "---" >> $GITHUB_STEP_SUMMARY echo "*Coverage targets: Core packages >80%, API layer >70%*" >> $GITHUB_STEP_SUMMARY smoke: strategy: fail-fast: false matrix: os: [ubuntu-latest, windows-latest] runs-on: ${{ matrix.os }} defaults: run: shell: bash steps: - name: Checkout code uses: actions/checkout@v6 - name: Set up Go uses: actions/setup-go@v6 with: go-version: '1.24.0' - name: Set up Python uses: actions/setup-python@v6 with: python-version: '3.10' - name: Install make (Windows) if: matrix.os == 'windows-latest' shell: powershell run: choco install make -y - name: Build run: | cd components/execd make build - name: Run smoke test run: | cd components/execd chmod +x tests/smoke.sh ./tests/smoke.sh sleep 5 python3 tests/smoke_api.py - name: Show logs if: always() run: | set -x cat components/execd/startup.log || true cat components/execd/execd.log || true ================================================ FILE: .github/workflows/ingress-test.yaml ================================================ name: Ingress Tests on: pull_request: branches: [ main ] paths: - 'components/ingress/**' - 'components/internal/**' concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: test: permissions: contents: read runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v6 - name: Set up Go uses: actions/setup-go@v6 with: go-version: '1.24.0' - name: Run golint working-directory: components/ingress run: | make golint - name: Run Build working-directory: components/ingress run: | make build - name: Run tests working-directory: components/ingress run: | make test ================================================ FILE: .github/workflows/publish-components.yml ================================================ name: Publish Components Image permissions: # required for bump step to push branch and create PR contents: write pull-requests: write on: workflow_dispatch: inputs: component: description: 'Component to build' required: true type: choice options: - execd - code-interpreter - ingress - egress - controller - task-executor default: 'execd' image_tag: description: 'Docker image tag' required: true default: 'latest' push: tags: - 'docker/execd/**' - 'docker/code-interpreter/**' - 'docker/ingress/**' - 'docker/egress/**' - 'k8s/controller/**' - 'k8s/task-executor/**' jobs: publish: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v6 - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to DockerHub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_PASSWORD }} - name: Login to ACR uses: docker/login-action@v3 with: registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com username: ${{ secrets.ACR_USERNAME }} password: ${{ secrets.ACR_PASSWORD }} - name: Parse tag and set variables id: parse_tag run: | if [[ "${{ github.ref }}" == refs/tags/docker/* ]]; then TAG_PATH="${{ github.ref }}" TAG_PATH="${TAG_PATH#refs/tags/}" COMPONENT=$(echo "$TAG_PATH" | cut -d'/' -f2) IMAGE_TAG=$(echo "$TAG_PATH" | cut -d'/' -f3) echo "component=$COMPONENT" >> $GITHUB_OUTPUT echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT elif [[ "${{ github.ref }}" == refs/tags/k8s/* ]]; then TAG_PATH="${{ github.ref }}" TAG_PATH="${TAG_PATH#refs/tags/}" COMPONENT=$(echo "$TAG_PATH" | cut -d'/' -f2) IMAGE_TAG=$(echo "$TAG_PATH" | cut -d'/' -f3) echo "component=$COMPONENT" >> $GITHUB_OUTPUT echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT else echo "component=${{ inputs.component }}" >> $GITHUB_OUTPUT echo "image_tag=${{ inputs.image_tag }}" >> $GITHUB_OUTPUT fi - name: Free disk space run: | sudo rm -rf /usr/share/dotnet /opt/ghc /opt/hostedtoolcache sudo apt-get clean sudo rm -rf /var/lib/apt/lists/* df -h - name: Build and push to registries run: | COMPONENT="${{ steps.parse_tag.outputs.component }}" IMAGE_TAG="${{ steps.parse_tag.outputs.image_tag }}" if [ "$COMPONENT" == "execd" ]; then cd components/execd elif [ "$COMPONENT" == "ingress" ]; then cd components/ingress elif [ "$COMPONENT" == "egress" ]; then cd components/egress elif [ "$COMPONENT" == "controller" ]; then cd kubernetes elif [ "$COMPONENT" == "task-executor" ]; then cd kubernetes else cd sandboxes/$COMPONENT fi export TAG=$IMAGE_TAG export COMPONENT=$COMPONENT chmod +x build.sh ./build.sh - name: Bump component version in repo if: steps.parse_tag.outputs.image_tag != 'latest' && steps.parse_tag.outputs.image_tag != '' env: GH_TOKEN: ${{ github.token }} run: | COMPONENT="${{ steps.parse_tag.outputs.component }}" IMAGE_TAG="${{ steps.parse_tag.outputs.image_tag }}" # Ensure version has 'v' prefix for bump script if [[ "$IMAGE_TAG" =~ ^v ]]; then VERSION="$IMAGE_TAG" else VERSION="v${IMAGE_TAG}" fi ./scripts/bump-component-version.sh "$COMPONENT" "$VERSION" BRANCH="bump/${COMPONENT}-${VERSION}" git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git checkout -b "$BRANCH" git add -A git diff --staged --quiet && echo "No changes to commit" && exit 0 git commit -m "chore: bump $COMPONENT to $VERSION" git push origin "$BRANCH" gh pr create \ --title "chore: bump $COMPONENT to $VERSION" \ --body "Auto-generated by Publish Components workflow after building \`$COMPONENT:$VERSION\`." \ --base "$(gh api repos/${{ github.repository }} --jq .default_branch)" ================================================ FILE: .github/workflows/publish-csharp-sdks.yml ================================================ # Copyright 2026 Alibaba Group Holding Ltd. # # 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. name: Publish C# SDKs on: push: tags: - "csharp/sandbox/v*" - "csharp/code-interpreter/v*" permissions: contents: read jobs: publish: name: Publish (${{ matrix.sdk.name }}) runs-on: ubuntu-latest strategy: fail-fast: false matrix: sdk: - name: sandbox tagPrefix: sandbox csprojPath: sdks/sandbox/csharp/src/OpenSandbox/OpenSandbox.csproj - name: code-interpreter tagPrefix: code-interpreter csprojPath: sdks/code-interpreter/csharp/src/OpenSandbox.CodeInterpreter/OpenSandbox.CodeInterpreter.csproj steps: - name: Checkout code uses: actions/checkout@v6 - name: Set up .NET uses: actions/setup-dotnet@v5 with: dotnet-version: "10.0.x" - name: Parse package version from tag if: startsWith(github.ref, format('refs/tags/csharp/{0}/v', matrix.sdk.tagPrefix)) shell: bash run: | VERSION="${GITHUB_REF_NAME#csharp/${{ matrix.sdk.tagPrefix }}/v}" echo "PACKAGE_VERSION=$VERSION" >> "$GITHUB_ENV" - name: Restore if: startsWith(github.ref, format('refs/tags/csharp/{0}/v', matrix.sdk.tagPrefix)) run: | EXTRA_RESTORE_ARGS="" if [ "${{ matrix.sdk.name }}" = "code-interpreter" ]; then EXTRA_RESTORE_ARGS="-p:UseLocalOpenSandboxProjectReference=false" fi dotnet restore "${{ matrix.sdk.csprojPath }}" ${EXTRA_RESTORE_ARGS} - name: Pack if: startsWith(github.ref, format('refs/tags/csharp/{0}/v', matrix.sdk.tagPrefix)) run: | EXTRA_PACK_ARGS="" if [ "${{ matrix.sdk.name }}" = "code-interpreter" ]; then EXTRA_PACK_ARGS="-p:UseLocalOpenSandboxProjectReference=false" fi dotnet pack "${{ matrix.sdk.csprojPath }}" \ --configuration Release \ --no-restore \ -p:PackageVersion="${PACKAGE_VERSION}" \ -p:ContinuousIntegrationBuild=true \ ${EXTRA_PACK_ARGS} \ --output ./artifacts/${{ matrix.sdk.name }} - name: Publish to NuGet if: startsWith(github.ref, format('refs/tags/csharp/{0}/v', matrix.sdk.tagPrefix)) env: NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} run: | dotnet nuget push "./artifacts/${{ matrix.sdk.name }}/*.nupkg" \ --api-key "$NUGET_API_KEY" \ --source "https://api.nuget.org/v3/index.json" \ --skip-duplicate ================================================ FILE: .github/workflows/publish-helm-chart.yml ================================================ name: Publish Helm Chart on: workflow_dispatch: inputs: component: description: 'Component to release' required: true type: choice options: - opensandbox-controller - opensandbox-server - opensandbox default: 'opensandbox-controller' app_version: description: 'App version (without v prefix, e.g., 0.1.0)' required: true default: '0.1.0' push: tags: - 'helm/**' # Format: helm//, e.g., helm/opensandbox-controller/0.1.0 jobs: publish: runs-on: ubuntu-latest permissions: contents: write steps: - name: Checkout code uses: actions/checkout@v4 with: fetch-depth: 0 - name: Configure Git run: | git config user.name "$GITHUB_ACTOR" git config user.email "$GITHUB_ACTOR@users.noreply.github.com" - name: Install Helm uses: azure/setup-helm@v4 with: version: 'latest' - name: Parse tag and set variables id: parse_tag run: | if [[ "${{ github.ref }}" == refs/tags/helm/* ]]; then TAG_PATH="${{ github.ref }}" TAG_PATH="${TAG_PATH#refs/tags/}" COMPONENT=$(echo "$TAG_PATH" | cut -d'/' -f2) VERSION=$(echo "$TAG_PATH" | cut -d'/' -f3) # Remove 'v' prefix if present VERSION=${VERSION#v} echo "component=$COMPONENT" >> $GITHUB_OUTPUT echo "app_version=$VERSION" >> $GITHUB_OUTPUT else echo "component=${{ inputs.component }}" >> $GITHUB_OUTPUT echo "app_version=${{ inputs.app_version }}" >> $GITHUB_OUTPUT fi - name: Set chart path id: chart_path run: | COMPONENT="${{ steps.parse_tag.outputs.component }}" if [ "$COMPONENT" == "opensandbox-controller" ]; then CHART_PATH="kubernetes/charts/opensandbox-controller" elif [ "$COMPONENT" == "opensandbox-server" ]; then CHART_PATH="kubernetes/charts/opensandbox-server" elif [ "$COMPONENT" == "opensandbox" ]; then CHART_PATH="kubernetes/charts/opensandbox" else echo "Error: Unknown component: $COMPONENT" exit 1 fi echo "path=$CHART_PATH" >> $GITHUB_OUTPUT - name: Get chart version from Chart.yaml id: chart_version run: | CHART_PATH="${{ steps.chart_path.outputs.path }}" CHART_VERSION=$(grep '^version:' $CHART_PATH/Chart.yaml | awk '{print $2}') echo "version=$CHART_VERSION" >> $GITHUB_OUTPUT echo "Chart version: $CHART_VERSION" - name: Update Chart.yaml with app version run: | APP_VERSION="${{ steps.parse_tag.outputs.app_version }}" CHART_PATH="${{ steps.chart_path.outputs.path }}" # Only update appVersion, keep chart version as-is in Chart.yaml sed -i "s/^appVersion:.*/appVersion: \"$APP_VERSION\"/" $CHART_PATH/Chart.yaml echo "Updated Chart.yaml:" cat $CHART_PATH/Chart.yaml - name: Build dependencies (for opensandbox all-in-one chart) if: ${{ steps.parse_tag.outputs.component == 'opensandbox' }} run: | CHART_PATH="${{ steps.chart_path.outputs.path }}" echo "Building dependencies for all-in-one chart..." helm dependency build $CHART_PATH - name: Lint Helm chart run: | CHART_PATH="${{ steps.chart_path.outputs.path }}" helm lint $CHART_PATH - name: Package Helm chart run: | CHART_PATH="${{ steps.chart_path.outputs.path }}" helm package $CHART_PATH - name: Create GitHub Release uses: softprops/action-gh-release@v1 with: tag_name: helm/${{ steps.parse_tag.outputs.component }}/${{ steps.parse_tag.outputs.app_version }} name: Helm Chart ${{ steps.parse_tag.outputs.component }} ${{ steps.chart_version.outputs.version }} (App v${{ steps.parse_tag.outputs.app_version }}) body: | ## ${{ steps.parse_tag.outputs.component }} Helm Chart **Chart Version:** ${{ steps.chart_version.outputs.version }} **App Version:** ${{ steps.parse_tag.outputs.app_version }} ### Installation 直接从 GitHub Release 安装: ```bash helm install ${{ steps.parse_tag.outputs.component }} \ https://github.com/${{ github.repository }}/releases/download/helm/${{ steps.parse_tag.outputs.component }}/${{ steps.parse_tag.outputs.app_version }}/${{ steps.parse_tag.outputs.component }}-${{ steps.chart_version.outputs.version }}.tgz \ --namespace opensandbox-system \ --create-namespace ``` 或者先下载后安装: ```bash # 下载 wget https://github.com/${{ github.repository }}/releases/download/helm/${{ steps.parse_tag.outputs.component }}/${{ steps.parse_tag.outputs.app_version }}/${{ steps.parse_tag.outputs.component }}-${{ steps.chart_version.outputs.version }}.tgz # 安装 helm install ${{ steps.parse_tag.outputs.component }} ./${{ steps.parse_tag.outputs.component }}-${{ steps.chart_version.outputs.version }}.tgz \ --namespace opensandbox-system \ --create-namespace ``` ${{ steps.parse_tag.outputs.component == 'opensandbox' && '**Note**: This is an all-in-one chart that bundles controller and server. The packaged chart already includes all dependencies, no need to run `helm dependency build` when installing from release.' || '' }} ### What's Changed - Chart version: ${{ steps.chart_version.outputs.version }} - App version: ${{ steps.parse_tag.outputs.app_version }} files: | ${{ steps.parse_tag.outputs.component }}-*.tgz draft: false prerelease: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/publish-java-sdks.yml ================================================ name: Publish Java SDKs on: push: tags: - "java/sandbox/v*" - "java/code-interpreter/v*" permissions: contents: read jobs: publish: name: Publish (${{ matrix.sdk.name }}) runs-on: ubuntu-latest strategy: fail-fast: false matrix: sdk: - name: sandbox tagPrefix: sandbox workingDirectory: sdks/sandbox/kotlin - name: code-interpreter tagPrefix: code-interpreter workingDirectory: sdks/code-interpreter/kotlin steps: - name: Checkout code uses: actions/checkout@v6 - name: Set up Java uses: actions/setup-java@v5 with: distribution: temurin java-version: "17" - name: Set up Gradle uses: gradle/actions/setup-gradle@v5 - name: Publish to Maven Central working-directory: ${{ matrix.sdk.workingDirectory }} if: startsWith(github.ref, format('refs/tags/java/{0}/v', matrix.sdk.tagPrefix)) env: ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.ORG_GRADLE_PROJECT_MAVENCENTRALUSERNAME }} ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.ORG_GRADLE_PROJECT_MAVENCENTRALPASSWORD }} ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.ORG_GRADLE_PROJECT_SIGNINGINMEMORYKEY }} ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.ORG_GRADLE_PROJECT_SIGNINGINMEMORYKEYPASSWORD }} run: | ./gradlew publishAndReleaseToMavenCentral ================================================ FILE: .github/workflows/publish-js-sdks.yml ================================================ name: Publish JavaScript SDKs on: push: tags: - "js/sandbox/v*" - "js/code-interpreter/v*" permissions: contents: read jobs: publish: name: Publish (${{ matrix.sdk.name }}) runs-on: ubuntu-latest strategy: fail-fast: false matrix: sdk: - name: sandbox tagPrefix: sandbox workingDirectory: sdks/sandbox/javascript packageName: "@alibaba-group/opensandbox" - name: code-interpreter tagPrefix: code-interpreter workingDirectory: sdks/code-interpreter/javascript packageName: "@alibaba-group/opensandbox-code-interpreter" steps: - name: Checkout code uses: actions/checkout@v6 - name: Set up Node uses: actions/setup-node@v6 with: node-version: "20" registry-url: "https://registry.npmjs.org" - name: Set up pnpm uses: pnpm/action-setup@v4 with: version: latest - name: Enable corepack run: corepack enable - name: Get pnpm store path id: pnpm-store run: echo "STORE_PATH=$(corepack pnpm store path)" >> "$GITHUB_OUTPUT" - name: Cache pnpm store uses: actions/cache@v5 with: path: ${{ steps.pnpm-store.outputs.STORE_PATH }} key: ${{ runner.os }}-pnpm-${{ hashFiles('sdks/pnpm-lock.yaml') }} restore-keys: ${{ runner.os }}-pnpm- - name: Install workspace dependencies working-directory: sdks run: corepack pnpm install --frozen-lockfile - name: Build SDK working-directory: sdks run: corepack pnpm --filter ${{ matrix.sdk.packageName }}... --sort run build - name: Publish to npm if: startsWith(github.ref, format('refs/tags/js/{0}/v', matrix.sdk.tagPrefix)) working-directory: ${{ matrix.sdk.workingDirectory }} env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | corepack pnpm publish --access public --no-git-checks ================================================ FILE: .github/workflows/publish-python-sdks.yml ================================================ # Copyright 2025 Alibaba Group Holding Ltd. # # 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. name: Publish Python SDKs permissions: contents: read on: push: tags: - "python/sandbox/v*" - "python/code-interpreter/v*" - "python/mcp/sandbox/v*" jobs: publish-sandbox: if: startsWith(github.ref, 'refs/tags/python/sandbox/v') runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 with: python-version: '3.10' - name: Install uv uses: astral-sh/setup-uv@v7 with: version: "latest" - name: Generate API working-directory: sdks/sandbox/python run: | uv run python scripts/generate_api.py - name: Build package working-directory: sdks/sandbox/python run: | uv build - name: Publish to PyPI working-directory: sdks/sandbox/python env: UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }} run: | uv publish publish-code-interpreter: if: startsWith(github.ref, 'refs/tags/python/code-interpreter/v') runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 with: python-version: '3.10' - name: Install uv uses: astral-sh/setup-uv@v7 with: version: "latest" - name: Build package working-directory: sdks/code-interpreter/python run: | uv build - name: Publish to PyPI working-directory: sdks/code-interpreter/python env: UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }} run: | uv publish publish-mcp-sandbox: if: startsWith(github.ref, 'refs/tags/python/mcp/sandbox/v') runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 with: python-version: "3.10" - name: Install uv uses: astral-sh/setup-uv@v7 with: version: "latest" - name: Build package working-directory: sdks/mcp/sandbox/python run: | uv build - name: Publish to PyPI working-directory: sdks/mcp/sandbox/python env: UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }} run: | uv publish ================================================ FILE: .github/workflows/publish-server.yml ================================================ # Copyright 2026 Alibaba Group Holding Ltd. # # 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. name: Publish Server on: push: tags: - 'server/v*' permissions: contents: read jobs: publish-pypi: if: startsWith(github.ref, 'refs/tags/server/v') runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 with: python-version: '3.10' - name: Install uv uses: astral-sh/setup-uv@v7 with: version: "latest" - name: Build package working-directory: server run: | uv build - name: Publish to PyPI working-directory: server env: UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }} run: | uv publish publish-image: if: startsWith(github.ref, 'refs/tags/server/v') runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v6 - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to DockerHub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_PASSWORD }} - name: Login to ACR uses: docker/login-action@v3 with: registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com username: ${{ secrets.ACR_USERNAME }} password: ${{ secrets.ACR_PASSWORD }} - name: Parse tag and set variables id: parse_tag run: | if [[ "${{ github.ref }}" == refs/tags/server/* ]]; then TAG_PATH="${{ github.ref }}" TAG_PATH="${TAG_PATH#refs/tags/}" IMAGE_TAG="${TAG_PATH#server/}" if [ -z "$IMAGE_TAG" ]; then echo "failed to parse image tag from $TAG_PATH" >&2 exit 1 fi echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT else echo "cannot parse tag" exit 1 fi - name: Build and push to registries working-directory: server env: TAG: ${{ steps.parse_tag.outputs.image_tag }} run: | chmod +x build.sh ./build.sh ================================================ FILE: .github/workflows/real-e2e.yml ================================================ name: Real E2E Tests permissions: contents: read on: pull_request: branches: [ main ] paths: - 'server/src/**' - 'components/execd/**' - 'components/egress/**' - 'sdks/code-interpreter/**' - 'sdks/sandbox/**' - 'tests/**' push: branches: [ main ] concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: python-e2e: name: Python E2E (docker bridge) runs-on: self-hosted env: UV_BIN: /home/admin/.local/bin steps: - name: Checkout code uses: actions/checkout@v6 - name: Set up uv PATH and verify run: | echo "${UV_BIN}" >> "$GITHUB_PATH" export PATH="${UV_BIN}:${PATH}" uv --version uv run python --version - name: Clean up previous E2E resources run: | docker ps -aq --filter "label=opensandbox" | xargs -r docker rm -f || true # Remove root-owned files from previous sandbox runs by mounting parent dir docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e || true - name: Build local egress image run: docker build -t opensandbox/egress:local -f components/egress/Dockerfile . - name: Run tests run: | set -e # Create config file cat < ~/.sandbox.toml [server] host = "127.0.0.1" port = 8080 log_level = "INFO" api_key = "" [runtime] type = "docker" execd_image = "opensandbox/execd:local" [egress] image = "opensandbox/egress:local" mode = "dns" [docker] network_mode = "bridge" [storage] allowed_host_paths = ["/tmp/opensandbox-e2e"] EOF ./scripts/python-e2e.sh - name: Eval server logs if: ${{ always() }} run: cat server/server.log - name: Upload execd logs if: always() uses: actions/upload-artifact@v7 with: name: execd-log-for-python-e2e path: /tmp/opensandbox-e2e/logs/ retention-days: 5 - name: Clean up after E2E if: always() run: | docker ps -aq --filter "label=opensandbox" | xargs -r docker rm -f || true docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e || true pkill -f "python -m src.main" || true java-e2e: name: Java E2E (docker bridge) runs-on: self-hosted env: UV_BIN: /home/admin/.local/bin steps: - name: Checkout code uses: actions/checkout@v6 - name: Set up uv PATH and verify run: | echo "${UV_BIN}" >> "$GITHUB_PATH" export PATH="${UV_BIN}:${PATH}" uv --version uv run python --version - name: Set up JDK 8 uses: actions/setup-java@v5 with: distribution: temurin java-version: "8" - name: Set up JDK 17 uses: actions/setup-java@v5 with: distribution: temurin java-version: "17" - name: Clean up previous E2E resources run: | docker ps -aq --filter "label=opensandbox" | xargs -r docker rm -f || true docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e || true - name: Build local egress image run: docker build -t opensandbox/egress:local -f components/egress/Dockerfile . - name: Run tests env: GRADLE_USER_HOME: ${{ github.workspace }}/.gradle-user-home run: | set -e export GRADLE_OPTS="-Dorg.gradle.java.installations.auto-detect=true -Dorg.gradle.java.installations.auto-download=false -Dorg.gradle.java.installations.paths=${JAVA_HOME_8_X64},${JAVA_HOME_17_X64}" # Create config file cat < ~/.sandbox.toml [server] host = "127.0.0.1" port = 8080 log_level = "INFO" api_key = "" [runtime] type = "docker" execd_image = "opensandbox/execd:local" [egress] image = "opensandbox/egress:local" mode = "dns+nft" [docker] network_mode = "bridge" [storage] allowed_host_paths = ["/tmp/opensandbox-e2e"] EOF bash ./scripts/java-e2e.sh - name: Eval server logs if: ${{ always() }} run: cat server/server.log - name: Upload Test Report if: always() uses: actions/upload-artifact@v7 with: name: java-test-report path: tests/java/build/reports/tests/test/ retention-days: 5 - name: Upload execd logs if: always() uses: actions/upload-artifact@v7 with: name: execd-log-for-java-e2e path: /tmp/opensandbox-e2e/logs/ retention-days: 5 - name: Clean up after E2E if: always() run: | docker ps -aq --filter "label=opensandbox" | xargs -r docker rm -f || true docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e || true pkill -f "python -m src.main" || true javascript-e2e: name: JavaScript E2E (docker bridge) runs-on: self-hosted env: UV_BIN: /home/admin/.local/bin NODE_VERSION: "20.19.0" steps: - name: Checkout code uses: actions/checkout@v6 - name: Set up uv PATH and verify run: | echo "${UV_BIN}" >> "$GITHUB_PATH" export PATH="${UV_BIN}:${PATH}" uv --version uv run python --version - name: Set up Node.js run: | NODE_DIR="/home/admin/.local/node-v${NODE_VERSION}-linux-x64" if [ -x "${NODE_DIR}/bin/node" ]; then echo "Node.js ${NODE_VERSION} already cached" else echo "Downloading Node.js ${NODE_VERSION}..." mkdir -p /home/admin/.local curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" \ | tar -xJ -C /home/admin/.local/ fi echo "${NODE_DIR}/bin" >> "$GITHUB_PATH" export PATH="${NODE_DIR}/bin:${PATH}" node --version npm --version - name: Clean up previous E2E resources run: | docker ps -aq --filter "label=opensandbox" | xargs -r docker rm -f || true docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e || true - name: Build local egress image run: docker build -t opensandbox/egress:local -f components/egress/Dockerfile . - name: Run tests run: | set -e # Create config file (match other E2E jobs) cat < ~/.sandbox.toml [server] host = "127.0.0.1" port = 8080 log_level = "INFO" api_key = "" [runtime] type = "docker" execd_image = "opensandbox/execd:local" [egress] image = "opensandbox/egress:local" [docker] network_mode = "bridge" [storage] allowed_host_paths = ["/tmp/opensandbox-e2e"] EOF bash ./scripts/javascript-e2e.sh - name: Eval server logs if: ${{ always() }} run: cat server/server.log - name: Upload Test Report if: always() uses: actions/upload-artifact@v7 with: name: javascript-test-report path: tests/javascript/build/test-results/junit.xml retention-days: 5 - name: Upload execd logs if: always() uses: actions/upload-artifact@v7 with: name: execd-log-for-js-e2e path: /tmp/opensandbox-e2e/logs/ retention-days: 5 - name: Clean up after E2E if: always() run: | docker ps -aq --filter "label=opensandbox" | xargs -r docker rm -f || true docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e || true pkill -f "python -m src.main" || true csharp-e2e: name: C# E2E (docker bridge) runs-on: self-hosted env: UV_BIN: /home/admin/.local/bin steps: - name: Checkout code uses: actions/checkout@v6 - name: Set up uv PATH and verify run: | echo "${UV_BIN}" >> "$GITHUB_PATH" export PATH="${UV_BIN}:${PATH}" uv --version uv run python --version - name: Set up .NET SDK uses: actions/setup-dotnet@v5 env: DOTNET_INSTALL_DIR: /home/admin/.local/dotnet with: dotnet-version: "10.0.x" - name: Clean up previous E2E resources run: | docker ps -aq --filter "label=opensandbox" | xargs -r docker rm -f || true docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e || true - name: Build local egress image run: docker build -t opensandbox/egress:local -f components/egress/Dockerfile . - name: Run tests run: | set -e cat < ~/.sandbox.toml [server] host = "127.0.0.1" port = 8080 log_level = "INFO" api_key = "" [runtime] type = "docker" execd_image = "opensandbox/execd:local" [egress] image = "opensandbox/egress:local" [docker] network_mode = "bridge" [storage] allowed_host_paths = ["/tmp/opensandbox-e2e"] EOF bash ./scripts/csharp-e2e.sh - name: Eval server logs if: ${{ always() }} run: cat server/server.log - name: Upload Test Report if: always() uses: actions/upload-artifact@v7 with: name: csharp-test-report path: tests/csharp/build/test-results/ retention-days: 5 - name: Upload execd logs if: always() uses: actions/upload-artifact@v7 with: name: execd-log-for-csharp-e2e path: /tmp/opensandbox-e2e/logs/ retention-days: 5 - name: Clean up after E2E if: always() run: | docker ps -aq --filter "label=opensandbox" | xargs -r docker rm -f || true docker run --rm -v /tmp:/host_tmp alpine rm -rf /host_tmp/opensandbox-e2e || true pkill -f "python -m src.main" || true ================================================ FILE: .github/workflows/sandbox-k8s-e2e.yml ================================================ name: Sandbox K8S E2E Tests on: pull_request: branches: [ main ] paths: - 'kubernetes/**' permissions: contents: read concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true env: GO_VERSION: '1.24' jobs: e2e-k8s: name: E2E Tests (K8s v${{ matrix.k8s-version }}) strategy: fail-fast: false matrix: k8s-version: ["1.21.1", "1.22.4", "1.24.4", "1.26.4", "1.28.6", "1.30.4", "1.32.2", "1.34.2"] runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v6 - name: Set up Go uses: actions/setup-go@v6 with: go-version: ${{ env.GO_VERSION }} - name: Run tests run: | cd kubernetes make test-e2e KIND_K8S_VERSION=v${{ matrix.k8s-version }} ================================================ FILE: .github/workflows/sandbox-k8s-test.yml ================================================ name: Sandbox K8S Tests on: pull_request: branches: [ main ] paths: - 'kubernetes/**' permissions: contents: read concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: test: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v6 - name: Set up Go uses: actions/setup-go@v6 with: go-version: '1.24.0' - name: Run golint run: | cd kubernetes make lint - name: Build binary run: | cd kubernetes make build make task-executor-build - name: Run tests run: | cd kubernetes make test ================================================ FILE: .github/workflows/sdk-tests.yml ================================================ # Copyright 2025 Alibaba Group Holding Ltd. # # 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. name: SDK Tests on: pull_request: branches: [main] paths: - "sdks/sandbox/**" - "sdks/code-interpreter/**" - "specs/**" push: branches: [main] paths: - "sdks/sandbox/**" - "sdks/code-interpreter/**" - "specs/**" permissions: contents: read concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: python-sdk-quality: name: Python SDK Quality (${{ matrix.package_name }}) runs-on: ubuntu-latest strategy: fail-fast: false matrix: include: - package_name: sandbox package_dir: sdks/sandbox/python - package_name: code-interpreter package_dir: sdks/code-interpreter/python steps: - name: Checkout code uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 with: python-version: "3.11" - name: Install uv uses: astral-sh/setup-uv@v7 with: version: "latest" - name: Install dependencies working-directory: ${{ matrix.package_dir }} run: | uv sync - name: Generate API if: matrix.package_name == 'sandbox' working-directory: sdks/sandbox/python run: | uv run python scripts/generate_api.py - name: Run ruff working-directory: ${{ matrix.package_dir }} run: | uv run ruff check - name: Run pyright working-directory: ${{ matrix.package_dir }} run: | uv run pyright python-sdk: name: Python SDK Tests runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 with: python-version: "3.11" - name: Install uv uses: astral-sh/setup-uv@v7 with: version: "latest" - name: Generate API working-directory: sdks/sandbox/python run: | uv sync uv run python scripts/generate_api.py - name: Run tests working-directory: sdks/sandbox/python run: | uv run pytest tests/ -v kotlin-sdk: name: Kotlin SDK Tests runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v6 - name: Set up Java uses: actions/setup-java@v5 with: distribution: temurin java-version: "17" - name: Set up Gradle uses: gradle/actions/setup-gradle@v5 - name: Run tests working-directory: sdks/sandbox/kotlin run: | ./gradlew :sandbox:test csharp-sdk: name: C# SDK Tests runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v6 - name: Set up .NET 10 uses: actions/setup-dotnet@v5 with: dotnet-version: "10.0.x" - name: Run sandbox tests working-directory: sdks/sandbox/csharp run: | dotnet test tests/OpenSandbox.Tests/OpenSandbox.Tests.csproj --configuration Release - name: Run code interpreter tests working-directory: sdks/code-interpreter/csharp run: | dotnet test tests/OpenSandbox.CodeInterpreter.Tests/OpenSandbox.CodeInterpreter.Tests.csproj --configuration Release ================================================ FILE: .github/workflows/server-test.yml ================================================ name: Server Tests on: pull_request: branches: [ main ] paths: - 'server/src/**' - 'server/tests/**' permissions: contents: read concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: test: strategy: fail-fast: false matrix: os: [ubuntu-latest, windows-latest] runs-on: ${{ matrix.os }} defaults: run: shell: bash steps: - name: Checkout code uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 with: python-version: '3.10' - name: Install uv run: | pip install uv - name: Run tests run: | cd server uv sync --all-groups uv run ruff check uv run pytest docker-smoke: strategy: matrix: network: [host, bridge] runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 with: python-version: '3.10' - name: Install uv run: | pip install uv - name: Set up Docker run: | docker --version - name: Run smoke test run: | set -e cd server uv sync --all-groups # Create config file cat < ~/.sandbox.toml [server] host = "127.0.0.1" port = 32888 log_level = "INFO" api_key = "" [runtime] type = "docker" execd_image = "opensandbox/execd:latest" [egress] image = "opensandbox/egress:latest" [docker] network_mode = "${{ matrix.network }}" [storage] allowed_host_paths = ["/tmp/opensandbox-e2e"] EOF # Start server in background uv run python -m src.main > app.log 2>&1 & # Wait for server to start sleep 10 # Run smoke test chmod +x tests/smoke.sh ./tests/smoke.sh - name: Show logs if: always() run: | cat server/app.log ================================================ FILE: .github/workflows/verify-license.yml ================================================ name: Verify License Headers on: pull_request: branches: [ main ] concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: verify-license: runs-on: self-hosted steps: - name: Checkout code uses: actions/checkout@v6 with: fetch-depth: 0 - name: Run license verification run: | chmod +x scripts/verify-license.sh ./scripts/verify-license.sh ================================================ FILE: .gitignore ================================================ # IDE and Editor files .vscode/ .idea/ *.swp *.swo *~ .DS_Store Thumbs.db # Go # Binaries for programs and plugins *.exe *.exe~ *.dll *.so *.dylib # Test binary, built with `go test -c` *.test # Output of the go coverage tool *.out # Dependency directories vendor/ # Go workspace file go.work # Java/Kotlin # Compiled class file *.class # Log file *.log # BlueJ files *.ctxt # Mobile Tools for Java (J2ME) .mtj.tmp/ # Package Files *.jar *.war *.nar *.ear *.zip *.tar.gz *.rar # virtual machine crash logs hs_err_pid* replay_pid* # Gradle .gradle/ build/ !**/gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ !**/src/test/**/build/ # Maven target/ pom.xml.tag pom.xml.releaseBackup pom.xml.versionsBackup pom.xml.next release.properties dependency-reduced-pom.xml buildNumber.properties .mvn/timing.properties .mvn/wrapper/maven-wrapper.jar # Node.js # Logs npm-debug.log* yarn-debug.log* yarn-error.log* lerna-debug.log* .pnpm-debug.log* # Dependency directories node_modules/ jspm_packages/ # TypeScript cache *.tsbuildinfo # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Optional stylelint cache .stylelintcache # Microbundle cache .rpt2_cache/ .rts2_cache_cjs/ .rts2_cache_es/ .rts2_cache_umd/ # Optional REPL history .node_repl_history # Output of 'npm pack' *.tgz # Yarn Integrity file .yarn-integrity # Yarn v2 .yarn/cache .yarn/unplugged .yarn/build-state.yml .yarn/install-state.gz .pnp.* # parcel-bundler cache (https://parceljs.org/) .cache .parcel-cache # Next.js build output .next out # Nuxt.js build / generate output .nuxt dist # Gatsby files .cache/ public !docs/public/ !docs/public/CNAME # Serverless directories .serverless/ # FuseBox cache .fusebox/ # DynamoDB Local files .dynamodb/ # TernJS port file .tern-port # Stores VSCode versions used for testing VSCode extensions .vscode-test # yarn v2 .yarn/cache .yarn/unplugged .yarn/build-state.yml .yarn/install-state.gz .pnp.* # Python __pycache__/ *.py[cod] *$py.class *.so .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # Virtual environments venv/ env/ ENV/ env.bak/ venv.bak/ # Docker *.pid *.seed *.pid.lock # OS generated files .DS_Store .DS_Store? ._* .Spotlight-V100 .Trashes ehthumbs.db Thumbs.db # Temporary files *.tmp *.temp *~ # Environment variables .env .env.local .env.development.local .env.test.local .env.production.local # API keys and secrets secrets/ *.pem *.key *.crt *.p12 *.pfx # Generated API documentation docs/generated/ docs/.vitepress/generated/ docs/.vitepress/dist/ docs/.vitepress/cache/ apidocs/ # Test results test-results/ coverage/ *.coverage .nyc_output # Backup files *.bak *.backup *.old # Flattened POM files (Maven) .flattened-pom.xml # Kotlin *.kotlin_module # JetBrains specific .idea/ *.iml *.ipr *.iws out/ # Eclipse specific .project .classpath .settings/ bin/ # NetBeans specific nbproject/ nbbuild/ nbdist/ .nb-gradle/ # Generated files generated/ **/generated/** # gVisor runtime binaries (downloaded dynamically) kubernetes/test/kind/gvisor/runsc kubernetes/test/kind/gvisor/containerd-shim-runsc-v1 bin/ obj/ ================================================ FILE: .pre-commit-config.yaml ================================================ # Minimal cross-language pre-commit hooks # Install: pip install pre-commit && pre-commit install # Run once on all files: pre-commit run --all-files repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.6.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: mixed-line-ending - id: check-merge-conflict - id: check-yaml - id: detect-private-key # Language-specific formatters/linters can be added later, for example: # - repo: local # hooks: # - id: gofmt # name: gofmt # entry: gofmt # language: system # types: [go] # - id: ruff # name: ruff # entry: ruff check # language: system # types: [python] ================================================ FILE: AGENTS.md ================================================ # Repository Guidelines ## Project Structure & Module Organization - `server/`: Python FastAPI service, configs, and tests. - `components/execd/`: Go execution daemon and related tests. - `sdks/`: Multi-language SDKs (`sdks/sandbox/*`, `sdks/code-interpreter/*`). - `sandboxes/`: Runtime sandbox implementations (e.g., `sandboxes/code-interpreter/`). - `specs/`: OpenAPI specs (`specs/execd-api.yaml`, `specs/sandbox-lifecycle.yml`). - `examples/`: End-to-end usage examples and integrations. - `tests/`: Cross-component/E2E tests (`tests/python/`, `tests/java/`). - `docs/`, `oseps/`, `scripts/`: Docs, proposals, and automation scripts. ## Build, Test, and Development Commands - Server (Python): - `cd server && uv sync` installs deps. - `cp server/example.config.toml ~/.sandbox.toml` sets local config. - `cd server && uv run python -m src.main` runs the API server. - execd (Go): - `cd components/execd && go build -o bin/execd .` builds the daemon. - `cd components/execd && make fmt` formats Go sources. - SDKs: - Python: `cd sdks/sandbox/python && uv sync && uv run pytest`. - Kotlin: `cd sdks/sandbox/kotlin && ./gradlew build`. - Specs: `node scripts/spec-doc/generate-spec.js` regenerates spec docs. ## Coding Style & Naming Conventions - Python: PEP 8, `ruff` for lint/format, type hints on public APIs. - Go: `gofmt`, explicit error handling, standard import grouping. - Kotlin: Kotlin Coding Conventions, `ktlint` where configured. - Naming: classes `PascalCase`, functions `snake_case` (Python) / `camelCase` (Go/Kotlin), constants `UPPER_SNAKE_CASE`. ## SDK API Implementation Conventions - Keep a clear split between generated API transport code and handwritten SDK business/adaptor code. - In adapter/infrastructure layers, default to integrating through generated API clients instead of handcrafted request wiring. - Prefer generated OpenAPI clients for standard request/response endpoints; use handwritten transport only for streaming or protocol-specific paths (for example SSE). - Do not manually edit generated client files. When specs change, regenerate first, then adapt handwritten layers. - For handwritten streaming paths, keep wire contracts aligned with OpenAPI field names/models and cover behavior with focused tests (especially parsing and error mapping). ## Testing Guidelines - Python tests use `pytest` (async tests common). - Go tests use `go test` under `components/execd/pkg/...`. - Kotlin tests use Gradle (`./gradlew test`). - Coverage targets (from CONTRIBUTING): core packages >80%, API layer >70%. ## Commit & Pull Request Guidelines - Commit messages follow Conventional Commits, e.g. `feat(server): add runtime`. - Use feature branches (e.g., `feature/...`, `fix/...`) and keep PRs focused. - PRs should include summary, testing status, and linked issues; follow the template in `CONTRIBUTING.md`. - For major API or architectural changes, submit an OSEP (`oseps/`). ## Security & Configuration Tips - Local server config lives in `~/.sandbox.toml` (copied from `server/example.config.toml`). - Docker is required for local sandbox execution; keep images and keys out of commits. ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Code of Conduct We are committed to a welcoming, safe, and respectful community. ## Expected Behavior - Be respectful and inclusive. - Assume good intent; seek to understand. - Provide constructive feedback; critique code, not people. - Follow project guidelines and security practices. ## Unacceptable Behavior - Harassment, personal attacks, or discriminatory language. - Publishing private information without consent. - Disruptive or aggressive behavior in any project space. ## Scope This Code applies to all project spaces, including issues, pull requests, discussions, chat, and events. ## Reporting Report incidents to: **conduct@opensandbox.io**. Include as much detail as possible (what happened, when/where, links, screenshots if applicable). ## Enforcement Maintainers will investigate in good faith and may take appropriate action, including warnings, temporary bans, or removal from the community. ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to OpenSandbox Thank you for your interest in contributing to OpenSandbox! This guide will help you get started with contributing to the project, whether you're fixing bugs, adding features, improving documentation, or helping in other ways. ## Table of Contents - [Code of Conduct](#code-of-conduct) - [Getting Started](#getting-started) - [Development Environment Setup](#development-environment-setup) - [Project Structure](#project-structure) - [Development Workflow](#development-workflow) - [Coding Standards](#coding-standards) - [Testing Guidelines](#testing-guidelines) - [Submitting Contributions](#submitting-contributions) - [Communication Channels](#communication-channels) ## Code of Conduct OpenSandbox adheres to a [Code of Conduct](CODE_OF_CONDUCT.md) that we expect all contributors to follow. Please read it before contributing to ensure a welcoming and inclusive environment for everyone. ## Getting Started ### Ways to Contribute There are many ways to contribute to OpenSandbox: - **Report Bugs**: Submit detailed bug reports through [GitHub Issues](https://github.com/alibaba/OpenSandbox/issues) - **Suggest Features**: Propose new features or improvements - **Write Code**: Fix bugs, implement features, or improve performance - **Improve Documentation**: Enhance README files, write tutorials, or fix typos - **Write Tests**: Add test coverage or improve existing tests - **Review Pull Requests**: Help review and test others' contributions - **Answer Questions**: Help other users in GitHub Discussions or Issues ### Before You Start 1. **Search Existing Issues**: Check if your bug report or feature request already exists 2. **Check Roadmap**: Review the project roadmap to see if your idea aligns with project goals 3. **Discuss Major Changes**: For significant changes, open an issue first or submit an [OSEP](oseps/README.md) to discuss your approach 4. **Review Architecture**: Read [docs/architecture.md](docs/architecture.md) to understand the system design ## Development Environment Setup ### Prerequisites Different components have different requirements: #### For Server (Python) - **Python 3.10+** - **uv** - Python package manager ([installation guide](https://github.com/astral-sh/uv)) - **Docker** - For running sandboxes locally #### For execd (Go) - **Go 1.24+** - **Make** - Build automation (optional) - **Docker** - For building container images #### For SDKs - **Python SDK**: Python 3.10+, uv - **Java/Kotlin SDK**: JDK 17+, Gradle ### Quick Setup #### Server Development ```bash # Navigate to server directory cd server # Install dependencies uv sync # Copy example configuration cp example.config.toml ~/.sandbox.toml # Edit configuration for development # Set log_level = "DEBUG" and api_key nano ~/.sandbox.toml # Run server uv run python -m src.main ``` See [server/DEVELOPMENT.md](server/DEVELOPMENT.md) for detailed server development guide. #### execd Development ```bash # Navigate to execd directory cd components/execd # Download dependencies go mod download # Build execd go build -o bin/execd . # Run execd (requires Jupyter Server) ./bin/execd --jupyter-host=http://localhost:8888 --port=44772 ``` See [components/execd/DEVELOPMENT.md](components/execd/DEVELOPMENT.md) for detailed execd development guide. #### SDK Development **Python SDK:** ```bash cd sdks/sandbox/python uv sync uv run pytest ``` **Java/Kotlin SDK:** ```bash cd sdks/sandbox/kotlin ./gradlew build ./gradlew test ``` ## Project Structure ``` OpenSandbox/ ├── sdks/ # Multi-language SDKs │ ├── code-interpreter/ # Code Interpreter SDK (Python, Kotlin) │ └── sandbox/ # Sandbox base SDK (Python, Kotlin) ├── specs/ # OpenAPI specifications │ ├── execd-api.yaml # Execution API spec │ └── sandbox-lifecycle.yml # Lifecycle API spec ├── server/ # Sandbox server (Python/FastAPI) ├── components/ │ └── execd/ # Execution daemon (Go/Beego) ├── sandboxes/ # Sandbox implementations │ └── code-interpreter/ # Code Interpreter sandbox ├── examples/ # Example integrations ├── docs/ # Documentation ├── tests/ # Cross-component tests │ └── e2e/ # End-to-end tests └── scripts/ # Build and utility scripts ``` ## Development Workflow ### Enhancement Proposals (OSEP) For major features, architectural changes, or modifications to the core API/security model, we follow the **OSEP (OpenSandbox Enhancement Proposals)** process. Please read the [OSEP README](oseps/README.md) to understand when an OSEP is required and how to submit one. Small bug fixes and minor improvements do not require an OSEP. ### Branching Strategy - **main**: Stable production branch - **feature/[name]**: New features - **fix/[name]**: Bug fixes - **docs/[name]**: Documentation updates - **refactor/[name]**: Code refactoring - **test/[name]**: Test additions or improvements ### Creating a Feature Branch ```bash # Update main branch git checkout main git pull origin main # Create feature branch git checkout -b feature/my-awesome-feature # Make your changes # ... # Commit your changes git add . git commit -m "feat: add my awesome feature" # Push to your fork git push origin feature/my-awesome-feature ``` ### Commit Message Format We follow [Conventional Commits](https://www.conventionalcommits.org/) specification: ``` (): [optional body] [optional footer] ``` **Types:** - `feat`: New feature - `fix`: Bug fix - `docs`: Documentation changes - `style`: Code style changes (formatting, no logic change) - `refactor`: Code refactoring - `test`: Adding or updating tests - `chore`: Build process, dependencies, or tooling changes - `perf`: Performance improvements - `ci`: CI/CD changes **Examples:** ``` feat(server): add Kubernetes runtime support fix(execd): resolve memory leak in session cleanup docs(sdk): add Python SDK usage examples test(server): add integration tests for Docker runtime refactor(sdk): simplify filesystem API ``` ### Making Changes 1. **Write Clean Code**: Follow project coding standards (see below) 2. **Add Tests**: Ensure your changes are covered by tests 3. **Update Documentation**: Update relevant documentation files 4. **Test Locally**: Run all tests and ensure they pass 5. **Check Linting**: Run linters and fix any issues ## Coding Standards ### Python (Server, Python SDKs) - **Style Guide**: Follow [PEP 8](https://pep8.org/) - **Formatter**: Use `ruff` for formatting and linting - **Type Hints**: Always use type hints for function signatures - **Docstrings**: Use Google-style docstrings for public APIs ```python def create_sandbox( image: ImageSpec, timeout: timedelta, entrypoint: Optional[List[str]] = None ) -> Sandbox: """Create a new sandbox instance. Args: image: Container image specification timeout: Sandbox timeout duration entrypoint: Optional custom entrypoint command Returns: Created sandbox instance Raises: ValueError: If image or timeout is invalid """ # Implementation ``` **Running Linter:** ```bash cd server uv run ruff check src tests uv run ruff format src tests ``` ### Go (execd) - **Style Guide**: Follow [Effective Go](https://golang.org/doc/effective_go) - **Formatter**: Use `gofmt` for formatting - **Imports**: Organize in three groups (stdlib, third-party, internal) - **Error Handling**: Always handle errors explicitly ```go // Good result, err := someOperation() if err != nil { logs.Error("operation failed: %v", err) return fmt.Errorf("failed to do something: %w", err) } // Bad - silent failure result, _ := someOperation() ``` **Running Formatter:** ```bash cd components/execd gofmt -w . # Or make fmt ``` ### Java/Kotlin (Java/Kotlin SDKs) - **Style Guide**: Follow [Kotlin Coding Conventions](https://kotlinlang.org/docs/coding-conventions.html) - **Formatter**: Use `ktlint` - **Null Safety**: Use Kotlin's null safety features ```kotlin suspend fun createSandbox( image: ImageSpec, timeout: Duration, entrypoint: List? = null ): Sandbox { // Implementation } ``` ### General Guidelines - **Naming Conventions**: - Functions/Methods: `snake_case` (Python), `camelCase` (Go, Kotlin) - Classes: `PascalCase` (all languages) - Constants: `UPPER_SNAKE_CASE` (all languages) - Private members: `_leading_underscore` (Python), `unexported` (Go) - **Comments**: Write clear, concise comments explaining "why", not "what" - **Error Messages**: Provide actionable error messages with context - **Logging**: Use appropriate log levels (DEBUG, INFO, WARNING, ERROR) ## Testing Guidelines ### Test Coverage Requirements - **Core Packages**: Aim for >80% coverage - **API Layer**: Aim for >70% coverage - **Utilities**: Aim for >90% coverage ### Writing Tests #### Python Tests (pytest) ```python import pytest from opensandbox import Sandbox @pytest.mark.asyncio async def test_create_sandbox(): """Test sandbox creation with valid parameters.""" sandbox = await Sandbox.create( image="python:3.11", timeout=timedelta(minutes=5) ) assert sandbox.id is not None assert sandbox.status == SandboxStatus.PENDING await sandbox.kill() @pytest.mark.asyncio async def test_invalid_timeout(): """Test sandbox creation fails with invalid timeout.""" with pytest.raises(ValueError): await Sandbox.create( image="python:3.11", timeout=timedelta(seconds=-1) ) ``` **Running Tests:** ```bash cd server uv run pytest uv run pytest --cov=src --cov-report=html ``` #### Go Tests ```go func TestController_Execute_Python(t *testing.T) { ctrl := NewController("http://localhost:8888", "test-token") req := &ExecuteCodeRequest{ Language: Python, Code: "print('hello')", } err := ctrl.Execute(req) assert.NoError(t, err) } ``` **Running Tests:** ```bash cd components/execd go test ./pkg/... go test -v -cover ./pkg/... ``` #### Integration Tests Integration tests require Docker: ```bash # Server integration tests cd server uv run pytest tests/integration/ # E2E tests cd tests/e2e/python uv run pytest ``` ### Test Best Practices - **Test Names**: Use descriptive names that explain what is being tested - **Arrange-Act-Assert**: Structure tests clearly - **Isolation**: Each test should be independent - **Mocking**: Mock external dependencies appropriately - **Cleanup**: Always clean up resources (use fixtures, context managers) ## Submitting Contributions ### Pull Request Process 1. **Create Feature Branch**: Branch from `main` 2. **Make Changes**: Implement your feature or fix 3. **Write Tests**: Add comprehensive test coverage 4. **Update Documentation**: Update relevant docs 5. **Test Locally**: Ensure all tests pass 6. **Run Linters**: Fix any style issues 7. **Commit Changes**: Use conventional commit messages 8. **Push to Fork**: Push your branch to your fork 9. **Create Pull Request**: Submit PR with detailed description ### Pull Request Template When creating a PR, fill out the template: ```markdown # Summary - What is changing and why? # Testing - [ ] Not run (explain why) - [ ] Unit tests - [ ] Integration tests - [ ] e2e / manual verification # Breaking Changes - [ ] None - [ ] Yes (describe impact and migration path) # Checklist - [ ] Linked Issue or clearly described motivation - [ ] Added/updated docs (if needed) - [ ] Added/updated tests (if needed) - [ ] Security impact considered - [ ] Backward compatibility considered ``` ### Pull Request Guidelines **Do:** - Keep PRs focused and reasonably sized (< 500 lines if possible) - Write clear PR descriptions with motivation and context - Link related issues - Respond to review comments promptly - Update your PR based on feedback - Ensure CI passes before requesting review **Don't:** - Mix multiple unrelated changes in one PR - Submit PRs with failing tests - Ignore code review feedback - Force push after reviews have started (unless necessary) - Include commented-out code or debug statements ### Code Review Process 1. **Automated Checks**: CI runs tests, linters, and security scans 2. **Maintainer Review**: A maintainer reviews your code 3. **Feedback Loop**: Address review comments 4. **Approval**: Once approved, a maintainer will merge your PR 5. **Cleanup**: Delete your feature branch after merge ## Communication Channels ### GitHub Issues Use GitHub Issues for: - Bug reports - Feature requests - Documentation improvements - Questions about implementation **Bug Report Template:** ```markdown **Description** A clear description of the bug. **To Reproduce** Steps to reproduce the behavior: 1. Create sandbox with... 2. Execute command... 3. See error **Expected Behavior** What you expected to happen. **Environment** - OpenSandbox version: - Runtime (Docker/K8s): - OS: - Python/Go version: **Additional Context** Logs, screenshots, or other relevant information. ``` ### GitHub Discussions Use GitHub Discussions for: - General questions - Design discussions - Brainstorming ideas - Community help ### Getting Help - **Issues**: Technical problems or bugs - **Discussions**: Questions and community support - **Email**: For security issues, email conduct@opensandbox.io ## Additional Resources ### Documentation - [Architecture Overview](docs/architecture.md) - [Server Development Guide](server/DEVELOPMENT.md) - [execd Development Guide](components/execd/DEVELOPMENT.md) - [OpenAPI Specifications](specs/README.md) - [Python SDK Documentation](sdks/sandbox/python/README.md) - [Java/Kotlin SDK Documentation](sdks/sandbox/kotlin/README.md) ### Examples Browse [examples/](examples/) for real-world usage patterns: - Code Interpreter integration - AI Coding Agent integrations (Claude Code, Gemini CLI, etc.) - Browser automation (Chrome, Playwright) - Remote development (VS Code, Desktop) ### External Resources - [FastAPI Documentation](https://fastapi.tiangolo.com/) - [Beego Documentation](https://beego.wiki/) - [Jupyter Protocol](https://jupyter-client.readthedocs.io/en/stable/messaging.html) - [OpenAPI Specification](https://swagger.io/specification/) - [Docker API](https://docs.docker.com/engine/api/) ## Acknowledgments Thank you for contributing to OpenSandbox! Your contributions help make this project better for everyone in the AI and developer tools community. If you have suggestions for improving this contributing guide, please open an issue or submit a pull request. ## License By contributing to OpenSandbox, you agree that your contributions will be licensed under the [Apache 2.0 License](LICENSE). ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] 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. ================================================ FILE: README.md ================================================ [Documentation](https://open-sandbox.ai/) | [中文文档](https://open-sandbox.ai/zh/) OpenSandbox is a **general-purpose sandbox platform** for AI applications, offering multi-language SDKs, unified sandbox APIs, and Docker/Kubernetes runtimes for scenarios like Coding Agents, GUI Agents, Agent Evaluation, AI Code Execution, and RL Training. OpenSandbox is now listed in the [CNCF Landscape](https://landscape.cncf.io/?item=orchestration-management--scheduling-orchestration--opensandbox). ## Features - **Multi-language SDKs**: Provides sandbox SDKs in Python, Java/Kotlin, JavaScript/TypeScript, C#/.NET, Go (Roadmap), and more. - **Sandbox Protocol**: Defines sandbox lifecycle management APIs and sandbox execution APIs so you can extend custom sandbox runtimes. - **Sandbox Runtime**: Built-in lifecycle management supporting Docker and [high-performance Kubernetes runtime](./kubernetes), enabling both local runs and large-scale distributed scheduling. - **Sandbox Environments**: Built-in Command, Filesystem, and Code Interpreter implementations. Examples cover Coding Agents (e.g., Claude Code), browser automation (Chrome, Playwright), and desktop environments (VNC, VS Code). - **Network Policy**: Unified [Ingress Gateway](components/ingress) with multiple routing strategies plus per-sandbox [egress controls](components/egress). - **Strong Isolation**: Supports secure container runtimes like gVisor, Kata Containers, and Firecracker microVM for enhanced isolation between sandbox workloads and the host. See [Secure Container Runtime Guide](docs/secure-container.md) for details. ## Examples ### Basic Sandbox Operations Requirements: - Docker (required for local execution) - Python 3.10+ (recommended for examples and local runtime) #### 1. Install and Configure the Sandbox Server ```bash uv pip install opensandbox-server opensandbox-server init-config ~/.sandbox.toml --example docker ``` > If you prefer working from source, you can still clone the repo for development, but you no longer need to clone this repository just to start the server. > You'll also require an instance of docker running. > ```bash > git clone https://github.com/alibaba/OpenSandbox.git > cd OpenSandbox/server > uv sync > cp example.config.toml ~/.sandbox.toml # Copy configuration file > uv run python -m src.main # Start the service > ``` #### 2. Start the Sandbox Server ```bash opensandbox-server # Show help opensandbox-server -h ``` #### 3. Create a Code Interpreter and Execute Commands Install the Code Interpreter SDK ```bash uv pip install opensandbox-code-interpreter ``` Create a sandbox and execute commands ```python import asyncio from datetime import timedelta from code_interpreter import CodeInterpreter, SupportedLanguage from opensandbox import Sandbox from opensandbox.models import WriteEntry async def main() -> None: # 1. Create a sandbox sandbox = await Sandbox.create( "opensandbox/code-interpreter:v1.0.2", entrypoint=["/opt/opensandbox/code-interpreter.sh"], env={"PYTHON_VERSION": "3.11"}, timeout=timedelta(minutes=10), ) async with sandbox: # 2. Execute a shell command execution = await sandbox.commands.run("echo 'Hello OpenSandbox!'") print(execution.logs.stdout[0].text) # 3. Write a file await sandbox.files.write_files([ WriteEntry(path="/tmp/hello.txt", data="Hello World", mode=644) ]) # 4. Read a file content = await sandbox.files.read_file("/tmp/hello.txt") print(f"Content: {content}") # Content: Hello World # 5. Create a code interpreter interpreter = await CodeInterpreter.create(sandbox) # 6. Execute Python code (single-run, pass language directly) result = await interpreter.codes.run( """ import sys print(sys.version) result = 2 + 2 result """, language=SupportedLanguage.PYTHON, ) print(result.result[0].text) # 4 print(result.logs.stdout[0].text) # 3.11.14 # 7. Cleanup the sandbox await sandbox.kill() if __name__ == "__main__": asyncio.run(main()) ``` ### More Examples OpenSandbox provides examples covering SDK usage, agent integrations, browser automation, and training workloads. All example code is located in the `examples/` directory. #### 🎯 Basic Examples - **[code-interpreter](examples/code-interpreter/README.md)** - End-to-end Code Interpreter SDK workflow in a sandbox. - **[aio-sandbox](examples/aio-sandbox/README.md)** - All-in-One sandbox setup using the OpenSandbox SDK. - **[agent-sandbox](examples/agent-sandbox/README.md)** - Example integration for running OpenSandbox workloads on Kubernetes with [kubernetes-sigs/agent-sandbox](https://github.com/kubernetes-sigs/agent-sandbox). #### 🤖 Coding Agent Integrations - **[claude-code](examples/claude-code/README.md)** - Run Claude Code inside OpenSandbox. - **[gemini-cli](examples/gemini-cli/README.md)** - Run Google Gemini CLI inside OpenSandbox. - **[codex-cli](examples/codex-cli/README.md)** - Run OpenAI Codex CLI inside OpenSandbox. - **[kimi-cli](examples/kimi-cli/README.md)** - Run [Kimi CLI](https://github.com/MoonshotAI/kimi-cli) (Moonshot AI) inside OpenSandbox. - **[langgraph](examples/langgraph/README.md)** - LangGraph state-machine workflow that creates/runs a sandbox job with fallback retry. - **[google-adk](examples/google-adk/README.md)** - Google ADK agent using OpenSandbox tools to write/read files and run commands. - **[nullclaw](examples/nullclaw/README.md)** - Launch a [Nullclaw](https://github.com/nullclaw/nullclaw) Gateway inside a sandbox. - **[openclaw](examples/openclaw/README.md)** - Launch an OpenClaw Gateway inside a sandbox. #### 🌐 Browser and Desktop Environments - **[chrome](examples/chrome/README.md)** - Chromium sandbox with VNC and DevTools access for automation and debugging. - **[playwright](examples/playwright/README.md)** - Playwright + Chromium headless scraping and testing example. - **[desktop](examples/desktop/README.md)** - Full desktop environment in a sandbox with VNC access. - **[vscode](examples/vscode/README.md)** - code-server (VS Code Web) running inside a sandbox for remote dev. #### 🧠 ML and Training - **[rl-training](examples/rl-training/README.md)** - DQN CartPole training in a sandbox with checkpoints and summary output. For more details, please refer to [examples](examples/README.md) and the README files in each example directory. ## Project Structure | Directory | Description | |-----------|------------------------------------------------------------------| | [`sdks/`](sdks/) | Multi-language SDKs (Python, Java/Kotlin, TypeScript/JavaScript, C#/.NET) | | [`specs/`](specs/README.md) | OpenAPI specs and lifecycle specifications | | [`server/`](server/README.md) | Python FastAPI sandbox lifecycle server | | [`kubernetes/`](kubernetes/README.md) | Kubernetes deployment and examples | | [`components/execd/`](components/execd/README.md) | Sandbox execution daemon (commands and file operations) | | [`components/ingress/`](components/ingress/README.md) | Sandbox traffic ingress proxy | | [`components/egress/`](components/egress/README.md) | Sandbox network egress control | | [`sandboxes/`](sandboxes/) | Runtime sandbox implementations | | [`examples/`](examples/README.md) | Integration examples and use cases | | [`oseps/`](oseps/README.md) | OpenSandbox Enhancement Proposals | | [`docs/`](docs/) | Architecture and design documentation | | [`tests/`](tests/) | Cross-component E2E tests | | [`scripts/`](scripts/) | Development and maintenance scripts | For detailed architecture, see [docs/architecture.md](docs/architecture.md). ## Documentation - [docs/architecture.md](docs/architecture.md) – Overall architecture & design philosophy - [oseps/README.md](oseps/README.md) – OpenSandbox Enhancement Proposals - SDK - Sandbox base SDK ([Java/Kotlin SDK](sdks/sandbox/kotlin/README.md), [Python SDK](sdks/sandbox/python/README.md), [JavaScript/TypeScript SDK](sdks/sandbox/javascript/README.md), [C#/.NET SDK](sdks/sandbox/csharp/README.md)) - includes sandbox lifecycle, command execution, file operations - Code Interpreter SDK ([Java/Kotlin SDK](sdks/code-interpreter/kotlin/README.md), [Python SDK](sdks/code-interpreter/python/README.md), [JavaScript/TypeScript SDK](sdks/code-interpreter/javascript/README.md), [C#/.NET SDK](sdks/code-interpreter/csharp/README.md)) - code interpreter - [specs/README.md](specs/README.md) - OpenAPI definitions for sandbox lifecycle API and sandbox execution API - [server/README.md](server/README.md) - Sandbox server startup and configuration; supports Docker and Kubernetes runtimes ## License This project is open source under the [Apache 2.0 License](LICENSE). ## Roadmap [2026.03] ### SDK - **Sandbox client connection pool** - Client-side sandbox connection pool management, providing pre-provisioned sandboxes to obtain an environment at X ms. - **Go SDK** - Go client SDK for sandbox lifecycle management, command execution, and file operations. ### Sandbox Runtime - **Persistent volumes** - Mountable persistent volumes for sandboxes (see [Proposal 0003](oseps/0003-volume-and-volumebinding-support.md)). - **Local lightweight sandbox** - Lightweight sandbox for AI tools running directly on PCs. - **Secure Container** - Secure sandbox for AI Agents running inside container. ### Deployment - **Guide** - Deployment guide for self-hosted Kubernetes cluster. ## Contact and Discussion - Issues: Submit bugs, feature requests, or design discussions through GitHub Issues - DingTalk: Join the [OpenSandbox technical discussion group](https://qr.dingtalk.com/action/joingroup?code=v1,k1,A4Bgl5q1I1eNU/r33D18YFNrMY108aFF38V+r19RJOM=&_dt_no_comment=1&origin=11) ## Star History [![Star History Chart](https://api.star-history.com/svg?repos=alibaba/OpenSandbox&type=date&legend=top-left)](https://www.star-history.com/#alibaba/OpenSandbox&type=date&legend=top-left) ================================================ FILE: SECURITY.md ================================================ # Security Policy ## Reporting Security Issues The OpenSandbox team takes security seriously. If you discover a security vulnerability, please report it responsibly. ### How to Report - **GitHub Security Advisories**: Open a private security advisory on GitHub - **Email**: Contact the maintainers directly with "[SECURITY]" in the subject ### What to Include - Clear description of the vulnerability - Steps to reproduce - Potential impact and scope - Suggested remediation (if available) ## Response Process 1. Acknowledgment within 48 hours 2. Investigation and validation 3. Fix development and testing 4. Coordinated disclosure ## Supported Versions Only the latest release and main branch are actively supported with security updates. ## Security Best Practices When deploying OpenSandbox: - Keep dependencies up to date - Use network policies to restrict sandbox egress - Monitor audit logs regularly - Follow principle of least privilege ================================================ FILE: cli/README.md ================================================ # OpenSandbox CLI A command-line interface for managing OpenSandbox environments from your terminal. Built on top of the [OpenSandbox Python SDK](../sdks/sandbox/python/README.md), the CLI provides intuitive commands for sandbox lifecycle management, file operations, command execution, and code interpretation. ## Installation ### pip ```bash pip install opensandbox-cli ``` ### uv ```bash uv add opensandbox-cli ``` ### pipx (recommended for global CLI usage) ```bash pipx install opensandbox-cli ``` ## Overview ```bash osb --help ``` ![CLI Help](assets/cli_help.png) ## Quick Start ### Step 0: Start the OpenSandbox Server Before using the CLI, make sure the OpenSandbox server is running. See the root [README.md](../README.md) for startup instructions. ```bash opensandbox-server ``` ![Start OpenSandbox Server](assets/start_opensandbox_server.png) ### Step 1: Install the CLI ```bash cd cli uv pip install -e . ``` ![Install CLI](assets/install_cli.png) ### Step 2: Initialize Configuration ```bash osb config init osb config set connection.domain localhost:8080 osb config set connection.protocol http ``` ![Init CLI](assets/init_cli.png) ### Step 3: Create a Sandbox ```bash osb sandbox create --image python:3.12 ``` ![Create Sandbox](assets/cli_create_sandbox.png) ### Step 4: List Sandboxes ```bash # Table output (default) osb sandbox list # JSON output for scripting osb -o json sandbox list ``` ![List Sandboxes](assets/cli_list_sandbox.png) ![List Sandboxes JSON](assets/cli_list_sandbox_json.png) ### Short ID Matching Like Docker, you don't need to type the full sandbox ID — just enough characters to uniquely identify the target sandbox: ```bash # Full ID osb sandbox get db027570-4f86-45f8-b1a8-c31a2dd90da8 # Short prefix — as long as it's unambiguous osb sandbox get db02 osb exec db02 -- echo "hello" ``` If the prefix matches multiple sandboxes, the CLI will report an error listing the matches so you can be more specific. ![Short ID Matching](assets/cli_sandbox_search.png) ### Step 5: Execute Commands ```bash osb exec -- echo "hello world" osb exec -- python -c "print(1+1)" ``` ![Execute Commands](assets/cli_sandbox_exec.png) ### Step 6: File Operations ```bash # Write a file osb file write /tmp/test.txt -c "hello" # Read it back osb file cat /tmp/test.txt ``` ![File Operations](assets/cli_sandbox_file.png) ### Step 7: Cleanup ```bash osb sandbox kill osb sandbox list ``` ![Kill Sandbox](assets/cli_kill_sandbox.png) ## Command Reference ### `osb sandbox` — Lifecycle Management | Command | Description | | ---------- | ------------------------------------------- | | `create` | Create a new sandbox | | `list` | List sandboxes (with optional filters) | | `get` | Get sandbox details by ID | | `kill` | Terminate one or more sandboxes | | `pause` | Pause a running sandbox | | `resume` | Resume a paused sandbox | | `renew` | Renew sandbox expiration | | `endpoint` | Get public endpoint for a sandbox port | | `health` | Check sandbox health | | `metrics` | Get sandbox resource metrics (CPU, memory) | ### `osb command` — Command Execution | Command | Description | | ----------- | ----------------------------------------- | | `run` | Run a shell command in the sandbox | | `status` | Get command execution status | | `logs` | Get background command logs | | `interrupt` | Interrupt a running command | ### `osb exec` — Quick Command Shortcut ```bash osb exec -- ``` Shortcut for `osb command run`. Everything after `--` is passed as the command. ### `osb file` — File Operations | Command | Description | | ---------- | ------------------------------------------ | | `cat` | Read file contents | | `write` | Write content to a file | | `upload` | Upload a local file to the sandbox | | `download` | Download a file from the sandbox | | `rm` | Delete files | | `mv` | Move or rename a file | | `mkdir` | Create directories | | `rmdir` | Remove directories | | `search` | Search for files by pattern | | `info` | Get file/directory metadata | | `chmod` | Set file permissions | | `replace` | Find and replace content in a file | ### `osb code` — Code Interpreter | Command | Description | | ----------- | ----------------------------------------- | | `run` | Execute code in a sandbox | | `context` | Manage code execution contexts | | `interrupt` | Interrupt a running code execution | ### `osb config` — Configuration | Command | Description | | ------- | ------------------------------------------ | | `init` | Create a default config file | | `show` | Show resolved configuration | ## Configuration The CLI resolves configuration from multiple sources with the following priority (highest to lowest): 1. **CLI flags** — `--api-key`, `--domain`, `--protocol`, `--timeout` 2. **Environment variables** — `OPEN_SANDBOX_API_KEY`, `OPEN_SANDBOX_DOMAIN`, `OPEN_SANDBOX_PROTOCOL`, `OPEN_SANDBOX_REQUEST_TIMEOUT`, `OPEN_SANDBOX_OUTPUT` 3. **Config file** — `~/.opensandbox/config.toml` (or path specified via `--config`) 4. **SDK defaults** ### Config File Format ```toml [connection] api_key = "your-api-key" domain = "localhost:8080" protocol = "http" request_timeout = 30 [output] format = "table" # table | json | yaml color = true [defaults] image = "python:3.11" timeout = "10m" ``` ## Global Options | Option | Description | | ----------------------------- | -------------------------------- | | `--api-key TEXT` | API key for authentication | | `--domain TEXT` | API server domain | | `--protocol [http\|https]` | Protocol | | `--timeout INTEGER` | Request timeout in seconds | | `-o, --output [table\|json\|yaml]` | Output format | | `--config PATH` | Config file path | | `-v, --verbose` | Enable debug output | | `--no-color` | Disable colored output | | `--version` | Show version | ## Output Formats The CLI supports three output formats via the `-o` / `--output` flag: - **`table`** (default) — Human-friendly tables powered by [Rich](https://github.com/Textualize/rich) - **`json`** — Machine-readable JSON - **`yaml`** — YAML output ```bash # Table (default) osb sandbox list # JSON for scripting osb -o json sandbox list # YAML osb -o yaml sandbox list ``` ================================================ FILE: cli/pyproject.toml ================================================ # Copyright 2026 Alibaba Group Holding Ltd. # # 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-system] requires = ["hatchling", "hatch-vcs"] build-backend = "hatchling.build" [project] name = "opensandbox-cli" dynamic = ["version"] description = "OpenSandbox CLI - Command-line interface for managing sandboxes" authors = [ { name = "OpenSandbox Team", email = "ninan.nn@alibaba-inc.com" } ] license = { file = "LICENSE" } readme = "README.md" requires-python = ">=3.10" keywords = ["sandbox", "cli", "opensandbox"] classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Topic :: Software Development :: Libraries", ] dependencies = [ "opensandbox>=0.1.4,<0.2.0", "opensandbox-code-interpreter>=0.1.0,<0.2.0", "click>=8.1.0,<9.0", "rich>=13.0.0,<14.0", "pyyaml>=6.0,<7.0", "tomli>=2.0.0; python_version < '3.11'", ] [project.urls] Homepage = "https://open-sandbox.ai" Repository = "https://github.com/alibaba/OpenSandbox" Documentation = "https://open-sandbox.ai" Issues = "https://github.com/alibaba/OpenSandbox/issues" [project.scripts] opensandbox = "opensandbox_cli.main:cli" osb = "opensandbox_cli.main:cli" [tool.hatch.version] source = "vcs" [tool.hatch.version.raw-options] root = ".." tag_regex = "^python/cli/v(?P\\d+\\.\\d+\\.\\d+(?:[\\.\\w\\+\\-]*)?)$" git_describe_command = 'git describe --dirty --tags --long --match "python/cli/v*"' fallback_version = "0.1.0" [tool.hatch.build] include = [ "LICENSE", "src/**/py.typed", "src/opensandbox_cli", ] [tool.hatch.build.targets.wheel] packages = ["src/opensandbox_cli"] [dependency-groups] dev = [ "pytest>=7.0.0", "pytest-cov>=4.0.0", "ruff>=0.14.8", "pyright>=1.1.0", ] [tool.ruff] target-version = "py310" line-length = 88 [tool.ruff.lint] select = [ "E", # pycodestyle errors "W", # pycodestyle warnings "F", # pyflakes "I", # isort "B", # flake8-bugbear "C4", # flake8-comprehensions "UP", # pyupgrade ] ignore = [ "E501", # line too long, handled by formatter "B008", # do not perform function calls in argument defaults "C901", # too complex ] [tool.ruff.lint.per-file-ignores] "__init__.py" = ["F401"] [tool.pyright] typeCheckingMode = "standard" pythonVersion = "3.10" pythonPlatform = "All" include = ["src"] exclude = [ "**/node_modules", "**/__pycache__", ] reportMissingImports = true reportMissingTypeStubs = false [tool.pytest.ini_options] minversion = "6.0" addopts = "-ra -q --strict-markers --strict-config" testpaths = ["tests"] python_files = ["test_*.py", "*_test.py"] [tool.coverage.run] source = ["src"] branch = true [tool.uv.sources] opensandbox = { path = "../sdks/sandbox/python", editable = true } opensandbox-code-interpreter = { path = "../sdks/code-interpreter/python", editable = true } ================================================ FILE: cli/src/opensandbox_cli/__init__.py ================================================ # Copyright 2026 Alibaba Group Holding Ltd. # # 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. try: from importlib.metadata import version __version__ = version("opensandbox-cli") except Exception: __version__ = "0.0.0-dev" ================================================ FILE: cli/src/opensandbox_cli/__main__.py ================================================ # Copyright 2026 Alibaba Group Holding Ltd. # # 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. """Allow running as ``python -m opensandbox_cli``.""" from opensandbox_cli.main import cli if __name__ == "__main__": cli() ================================================ FILE: cli/src/opensandbox_cli/client.py ================================================ # Copyright 2026 Alibaba Group Holding Ltd. # # 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. """SDK client factory stored in Click context.""" from __future__ import annotations import re from dataclasses import dataclass, field from datetime import timedelta from typing import Any import click from opensandbox.config.connection_sync import ConnectionConfigSync from opensandbox.models.sandboxes import SandboxFilter from opensandbox.sync.manager import SandboxManagerSync from opensandbox.sync.sandbox import SandboxSync from opensandbox_cli.output import OutputFormatter # Full UUID pattern: 8-4-4-4-12 hex characters _UUID_RE = re.compile( r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" ) @dataclass class ClientContext: """Shared context passed via ``ctx.obj`` to all Click commands.""" resolved_config: dict[str, Any] output: OutputFormatter _connection_config: ConnectionConfigSync | None = field( default=None, init=False, repr=False ) _manager: SandboxManagerSync | None = field( default=None, init=False, repr=False ) @property def connection_config(self) -> ConnectionConfigSync: if self._connection_config is None: cfg = self.resolved_config self._connection_config = ConnectionConfigSync( api_key=cfg.get("api_key"), domain=cfg.get("domain"), protocol=cfg.get("protocol", "http"), request_timeout=timedelta(seconds=cfg.get("request_timeout", 30)), ) return self._connection_config def get_manager(self) -> SandboxManagerSync: """Return a lazily-created ``SandboxManagerSync``.""" if self._manager is None: self._manager = SandboxManagerSync.create(self.connection_config) return self._manager def resolve_sandbox_id(self, prefix: str) -> str: """Resolve a sandbox ID prefix to the full ID (Docker-style). If *prefix* looks like a complete UUID, it is returned as-is without querying the server. Otherwise **all pages** of sandboxes are fetched so that prefix collisions on later pages are never missed. """ # Skip resolution for full UUIDs if _UUID_RE.match(prefix): return prefix mgr = self.get_manager() matches: list[str] = [] page = 0 while True: result = mgr.list_sandbox_infos( SandboxFilter(page=page, page_size=100) ) if result.sandbox_infos: matches.extend( info.id for info in result.sandbox_infos if info.id.startswith(prefix) ) # Stop early if we already found >1 match (ambiguous) if len(matches) > 1: break if not result.pagination.has_next_page: break page += 1 if len(matches) == 1: return matches[0] elif len(matches) == 0: raise click.ClickException( f"No sandbox found with ID prefix '{prefix}'" ) else: ids_str = ", ".join(matches[:5]) if len(matches) > 5: ids_str += ", ..." raise click.ClickException( f"Ambiguous ID prefix '{prefix}' matches {len(matches)} sandboxes: {ids_str}" ) def connect_sandbox( self, sandbox_id: str, *, skip_health_check: bool = True ) -> SandboxSync: """Connect to an existing sandbox by ID (supports prefix matching).""" sandbox_id = self.resolve_sandbox_id(sandbox_id) return SandboxSync.connect( sandbox_id, connection_config=self.connection_config, skip_health_check=skip_health_check, ) def close(self) -> None: """Release resources.""" if self._manager is not None: self._manager.close() self._manager = None if self._connection_config is not None: self._connection_config.close_transport_if_owned() self._connection_config = None ================================================ FILE: cli/src/opensandbox_cli/commands/__init__.py ================================================ # Copyright 2026 Alibaba Group Holding Ltd. # # 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. ================================================ FILE: cli/src/opensandbox_cli/commands/code.py ================================================ # Copyright 2026 Alibaba Group Holding Ltd. # # 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. """Code execution commands: run, context management, interrupt.""" from __future__ import annotations import sys import click from opensandbox.models.execd import OutputMessage from opensandbox.models.execd_sync import ExecutionHandlersSync from opensandbox_cli.client import ClientContext from opensandbox_cli.utils import handle_errors @click.group("code", invoke_without_command=True) @click.pass_context def code_group(ctx: click.Context) -> None: """💻 Execute code in a sandbox (via Code Interpreter).""" if ctx.invoked_subcommand is None: click.echo(ctx.get_help()) # ---- run ------------------------------------------------------------------ @code_group.command("run") @click.argument("sandbox_id") @click.option("--language", "-l", required=True, help="Language (python, javascript, java, go, bash, ...).") @click.option("--code", "-c", default=None, help="Code to execute. Reads from stdin if not provided.") @click.option("--context-id", default=None, help="Execution context ID for stateful sessions.") @click.pass_obj @handle_errors def code_run( obj: ClientContext, sandbox_id: str, language: str, code: str | None, context_id: str | None, ) -> None: """Execute code in a sandbox.""" from code_interpreter.sync.code_interpreter import CodeInterpreterSync if code is None: if sys.stdin.isatty(): click.echo("Reading code from stdin (Ctrl+D to finish):", err=True) code = sys.stdin.read() sandbox = obj.connect_sandbox(sandbox_id) try: interpreter = CodeInterpreterSync.create(sandbox) kwargs: dict = {} if context_id: ctx = interpreter.codes.get_context(context_id) kwargs["context"] = ctx def on_stdout(msg: OutputMessage) -> None: sys.stdout.write(msg.text) sys.stdout.flush() def on_stderr(msg: OutputMessage) -> None: sys.stderr.write(msg.text) sys.stderr.flush() handlers = ExecutionHandlersSync(on_stdout=on_stdout, on_stderr=on_stderr) execution = interpreter.codes.run( code, language=language, handlers=handlers, **kwargs ) if execution.error: obj.output.error( f"{execution.error.name}: {execution.error.value}" ) sys.exit(1) finally: sandbox.close() # ---- context group -------------------------------------------------------- @code_group.group("context", invoke_without_command=True) @click.pass_context def context_group(ctx: click.Context) -> None: """Manage code execution contexts.""" if ctx.invoked_subcommand is None: click.echo(ctx.get_help()) @context_group.command("create") @click.argument("sandbox_id") @click.option("--language", "-l", required=True, help="Language for the context.") @click.pass_obj @handle_errors def context_create(obj: ClientContext, sandbox_id: str, language: str) -> None: """Create a new code execution context.""" from code_interpreter.sync.code_interpreter import CodeInterpreterSync sandbox = obj.connect_sandbox(sandbox_id) try: interpreter = CodeInterpreterSync.create(sandbox) ctx = interpreter.codes.create_context(language) obj.output.success_panel( {"context_id": ctx.id, "language": language}, title="Context Created", ) finally: sandbox.close() @context_group.command("list") @click.argument("sandbox_id") @click.option("--language", "-l", required=True, help="Language to list contexts for.") @click.pass_obj @handle_errors def context_list(obj: ClientContext, sandbox_id: str, language: str) -> None: """List code execution contexts.""" from code_interpreter.sync.code_interpreter import CodeInterpreterSync sandbox = obj.connect_sandbox(sandbox_id) try: interpreter = CodeInterpreterSync.create(sandbox) contexts = interpreter.codes.list_contexts(language) for ctx in contexts: click.echo(f"{ctx.id}") finally: sandbox.close() @context_group.command("delete") @click.argument("sandbox_id") @click.argument("context_id") @click.pass_obj @handle_errors def context_delete(obj: ClientContext, sandbox_id: str, context_id: str) -> None: """Delete a code execution context.""" from code_interpreter.sync.code_interpreter import CodeInterpreterSync sandbox = obj.connect_sandbox(sandbox_id) try: interpreter = CodeInterpreterSync.create(sandbox) interpreter.codes.delete_context(context_id) obj.output.success(f"Deleted context: {context_id}") finally: sandbox.close() @context_group.command("delete-all") @click.argument("sandbox_id") @click.option("--language", "-l", required=True, help="Language to delete all contexts for.") @click.pass_obj @handle_errors def context_delete_all(obj: ClientContext, sandbox_id: str, language: str) -> None: """Delete all code execution contexts for a language.""" from code_interpreter.sync.code_interpreter import CodeInterpreterSync sandbox = obj.connect_sandbox(sandbox_id) try: interpreter = CodeInterpreterSync.create(sandbox) interpreter.codes.delete_contexts(language) obj.output.success(f"Deleted all {language} contexts") finally: sandbox.close() # ---- interrupt ------------------------------------------------------------ @code_group.command("interrupt") @click.argument("sandbox_id") @click.argument("execution_id") @click.pass_obj @handle_errors def code_interrupt(obj: ClientContext, sandbox_id: str, execution_id: str) -> None: """Interrupt a running code execution.""" sandbox = obj.connect_sandbox(sandbox_id) try: sandbox.commands.interrupt(execution_id) obj.output.success(f"Interrupted: {execution_id}") finally: sandbox.close() ================================================ FILE: cli/src/opensandbox_cli/commands/command.py ================================================ # Copyright 2026 Alibaba Group Holding Ltd. # # 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. """Command execution commands: run, status, logs, interrupt + top-level exec alias.""" from __future__ import annotations import shlex import sys from datetime import timedelta import click from opensandbox.models.execd import OutputMessage, RunCommandOpts from opensandbox.models.execd_sync import ExecutionHandlersSync from opensandbox_cli.client import ClientContext from opensandbox_cli.utils import DURATION, handle_errors @click.group("command", invoke_without_command=True) @click.pass_context def command_group(ctx: click.Context) -> None: """⚡ Execute commands in a sandbox.""" if ctx.invoked_subcommand is None: click.echo(ctx.get_help()) # ---- run ------------------------------------------------------------------ def _run_command( obj: ClientContext, sandbox_id: str, command: tuple[str, ...], background: bool, workdir: str | None, timeout: timedelta | None, ) -> None: """Shared implementation for 'command run' and top-level 'exec'.""" cmd_str = " ".join(shlex.quote(arg) for arg in command) sandbox = obj.connect_sandbox(sandbox_id) try: opts = RunCommandOpts( background=background, working_directory=workdir, timeout=timeout, ) if background: execution = sandbox.commands.run(cmd_str, opts=opts) obj.output.success_panel( { "execution_id": execution.id, "sandbox_id": sandbox_id, "mode": "background", }, title="Background Command Started", ) return # Foreground: stream stdout/stderr to terminal last_text = "" def on_stdout(msg: OutputMessage) -> None: nonlocal last_text last_text = msg.text sys.stdout.write(msg.text) sys.stdout.flush() def on_stderr(msg: OutputMessage) -> None: nonlocal last_text last_text = msg.text sys.stderr.write(msg.text) sys.stderr.flush() handlers = ExecutionHandlersSync(on_stdout=on_stdout, on_stderr=on_stderr) execution = sandbox.commands.run(cmd_str, opts=opts, handlers=handlers) # Ensure terminal prompt starts on a new line if last_text and not last_text.endswith("\n"): sys.stdout.write("\n") sys.stdout.flush() if execution.error: obj.output.error_panel( f"{execution.error.name}: {execution.error.value}", title="Execution Error", ) sys.exit(1) finally: sandbox.close() @command_group.command("run") @click.argument("sandbox_id") @click.argument("command", nargs=-1, required=True) @click.option("-d", "--background", is_flag=True, default=False, help="Run in background.") @click.option("-w", "--workdir", default=None, help="Working directory.") @click.option("-t", "--timeout", type=DURATION, default=None, help="Command timeout (e.g. 30s, 5m).") @click.pass_obj @handle_errors def command_run( obj: ClientContext, sandbox_id: str, command: tuple[str, ...], background: bool, workdir: str | None, timeout: timedelta | None, ) -> None: """Run a command in a sandbox.""" _run_command(obj, sandbox_id, command, background, workdir, timeout) # ---- status --------------------------------------------------------------- @command_group.command("status") @click.argument("sandbox_id") @click.argument("execution_id") @click.pass_obj @handle_errors def command_status(obj: ClientContext, sandbox_id: str, execution_id: str) -> None: """Get command execution status.""" sandbox = obj.connect_sandbox(sandbox_id) try: status = sandbox.commands.get_command_status(execution_id) obj.output.print_model(status, title="Command Status") finally: sandbox.close() # ---- logs ----------------------------------------------------------------- @command_group.command("logs") @click.argument("sandbox_id") @click.argument("execution_id") @click.option("--cursor", type=int, default=None, help="Cursor for incremental reads.") @click.pass_obj @handle_errors def command_logs( obj: ClientContext, sandbox_id: str, execution_id: str, cursor: int | None ) -> None: """Get background command logs.""" sandbox = obj.connect_sandbox(sandbox_id) try: logs = sandbox.commands.get_background_command_logs(execution_id, cursor=cursor) if obj.output.fmt in ("json", "yaml"): obj.output.print_model(logs, title="Command Logs") else: click.echo(logs.content) finally: sandbox.close() # ---- interrupt ------------------------------------------------------------ @command_group.command("interrupt") @click.argument("sandbox_id") @click.argument("execution_id") @click.pass_obj @handle_errors def command_interrupt(obj: ClientContext, sandbox_id: str, execution_id: str) -> None: """Interrupt a running command.""" sandbox = obj.connect_sandbox(sandbox_id) try: sandbox.commands.interrupt(execution_id) obj.output.success(f"Interrupted: {execution_id}") finally: sandbox.close() # ---- top-level exec alias ------------------------------------------------ @click.command("exec") @click.argument("sandbox_id") @click.argument("command", nargs=-1, required=True) @click.option("-d", "--background", is_flag=True, default=False, help="Run in background.") @click.option("-w", "--workdir", default=None, help="Working directory.") @click.option("-t", "--timeout", type=DURATION, default=None, help="Command timeout (e.g. 30s, 5m).") @click.pass_obj @handle_errors def exec_cmd( obj: ClientContext, sandbox_id: str, command: tuple[str, ...], background: bool, workdir: str | None, timeout: timedelta | None, ) -> None: """🚀 Execute a command in a sandbox (shortcut for 'command run').""" _run_command(obj, sandbox_id, command, background, workdir, timeout) ================================================ FILE: cli/src/opensandbox_cli/commands/config_cmd.py ================================================ # Copyright 2026 Alibaba Group Holding Ltd. # # 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. """Config management commands: init, show, set.""" from __future__ import annotations from pathlib import Path import click from opensandbox_cli.client import ClientContext from opensandbox_cli.config import DEFAULT_CONFIG_PATH, init_config_file from opensandbox_cli.utils import handle_errors @click.group("config", invoke_without_command=True) @click.pass_context def config_group(ctx: click.Context) -> None: """⚙️ Manage CLI configuration.""" if ctx.invoked_subcommand is None: click.echo(ctx.get_help()) # ---- init ----------------------------------------------------------------- @config_group.command("init") @click.option("--force", is_flag=True, default=False, help="Overwrite existing config file.") @click.option("--path", "config_path", type=click.Path(path_type=Path), default=None, help="Config file path.") @handle_errors def config_init(force: bool, config_path: Path | None) -> None: """Create a default configuration file.""" # config_init doesn't have @click.pass_obj, get formatter from context ctx = click.get_current_context(silent=True) obj = getattr(ctx, "obj", None) if ctx else None output = getattr(obj, "output", None) if obj else None try: path = init_config_file(config_path, force=force) if output: output.success(f"Config file created: {path}") else: click.echo(f"Config file created: {path}") except FileExistsError as exc: if output: output.warning(str(exc)) else: click.secho(str(exc), fg="yellow", err=True) # ---- show ----------------------------------------------------------------- @config_group.command("show") @click.pass_obj @handle_errors def config_show(obj: ClientContext) -> None: """Show the resolved configuration.""" obj.output.print_dict(obj.resolved_config, title="Resolved Configuration") # ---- set ------------------------------------------------------------------ @config_group.command("set") @click.argument("key") @click.argument("value") @click.option("--path", "config_path", type=click.Path(path_type=Path), default=None, help="Config file path.") @handle_errors def config_set(key: str, value: str, config_path: Path | None) -> None: """Set a configuration value (e.g. 'connection.domain' 'localhost:9090').""" path = config_path or DEFAULT_CONFIG_PATH if not path.exists(): click.secho(f"Config file not found: {path}. Run 'osb config init' first.", fg="red", err=True) return content = path.read_text() # Simple key replacement in TOML # Supports dotted keys like connection.domain parts = key.split(".", 1) if len(parts) == 2: section, field = parts # Try to find and update existing value import re section_pattern = rf"(\[{re.escape(section)}\].*?)(?=\n\[|\Z)" section_match = re.search(section_pattern, content, re.DOTALL) # Infer TOML value type: bool > int > float > string def _toml_value(raw: str) -> str: if raw.lower() in ("true", "false"): return raw.lower() try: int(raw) return raw except ValueError: pass try: float(raw) return raw except ValueError: pass return f'"{raw}"' toml_val = _toml_value(value) if section_match: section_text = section_match.group(1) field_pattern = rf'^(#?\s*{re.escape(field)}\s*=\s*).*$' field_match = re.search(field_pattern, section_text, re.MULTILINE) if field_match: new_line = f'{field} = {toml_val}' new_section = section_text[:field_match.start()] + new_line + section_text[field_match.end():] content = content[:section_match.start()] + new_section + content[section_match.end():] else: # Add field to section insert_pos = section_match.end() content = content[:insert_pos] + f'\n{field} = {toml_val}' + content[insert_pos:] else: # Add new section content += f'\n[{section}]\n{field} = {toml_val}\n' else: click.secho("Key must be in 'section.field' format (e.g. connection.domain).", fg="red", err=True) return path.write_text(content) # config_set doesn't have @click.pass_obj, get formatter from context ctx = click.get_current_context(silent=True) obj = getattr(ctx, "obj", None) if ctx else None output = getattr(obj, "output", None) if obj else None if output: output.success(f"Set {key} = {value}") else: click.echo(f"Set {key} = {value}") ================================================ FILE: cli/src/opensandbox_cli/commands/file.py ================================================ # Copyright 2026 Alibaba Group Holding Ltd. # # 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. """File operation commands: cat, write, upload, download, rm, mv, mkdir, rmdir, search, info, chmod, replace.""" from __future__ import annotations import sys from pathlib import Path import click from opensandbox_cli.client import ClientContext from opensandbox_cli.utils import handle_errors @click.group("file", invoke_without_command=True) @click.pass_context def file_group(ctx: click.Context) -> None: """📁 File operations on a sandbox.""" if ctx.invoked_subcommand is None: click.echo(ctx.get_help()) # ---- cat (read) ----------------------------------------------------------- @file_group.command("cat") @click.argument("sandbox_id") @click.argument("path") @click.option("--encoding", default="utf-8", help="File encoding.") @click.pass_obj @handle_errors def file_cat(obj: ClientContext, sandbox_id: str, path: str, encoding: str) -> None: """Read a file from the sandbox.""" sandbox = obj.connect_sandbox(sandbox_id) try: content = sandbox.files.read_file(path, encoding=encoding) click.echo(content, nl=False) finally: sandbox.close() # ---- write ---------------------------------------------------------------- @file_group.command("write") @click.argument("sandbox_id") @click.argument("path") @click.option("--content", "-c", default=None, help="Content to write. Reads from stdin if not provided.") @click.option("--encoding", default="utf-8", help="File encoding.") @click.option("--mode", default=None, help="File permission mode (e.g. 0644).") @click.option("--owner", default=None, help="File owner.") @click.option("--group", default=None, help="File group.") @click.pass_obj @handle_errors def file_write( obj: ClientContext, sandbox_id: str, path: str, content: str | None, encoding: str, mode: str | None, owner: str | None, group: str | None, ) -> None: """Write content to a file in the sandbox.""" if content is None: if sys.stdin.isatty(): click.echo("Reading from stdin (Ctrl+D to finish):", err=True) content = sys.stdin.read() sandbox = obj.connect_sandbox(sandbox_id) try: kwargs: dict = {"encoding": encoding} if mode is not None: kwargs["mode"] = mode if owner is not None: kwargs["owner"] = owner if group is not None: kwargs["group"] = group sandbox.files.write_file(path, content, **kwargs) obj.output.success(f"Written: {path}") finally: sandbox.close() # ---- upload --------------------------------------------------------------- @file_group.command("upload") @click.argument("sandbox_id") @click.argument("local_path", type=click.Path(exists=True)) @click.argument("remote_path") @click.pass_obj @handle_errors def file_upload( obj: ClientContext, sandbox_id: str, local_path: str, remote_path: str ) -> None: """Upload a local file to the sandbox.""" data = Path(local_path).read_bytes() sandbox = obj.connect_sandbox(sandbox_id) try: sandbox.files.write_file(remote_path, data) obj.output.success(f"Uploaded: {local_path} → {remote_path}") finally: sandbox.close() # ---- download ------------------------------------------------------------- @file_group.command("download") @click.argument("sandbox_id") @click.argument("remote_path") @click.argument("local_path", type=click.Path()) @click.pass_obj @handle_errors def file_download( obj: ClientContext, sandbox_id: str, remote_path: str, local_path: str ) -> None: """Download a file from the sandbox to local disk.""" sandbox = obj.connect_sandbox(sandbox_id) try: content = sandbox.files.read_bytes(remote_path) Path(local_path).write_bytes(content) obj.output.success(f"Downloaded: {remote_path} → {local_path}") finally: sandbox.close() # ---- rm (delete) ---------------------------------------------------------- @file_group.command("rm") @click.argument("sandbox_id") @click.argument("paths", nargs=-1, required=True) @click.pass_obj @handle_errors def file_rm(obj: ClientContext, sandbox_id: str, paths: tuple[str, ...]) -> None: """Delete files from the sandbox.""" sandbox = obj.connect_sandbox(sandbox_id) try: sandbox.files.delete_files(list(paths)) for p in paths: obj.output.success(f"Deleted: {p}") finally: sandbox.close() # ---- mv (move) ------------------------------------------------------------ @file_group.command("mv") @click.argument("sandbox_id") @click.argument("source") @click.argument("destination") @click.pass_obj @handle_errors def file_mv( obj: ClientContext, sandbox_id: str, source: str, destination: str ) -> None: """Move/rename a file in the sandbox.""" from opensandbox.models.filesystem import MoveEntry sandbox = obj.connect_sandbox(sandbox_id) try: sandbox.files.move_files([MoveEntry(source=source, destination=destination)]) obj.output.success(f"Moved: {source} → {destination}") finally: sandbox.close() # ---- mkdir ---------------------------------------------------------------- @file_group.command("mkdir") @click.argument("sandbox_id") @click.argument("paths", nargs=-1, required=True) @click.option("--mode", default=None, help="Directory permission mode.") @click.option("--owner", default=None, help="Directory owner.") @click.option("--group", default=None, help="Directory group.") @click.pass_obj @handle_errors def file_mkdir( obj: ClientContext, sandbox_id: str, paths: tuple[str, ...], mode: str | None, owner: str | None, group: str | None, ) -> None: """Create directories in the sandbox.""" from opensandbox.models.filesystem import WriteEntry sandbox = obj.connect_sandbox(sandbox_id) try: entries = [] for p in paths: kwargs: dict = {"path": p} if mode is not None: kwargs["mode"] = mode if owner is not None: kwargs["owner"] = owner if group is not None: kwargs["group"] = group entries.append(WriteEntry(**kwargs)) sandbox.files.create_directories(entries) for p in paths: obj.output.success(f"Created: {p}") finally: sandbox.close() # ---- rmdir ---------------------------------------------------------------- @file_group.command("rmdir") @click.argument("sandbox_id") @click.argument("paths", nargs=-1, required=True) @click.pass_obj @handle_errors def file_rmdir(obj: ClientContext, sandbox_id: str, paths: tuple[str, ...]) -> None: """Delete directories from the sandbox.""" sandbox = obj.connect_sandbox(sandbox_id) try: sandbox.files.delete_directories(list(paths)) for p in paths: obj.output.success(f"Removed: {p}") finally: sandbox.close() # ---- search --------------------------------------------------------------- @file_group.command("search") @click.argument("sandbox_id") @click.argument("path") @click.option("--pattern", "-p", required=True, help="Glob pattern to search for.") @click.pass_obj @handle_errors def file_search( obj: ClientContext, sandbox_id: str, path: str, pattern: str ) -> None: """Search for files in the sandbox.""" from opensandbox.models.filesystem import SearchEntry sandbox = obj.connect_sandbox(sandbox_id) try: results = sandbox.files.search(SearchEntry(path=path, pattern=pattern)) if not results: if obj.output.fmt in ("json", "yaml"): obj.output.print_models([], columns=[]) else: obj.output.info("No files found.") return if obj.output.fmt in ("json", "yaml"): obj.output.print_models(results, columns=["path", "size", "mode", "owner", "modified_at"]) else: obj.output.print_models(results, columns=["path", "size", "owner"], title="Search Results") finally: sandbox.close() # ---- info (stat) ---------------------------------------------------------- @file_group.command("info") @click.argument("sandbox_id") @click.argument("paths", nargs=-1, required=True) @click.pass_obj @handle_errors def file_info(obj: ClientContext, sandbox_id: str, paths: tuple[str, ...]) -> None: """Get file/directory info.""" sandbox = obj.connect_sandbox(sandbox_id) try: info_map = sandbox.files.get_file_info(list(paths)) for path, entry in info_map.items(): obj.output.print_dict( {"path": path, **entry.model_dump(mode="json")}, title=path, ) finally: sandbox.close() # ---- chmod ---------------------------------------------------------------- @file_group.command("chmod") @click.argument("sandbox_id") @click.argument("path") @click.option("--mode", required=True, help="Permission mode (e.g. 0755).") @click.option("--owner", default=None, help="File owner.") @click.option("--group", default=None, help="File group.") @click.pass_obj @handle_errors def file_chmod( obj: ClientContext, sandbox_id: str, path: str, mode: str, owner: str | None, group: str | None, ) -> None: """Set file permissions.""" from opensandbox.models.filesystem import SetPermissionEntry sandbox = obj.connect_sandbox(sandbox_id) try: sandbox.files.set_permissions( [SetPermissionEntry(path=path, mode=mode, owner=owner, group=group)] ) obj.output.success(f"Permissions set: {path}") finally: sandbox.close() # ---- replace -------------------------------------------------------------- @file_group.command("replace") @click.argument("sandbox_id") @click.argument("path") @click.option("--old", required=True, help="Text to search for.") @click.option("--new", required=True, help="Replacement text.") @click.pass_obj @handle_errors def file_replace( obj: ClientContext, sandbox_id: str, path: str, old: str, new: str ) -> None: """Replace content in a file.""" from opensandbox.models.filesystem import ContentReplaceEntry sandbox = obj.connect_sandbox(sandbox_id) try: sandbox.files.replace_contents( [ContentReplaceEntry(path=path, old_content=old, new_content=new)] ) obj.output.success(f"Replaced in: {path}") finally: sandbox.close() ================================================ FILE: cli/src/opensandbox_cli/commands/sandbox.py ================================================ # Copyright 2026 Alibaba Group Holding Ltd. # # 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. """Sandbox lifecycle commands: create, list, get, kill, pause, resume, renew, endpoint, health, metrics.""" from __future__ import annotations import json from datetime import timedelta import click from opensandbox.models.sandboxes import NetworkPolicy, SandboxFilter from opensandbox_cli.client import ClientContext from opensandbox_cli.utils import DURATION, KEY_VALUE, handle_errors @click.group("sandbox", invoke_without_command=True) @click.pass_context def sandbox_group(ctx: click.Context) -> None: """📦 Manage sandbox lifecycle.""" if ctx.invoked_subcommand is None: click.echo(ctx.get_help()) # Alias: osb sb ... sandbox_group.name = "sandbox" # ---- create --------------------------------------------------------------- @sandbox_group.command("create") @click.option("--image", "-i", required=True, help="Container image (e.g. python:3.11).") @click.option("--timeout", "-t", "timeout", type=DURATION, default=None, help="Sandbox lifetime (e.g. 10m, 1h).") @click.option("--env", "-e", "envs", multiple=True, type=KEY_VALUE, help="Environment variable (KEY=VALUE). Repeatable.") @click.option("--metadata", "-m", "metadata_kv", multiple=True, type=KEY_VALUE, help="Metadata (KEY=VALUE). Repeatable.") @click.option("--resource", "resources_kv", multiple=True, type=KEY_VALUE, help="Resource limit (e.g. cpu=1 memory=2Gi). Repeatable.") @click.option("--entrypoint", default=None, help="Entrypoint command (JSON array or shell string).") @click.option("--network-policy-file", type=click.Path(exists=True), default=None, help="Network policy JSON file.") @click.option("--skip-health-check", is_flag=True, default=False, help="Skip waiting for sandbox readiness.") @click.option("--ready-timeout", type=DURATION, default=None, help="Max wait time for sandbox readiness (e.g. 30s).") @click.pass_obj @handle_errors def sandbox_create( obj: ClientContext, image: str, timeout: timedelta | None, envs: tuple[tuple[str, str], ...], metadata_kv: tuple[tuple[str, str], ...], resources_kv: tuple[tuple[str, str], ...], entrypoint: str | None, network_policy_file: str | None, skip_health_check: bool, ready_timeout: timedelta | None, ) -> None: """Create a new sandbox.""" from opensandbox.sync.sandbox import SandboxSync kwargs: dict = { "connection_config": obj.connection_config, "skip_health_check": skip_health_check, } if timeout is not None: kwargs["timeout"] = timeout if ready_timeout is not None: kwargs["ready_timeout"] = ready_timeout if envs: kwargs["env"] = dict(envs) if metadata_kv: kwargs["metadata"] = dict(metadata_kv) if resources_kv: kwargs["resource"] = dict(resources_kv) if entrypoint: try: kwargs["entrypoint"] = json.loads(entrypoint) except json.JSONDecodeError: kwargs["entrypoint"] = ["sh", "-c", entrypoint] if network_policy_file: with open(network_policy_file) as f: kwargs["network_policy"] = NetworkPolicy(**json.load(f)) with obj.output.spinner("Creating sandbox..."): sandbox = SandboxSync.create(image, **kwargs) obj.output.success_panel( {"id": sandbox.id, "image": image, "status": "created"}, title="Sandbox Created", ) # ---- list ----------------------------------------------------------------- @sandbox_group.command("list") @click.option("--state", "-s", "states", multiple=True, help="Filter by state (Pending, Running, Paused, ...). Repeatable.") @click.option("--metadata", "-m", "metadata_kv", multiple=True, type=KEY_VALUE, help="Metadata filter (KEY=VALUE). Repeatable.") @click.option("--page", type=int, default=None, help="Page number (0-indexed).") @click.option("--page-size", type=int, default=None, help="Items per page.") @click.pass_obj @handle_errors def sandbox_list( obj: ClientContext, states: tuple[str, ...], metadata_kv: tuple[tuple[str, str], ...], page: int | None, page_size: int | None, ) -> None: """List sandboxes.""" mgr = obj.get_manager() filt = SandboxFilter( states=list(states) if states else None, metadata=dict(metadata_kv) if metadata_kv else None, page=page, page_size=page_size, ) with obj.output.spinner("Fetching sandboxes..."): result = mgr.list_sandbox_infos(filt) if not result.sandbox_infos: if obj.output.fmt in ("json", "yaml"): obj.output.print_rows( [], columns=["id", "status", "image", "created_at", "expires_at"], title="Sandboxes", ) else: obj.output.info("No sandboxes found.") return raw_rows = [info.model_dump(mode="json") for info in result.sandbox_infos] # For machine-readable formats, preserve the original structure if obj.output.fmt in ("json", "yaml"): obj.output.print_rows( raw_rows, columns=["id", "status", "image", "created_at", "expires_at"], title="Sandboxes", ) return # Flatten nested status/image objects for clean table display rows = [] for d in raw_rows: flat = dict(d) status_val = flat.get("status") if isinstance(status_val, dict): flat["status"] = status_val.get("state", str(status_val)) image_val = flat.get("image") if isinstance(image_val, dict): flat["image"] = image_val.get("image", str(image_val)) rows.append(flat) obj.output.print_rows( rows, columns=["id", "status", "image", "created_at", "expires_at"], title="Sandboxes", ) # ---- get ------------------------------------------------------------------ @sandbox_group.command("get") @click.argument("sandbox_id") @click.pass_obj @handle_errors def sandbox_get(obj: ClientContext, sandbox_id: str) -> None: """Get sandbox details.""" sandbox_id = obj.resolve_sandbox_id(sandbox_id) mgr = obj.get_manager() info = mgr.get_sandbox_info(sandbox_id) d = info.model_dump(mode="json") # For machine-readable formats, preserve the original structure if obj.output.fmt in ("json", "yaml"): obj.output.print_dict(d, title="Sandbox Info") return # Flatten nested objects for clean table display status_val = d.get("status") if isinstance(status_val, dict): d["status"] = status_val.get("state", str(status_val)) if status_val.get("reason"): d["status_reason"] = status_val["reason"] if status_val.get("message"): d["status_message"] = status_val["message"] image_val = d.get("image") if isinstance(image_val, dict): d["image"] = image_val.get("image", str(image_val)) obj.output.print_dict(d, title="Sandbox Info") # ---- kill ----------------------------------------------------------------- @sandbox_group.command("kill") @click.argument("sandbox_ids", nargs=-1, required=True) @click.pass_obj @handle_errors def sandbox_kill(obj: ClientContext, sandbox_ids: tuple[str, ...]) -> None: """Terminate one or more sandboxes.""" mgr = obj.get_manager() for sid in sandbox_ids: resolved = obj.resolve_sandbox_id(sid) with obj.output.spinner(f"Killing sandbox {resolved}..."): mgr.kill_sandbox(resolved) obj.output.success(f"Sandbox terminated: {resolved}") # ---- pause ---------------------------------------------------------------- @sandbox_group.command("pause") @click.argument("sandbox_id") @click.pass_obj @handle_errors def sandbox_pause(obj: ClientContext, sandbox_id: str) -> None: """Pause a running sandbox.""" sandbox_id = obj.resolve_sandbox_id(sandbox_id) mgr = obj.get_manager() with obj.output.spinner("Pausing sandbox..."): mgr.pause_sandbox(sandbox_id) obj.output.success(f"Sandbox paused: {sandbox_id}") # ---- resume --------------------------------------------------------------- @sandbox_group.command("resume") @click.argument("sandbox_id") @click.pass_obj @handle_errors def sandbox_resume(obj: ClientContext, sandbox_id: str) -> None: """Resume a paused sandbox.""" sandbox_id = obj.resolve_sandbox_id(sandbox_id) mgr = obj.get_manager() with obj.output.spinner("Resuming sandbox..."): mgr.resume_sandbox(sandbox_id) obj.output.success(f"Sandbox resumed: {sandbox_id}") # ---- renew ---------------------------------------------------------------- @sandbox_group.command("renew") @click.argument("sandbox_id") @click.option("--timeout", "-t", required=True, type=DURATION, help="New TTL duration (e.g. 30m, 2h).") @click.pass_obj @handle_errors def sandbox_renew(obj: ClientContext, sandbox_id: str, timeout: timedelta) -> None: """Renew sandbox expiration.""" sandbox_id = obj.resolve_sandbox_id(sandbox_id) mgr = obj.get_manager() with obj.output.spinner("Renewing sandbox..."): resp = mgr.renew_sandbox(sandbox_id, timeout) obj.output.success_panel( {"sandbox_id": sandbox_id, "expires_at": str(resp.expires_at)}, title="Sandbox Renewed", ) # ---- endpoint ------------------------------------------------------------- @sandbox_group.command("endpoint") @click.argument("sandbox_id") @click.option("--port", "-p", required=True, type=int, help="Port number.") @click.pass_obj @handle_errors def sandbox_endpoint(obj: ClientContext, sandbox_id: str, port: int) -> None: """Get the public endpoint for a sandbox port.""" sandbox = obj.connect_sandbox(sandbox_id) try: ep = sandbox.get_endpoint(port) obj.output.print_model(ep, title="Sandbox Endpoint") finally: sandbox.close() # ---- health --------------------------------------------------------------- @sandbox_group.command("health") @click.argument("sandbox_id") @click.pass_obj @handle_errors def sandbox_health(obj: ClientContext, sandbox_id: str) -> None: """Check sandbox health.""" sandbox = obj.connect_sandbox(sandbox_id) try: healthy = sandbox.is_healthy() if obj.output.fmt == "table": if healthy: obj.output.success(f"Sandbox {sandbox_id} is healthy") else: obj.output.error(f"Sandbox {sandbox_id} is unhealthy") else: obj.output.print_dict( {"sandbox_id": sandbox_id, "healthy": healthy}, title="Health Check", ) finally: sandbox.close() # ---- metrics -------------------------------------------------------------- @sandbox_group.command("metrics") @click.argument("sandbox_id") @click.pass_obj @handle_errors def sandbox_metrics(obj: ClientContext, sandbox_id: str) -> None: """Get sandbox resource metrics.""" sandbox = obj.connect_sandbox(sandbox_id) try: m = sandbox.get_metrics() obj.output.print_model(m, title="Sandbox Metrics") finally: sandbox.close() ================================================ FILE: cli/src/opensandbox_cli/config.py ================================================ # Copyright 2026 Alibaba Group Holding Ltd. # # 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. """CLI configuration loading and management. Priority (highest to lowest): 1. CLI flags 2. Environment variables 3. Config file (~/.opensandbox/config.toml) 4. SDK defaults """ from __future__ import annotations import os import sys from pathlib import Path from typing import Any if sys.version_info >= (3, 11): import tomllib else: try: import tomli as tomllib # type: ignore[no-redef] except ModuleNotFoundError: # pragma: no cover tomllib = None # type: ignore[assignment] DEFAULT_CONFIG_DIR = Path.home() / ".opensandbox" DEFAULT_CONFIG_PATH = DEFAULT_CONFIG_DIR / "config.toml" DEFAULT_CONFIG_TEMPLATE = """\ # OpenSandbox CLI configuration # Priority: CLI flags > environment variables > this file > SDK defaults [connection] # api_key = "your-api-key" # domain = "localhost:8080" # protocol = "http" # request_timeout = 30 [output] # format = "table" # table | json | yaml # color = true [defaults] # image = "python:3.11" # timeout = "10m" """ def load_config_file(config_path: Path | None = None) -> dict[str, Any]: """Load and parse the TOML config file. Returns an empty dict if the file doesn't exist or tomllib is unavailable. """ path = config_path or DEFAULT_CONFIG_PATH if not path.exists(): return {} if tomllib is None: return {} with open(path, "rb") as f: return tomllib.load(f) def resolve_config( *, cli_api_key: str | None = None, cli_domain: str | None = None, cli_protocol: str | None = None, cli_timeout: int | None = None, cli_output: str | None = None, config_path: Path | None = None, ) -> dict[str, Any]: """Merge config from all sources and return a flat dict. Keys returned: - api_key, domain, protocol, request_timeout (int seconds) - output_format ("table" | "json" | "yaml") - default_image, default_timeout (str like "10m") """ file_cfg = load_config_file(config_path) conn = file_cfg.get("connection", {}) output_cfg = file_cfg.get("output", {}) defaults = file_cfg.get("defaults", {}) return { "api_key": cli_api_key or os.getenv("OPEN_SANDBOX_API_KEY") or conn.get("api_key"), "domain": cli_domain or os.getenv("OPEN_SANDBOX_DOMAIN") or conn.get("domain"), "protocol": cli_protocol or os.getenv("OPEN_SANDBOX_PROTOCOL") or conn.get("protocol") or "http", "request_timeout": cli_timeout or _int_or_none(os.getenv("OPEN_SANDBOX_REQUEST_TIMEOUT")) or conn.get("request_timeout") or 30, "output_format": cli_output or os.getenv("OPEN_SANDBOX_OUTPUT") or output_cfg.get("format") or "table", "color": output_cfg.get("color", True), "default_image": defaults.get("image"), "default_timeout": defaults.get("timeout"), } def init_config_file(config_path: Path | None = None, *, force: bool = False) -> Path: """Create a default config file. Returns the path written.""" path = config_path or DEFAULT_CONFIG_PATH if path.exists() and not force: raise FileExistsError( f"Config file already exists at {path}. Use --force to overwrite." ) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(DEFAULT_CONFIG_TEMPLATE) return path def _int_or_none(value: str | None) -> int | None: if value is None: return None try: return int(value) except ValueError: return None ================================================ FILE: cli/src/opensandbox_cli/main.py ================================================ # Copyright 2026 Alibaba Group Holding Ltd. # # 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. """Root Click group with global options.""" from __future__ import annotations from pathlib import Path import click from rich.console import Console from rich.text import Text from opensandbox_cli import __version__ from opensandbox_cli.client import ClientContext from opensandbox_cli.commands.code import code_group from opensandbox_cli.commands.command import command_group, exec_cmd from opensandbox_cli.commands.config_cmd import config_group from opensandbox_cli.commands.file import file_group from opensandbox_cli.commands.sandbox import sandbox_group from opensandbox_cli.config import resolve_config from opensandbox_cli.output import OutputFormatter # --------------------------------------------------------------------------- # Banner # --------------------------------------------------------------------------- BANNER = r"""[bold cyan] ____ _____ _ _ / __ \ / ____| | | | | | | |_ __ ___ _ _| (___ __ _ _ __ __| | |__ _____ __ | | | | '_ \ / _ \ '_ \___ \ / _` | '_ \ / _` | '_ \ / _ \ \/ / | |__| | |_) | __/ | | |___) | (_| | | | | (_| | |_) | (_) > < \____/| .__/ \___|_| |_|____/ \__,_|_| |_|\__,_|_.__/ \___/_/\_\ | | |_|[/] [dim]v{version}[/] """ class BannerGroup(click.Group): """Custom Click group that shows a banner before help text.""" def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None: console = Console(stderr=False) console.print(BANNER.format(version=__version__)) super().format_help(ctx, formatter) @click.group(cls=BannerGroup, context_settings={"help_option_names": ["-h", "--help"]}) @click.option("--api-key", envvar="OPEN_SANDBOX_API_KEY", default=None, help="API key for authentication.") @click.option("--domain", envvar="OPEN_SANDBOX_DOMAIN", default=None, help="API server domain (e.g. localhost:8080).") @click.option("--protocol", type=click.Choice(["http", "https"]), default=None, help="Protocol (http/https).") @click.option("--timeout", "request_timeout", type=int, default=None, help="Request timeout in seconds.") @click.option("-o", "--output", "output_format", type=click.Choice(["table", "json", "yaml"]), default=None, help="Output format.") @click.option("--config", "config_path", type=click.Path(exists=False, path_type=Path), default=None, help="Config file path.") @click.option("-v", "--verbose", is_flag=True, default=False, help="Enable verbose/debug output.") @click.option("--no-color", is_flag=True, default=False, help="Disable colored output.") @click.version_option(version=__version__, prog_name="opensandbox") @click.pass_context def cli( ctx: click.Context, api_key: str | None, domain: str | None, protocol: str | None, request_timeout: int | None, output_format: str | None, config_path: Path | None, verbose: bool, no_color: bool, ) -> None: """OpenSandbox CLI — manage sandboxes from your terminal.""" if verbose: import logging logging.basicConfig(level=logging.DEBUG) resolved = resolve_config( cli_api_key=api_key, cli_domain=domain, cli_protocol=protocol, cli_timeout=request_timeout, cli_output=output_format, config_path=config_path, ) formatter = OutputFormatter( resolved["output_format"], color=not no_color and resolved.get("color", True), ) ctx.obj = ClientContext(resolved_config=resolved, output=formatter) ctx.call_on_close(lambda: ctx.obj.close()) # Register sub-command groups cli.add_command(sandbox_group) cli.add_command(command_group) cli.add_command(exec_cmd) cli.add_command(file_group) cli.add_command(code_group) cli.add_command(config_group) ================================================ FILE: cli/src/opensandbox_cli/output.py ================================================ # Copyright 2026 Alibaba Group Holding Ltd. # # 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. """Output formatting: table (rich), JSON, YAML.""" from __future__ import annotations import json import sys from contextlib import contextmanager from typing import Any, Generator, Sequence import click try: import yaml except ImportError: # pragma: no cover yaml = None # type: ignore[assignment] from pydantic import BaseModel from rich import box from rich.console import Console from rich.panel import Panel from rich.status import Status from rich.table import Table from rich.text import Text # --------------------------------------------------------------------------- # Status badge styling (sandbox state → color + icon) # --------------------------------------------------------------------------- _STATUS_STYLES: dict[str, tuple[str, str]] = { # state → (rich style, icon) "running": ("bold green", "●"), "ready": ("bold green", "●"), "healthy": ("bold green", "●"), "pending": ("bold yellow", "◐"), "creating": ("bold yellow", "◐"), "starting": ("bold yellow", "◐"), "paused": ("bold blue", "⏸"), "stopped": ("dim", "○"), "terminated": ("dim", "○"), "killed": ("dim", "○"), "error": ("bold red", "✗"), "failed": ("bold red", "✗"), "unhealthy": ("bold red", "✗"), "created": ("bold cyan", "✦"), } # Columns that contain status-like values _STATUS_COLUMNS = {"status", "state", "healthy"} # Columns that should be rendered in a dimmer style (long IDs, timestamps) _DIM_COLUMNS = {"created_at", "expires_at", "modified_at", "updated_at"} # Columns that are primary identifiers _ID_COLUMNS = {"id", "sandbox_id", "execution_id", "context_id"} def _style_value(col: str, value: str) -> Text: """Apply contextual styling to a cell value.""" lower = value.lower() if col in _STATUS_COLUMNS: style, icon = _STATUS_STYLES.get(lower, ("", "")) if style: return Text(f"{icon} {value}", style=style) if col in _DIM_COLUMNS: return Text(value, style="dim") if col in _ID_COLUMNS: return Text(value, style="bold cyan") return Text(value) class OutputFormatter: """Renders data in table / json / yaml format.""" def __init__(self, fmt: str = "table", *, color: bool = True) -> None: self.fmt = fmt self.color = color self.console = Console( stderr=False, no_color=not color, force_terminal=None ) self._err_console = Console( stderr=True, no_color=not color, force_terminal=None ) # ------------------------------------------------------------------ # Status messages with icons # ------------------------------------------------------------------ def success(self, msg: str) -> None: """Print a success message with ✅ icon.""" if self.color: self.console.print(f" [bold green]✅ {msg}[/]") else: click.echo(f"OK: {msg}") def info(self, msg: str) -> None: """Print an info message with ℹ️ icon.""" if self.color: self.console.print(f" [bold blue]ℹ️ {msg}[/]") else: click.echo(f"INFO: {msg}") def warning(self, msg: str) -> None: """Print a warning message with ⚠️ icon.""" if self.color: self._err_console.print(f" [bold yellow]⚠️ {msg}[/]") else: click.echo(f"WARN: {msg}", err=True) def error(self, msg: str) -> None: """Print an error message with ❌ icon.""" if self.color: self._err_console.print(f" [bold red]❌ {msg}[/]") else: click.echo(f"ERROR: {msg}", err=True) def error_panel(self, msg: str, title: str = "Error") -> None: """Print an error with a bold header and message.""" if self.color: self._err_console.print() self._err_console.print(f" [bold red]{title}[/]") self._err_console.print(f" [dim]{'─' * (len(title) + 2)}[/]") for line in msg.splitlines(): self._err_console.print(f" {line}") self._err_console.print() else: click.echo(f"ERROR [{title}]: {msg}", err=True) # ------------------------------------------------------------------ # Spinner for long-running operations # ------------------------------------------------------------------ @contextmanager def spinner(self, msg: str) -> Generator[Status, None, None]: """Context manager that shows a spinner while work is in progress.""" if self.color and self.fmt == "table": with self._err_console.status(f"[bold cyan]⏳ {msg}[/]", spinner="dots") as status: yield status else: # No spinner in non-color or non-table mode yield None # type: ignore[arg-type] # ------------------------------------------------------------------ # Panel output # ------------------------------------------------------------------ def panel(self, content: str, *, title: str | None = None, style: str = "cyan") -> None: """Print content inside a styled panel.""" if self.color: self.console.print(Panel( content, title=title, title_align="left", border_style=style, box=box.ROUNDED, padding=(0, 1), )) else: if title: click.echo(f"--- {title} ---") click.echo(content) def success_panel(self, data: dict[str, Any], *, title: str = "Success") -> None: """Print a success result with a header and indented key-value pairs.""" if self.fmt != "table": if self.fmt == "json": self._print_json(data) elif self.fmt == "yaml": self._print_yaml(data) return if self.color: self.console.print() self.console.print(f" [bold green]✓ {title}[/]") self.console.print(f" [dim]{'─' * (len(title) + 2)}[/]") for k, v in data.items(): self.console.print(f" [bold]{k}:[/] [cyan]{v}[/]") self.console.print() else: click.echo(f"--- {title} ---") for k, v in data.items(): click.echo(f" {k}: {v}") # ------------------------------------------------------------------ # Public helpers # ------------------------------------------------------------------ def print_model(self, model: BaseModel, title: str | None = None) -> None: """Print a single Pydantic model as key-value panel or JSON/YAML.""" data = _model_to_dict(model) if self.fmt == "json": self._print_json(data) elif self.fmt == "yaml": self._print_yaml(data) else: self._print_kv_table(data, title=title) def print_models( self, models: Sequence[BaseModel], columns: list[str], *, title: str | None = None, ) -> None: """Print a list of Pydantic models as a table or JSON/YAML.""" rows = [_model_to_dict(m) for m in models] if self.fmt == "json": self._print_json(rows) elif self.fmt == "yaml": self._print_yaml(rows) else: self._print_table(rows, columns, title=title) def print_rows( self, rows: list[dict[str, Any]], columns: list[str], *, title: str | None = None, ) -> None: """Print pre-processed rows (list of dicts) as a table or JSON/YAML.""" if self.fmt == "json": self._print_json(rows) elif self.fmt == "yaml": self._print_yaml(rows) else: self._print_table(rows, columns, title=title) def print_dict(self, data: dict[str, Any], title: str | None = None) -> None: """Print a flat dict.""" if self.fmt == "json": self._print_json(data) elif self.fmt == "yaml": self._print_yaml(data) else: self._print_kv_table(data, title=title) def print_text(self, text: str) -> None: """Print raw text (ignores format).""" click.echo(text) # ------------------------------------------------------------------ # Internal renderers # ------------------------------------------------------------------ def _print_json(self, data: Any) -> None: if self.color: self.console.print_json(json.dumps(data, default=str)) else: click.echo(json.dumps(data, indent=2, default=str)) def _print_yaml(self, data: Any) -> None: if yaml is None: click.secho( "PyYAML is not installed. Use --output json instead.", fg="red", err=True ) sys.exit(1) click.echo(yaml.dump(data, default_flow_style=False, allow_unicode=True).rstrip()) def _print_kv_table(self, data: dict[str, Any], *, title: str | None = None) -> None: table = Table( title=title, show_header=True, header_style="bold magenta", title_style="bold cyan", box=box.ROUNDED, border_style="bright_black", padding=(0, 1), show_lines=True, ) table.add_column("Key", style="bold cyan", no_wrap=True) table.add_column("Value") for k, v in data.items(): val_text = _style_value(k, str(v)) if v is not None else Text("-", style="dim") table.add_row(str(k), val_text) self.console.print(table) def _print_table( self, rows: list[dict[str, Any]], columns: list[str], *, title: str | None = None, ) -> None: table = Table( title=title, show_header=True, header_style="bold magenta", title_style="bold cyan", box=box.ROUNDED, border_style="bright_black", padding=(0, 1), row_styles=["", "dim"], ) for col in columns: style = "" if col in _ID_COLUMNS: style = "bold cyan" elif col in _DIM_COLUMNS: style = "dim" table.add_column(col.upper(), style=style, no_wrap=(col in _ID_COLUMNS)) for row in rows: cells: list[Text | str] = [] for col in columns: val = str(row.get(col, "-")) if col in _STATUS_COLUMNS: cells.append(_style_value(col, val)) else: cells.append(val) table.add_row(*cells) self.console.print(table) # ------------------------------------------------------------------ # Helpers # ------------------------------------------------------------------ def _model_to_dict(model: BaseModel) -> dict[str, Any]: return model.model_dump(mode="json") ================================================ FILE: cli/src/opensandbox_cli/utils.py ================================================ # Copyright 2026 Alibaba Group Holding Ltd. # # 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. """Shared CLI utilities: duration parsing, error handling, key-value parsing.""" from __future__ import annotations import functools import re import sys from datetime import timedelta import click # --------------------------------------------------------------------------- # Duration parsing (e.g. "10m", "1h30m", "90s", "2h") # --------------------------------------------------------------------------- _DURATION_RE = re.compile( r"^(?:(?P\d+)h)?(?:(?P\d+)m)?(?:(?P\d+)s)?$" ) def parse_duration(value: str) -> timedelta: """Parse a human-friendly duration string into a ``timedelta``. Supported formats: ``10m``, ``1h30m``, ``90s``, ``2h``, ``1h30m45s``. A plain integer is treated as seconds. """ value = value.strip() if not value: raise click.BadParameter("Duration cannot be empty") # Plain integer → seconds if value.isdigit(): return timedelta(seconds=int(value)) m = _DURATION_RE.match(value) if not m or not m.group(0): raise click.BadParameter( f"Invalid duration '{value}'. Use format like 10m, 1h30m, 90s." ) hours = int(m.group("hours") or 0) minutes = int(m.group("minutes") or 0) seconds = int(m.group("seconds") or 0) return timedelta(hours=hours, minutes=minutes, seconds=seconds) class DurationType(click.ParamType): """Click parameter type for duration strings.""" name = "duration" def convert( self, value: str, param: click.Parameter | None, ctx: click.Context | None ) -> timedelta: if isinstance(value, timedelta): return value try: return parse_duration(value) except click.BadParameter: self.fail( f"Invalid duration '{value}'. Use format like 10m, 1h30m, 90s.", param, ctx, ) DURATION = DurationType() # --------------------------------------------------------------------------- # Key=Value parsing (e.g. --env FOO=bar) # --------------------------------------------------------------------------- class KeyValueType(click.ParamType): """Click parameter type that parses ``KEY=VALUE`` strings into a tuple.""" name = "KEY=VALUE" def convert( self, value: str, param: click.Parameter | None, ctx: click.Context | None ) -> tuple[str, str]: if isinstance(value, tuple): return value if "=" not in value: self.fail(f"Expected KEY=VALUE format, got '{value}'", param, ctx) key, _, val = value.partition("=") return (key, val) KEY_VALUE = KeyValueType() # --------------------------------------------------------------------------- # Error handling decorator # --------------------------------------------------------------------------- def handle_errors(fn): # type: ignore[no-untyped-def] """Decorator that catches SDK / HTTP exceptions and prints a friendly message.""" @functools.wraps(fn) def wrapper(*args, **kwargs): # type: ignore[no-untyped-def] try: return fn(*args, **kwargs) except click.exceptions.Exit: raise except click.ClickException: raise except Exception as exc: # Import here to avoid circular imports at module level from opensandbox.exceptions import SandboxException # Try to get the OutputFormatter from the Click context ctx = click.get_current_context(silent=True) obj = getattr(ctx, "obj", None) if ctx else None output = getattr(obj, "output", None) if obj else None if output and hasattr(output, "error_panel"): if isinstance(exc, SandboxException): output.error_panel(str(exc), title="Sandbox Error") else: output.error_panel( f"{str(exc)}\n\n[dim]Type: {type(exc).__qualname__}[/]", title=type(exc).__name__, ) else: click.secho(f"Error: {exc}", fg="red", err=True) sys.exit(1) return wrapper ================================================ FILE: cli/tests/__init__.py ================================================ ================================================ FILE: cli/tests/conftest.py ================================================ # Copyright 2026 Alibaba Group Holding Ltd. # # 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. """Shared test fixtures.""" from __future__ import annotations from unittest.mock import MagicMock import pytest from click.testing import CliRunner from opensandbox_cli.output import OutputFormatter @pytest.fixture() def runner() -> CliRunner: return CliRunner() @pytest.fixture() def mock_manager() -> MagicMock: return MagicMock() @pytest.fixture() def mock_sandbox() -> MagicMock: return MagicMock() @pytest.fixture() def mock_client_context(mock_manager: MagicMock, mock_sandbox: MagicMock) -> MagicMock: """A mock ClientContext that avoids real SDK/HTTP calls.""" ctx = MagicMock() ctx.resolved_config = { "api_key": "test-key", "domain": "localhost:8080", "protocol": "http", "request_timeout": 30, "output_format": "json", "color": False, "default_image": None, "default_timeout": None, } ctx.output = OutputFormatter("json", color=False) ctx.get_manager.return_value = mock_manager ctx.connect_sandbox.return_value = mock_sandbox ctx.connection_config = MagicMock() ctx.close = MagicMock() return ctx ================================================ FILE: cli/tests/test_cli_help.py ================================================ # Copyright 2026 Alibaba Group Holding Ltd. # # 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. """Tests that all CLI commands register correctly and --help exits cleanly.""" from __future__ import annotations import pytest from click.testing import CliRunner from opensandbox_cli.main import cli @pytest.fixture() def runner() -> CliRunner: return CliRunner() # --------------------------------------------------------------------------- # Root # --------------------------------------------------------------------------- class TestRootCLI: def test_help(self, runner: CliRunner) -> None: result = runner.invoke(cli, ["--help"]) assert result.exit_code == 0 assert "OpenSandbox CLI" in result.output def test_version(self, runner: CliRunner) -> None: result = runner.invoke(cli, ["--version"]) assert result.exit_code == 0 assert "opensandbox" in result.output def test_root_lists_commands(self, runner: CliRunner) -> None: result = runner.invoke(cli, ["--help"]) for cmd in ("sandbox", "command", "exec", "file", "code", "config"): assert cmd in result.output # --------------------------------------------------------------------------- # Sandbox sub-commands # --------------------------------------------------------------------------- class TestSandboxHelp: def test_sandbox_help(self, runner: CliRunner) -> None: result = runner.invoke(cli, ["sandbox", "--help"]) assert result.exit_code == 0 for subcmd in ("create", "list", "get", "kill", "pause", "resume", "renew", "endpoint", "health", "metrics"): assert subcmd in result.output @pytest.mark.parametrize( "subcmd", ["create", "list", "get", "kill", "pause", "resume", "renew", "endpoint", "health", "metrics"], ) def test_sandbox_subcommand_help(self, runner: CliRunner, subcmd: str) -> None: result = runner.invoke(cli, ["sandbox", subcmd, "--help"]) assert result.exit_code == 0 assert subcmd in result.output.lower() or "usage" in result.output.lower() # --------------------------------------------------------------------------- # Command sub-commands # --------------------------------------------------------------------------- class TestCommandHelp: def test_command_help(self, runner: CliRunner) -> None: result = runner.invoke(cli, ["command", "--help"]) assert result.exit_code == 0 for subcmd in ("run", "status", "logs", "interrupt"): assert subcmd in result.output @pytest.mark.parametrize("subcmd", ["run", "status", "logs", "interrupt"]) def test_command_subcommand_help(self, runner: CliRunner, subcmd: str) -> None: result = runner.invoke(cli, ["command", subcmd, "--help"]) assert result.exit_code == 0 # --------------------------------------------------------------------------- # exec (top-level shortcut) # --------------------------------------------------------------------------- class TestExecHelp: def test_exec_help(self, runner: CliRunner) -> None: result = runner.invoke(cli, ["exec", "--help"]) assert result.exit_code == 0 assert "shortcut" in result.output.lower() or "command" in result.output.lower() # --------------------------------------------------------------------------- # File sub-commands # --------------------------------------------------------------------------- class TestFileHelp: def test_file_help(self, runner: CliRunner) -> None: result = runner.invoke(cli, ["file", "--help"]) assert result.exit_code == 0 for subcmd in ("cat", "write", "upload", "download", "rm", "mv", "mkdir", "rmdir", "search", "info", "chmod", "replace"): assert subcmd in result.output @pytest.mark.parametrize( "subcmd", ["cat", "write", "upload", "download", "rm", "mv", "mkdir", "rmdir", "search", "info", "chmod", "replace"], ) def test_file_subcommand_help(self, runner: CliRunner, subcmd: str) -> None: result = runner.invoke(cli, ["file", subcmd, "--help"]) assert result.exit_code == 0 # --------------------------------------------------------------------------- # Code sub-commands # --------------------------------------------------------------------------- class TestCodeHelp: def test_code_help(self, runner: CliRunner) -> None: result = runner.invoke(cli, ["code", "--help"]) assert result.exit_code == 0 for subcmd in ("run", "context", "interrupt"): assert subcmd in result.output def test_code_context_help(self, runner: CliRunner) -> None: result = runner.invoke(cli, ["code", "context", "--help"]) assert result.exit_code == 0 for subcmd in ("create", "list", "delete", "delete-all"): assert subcmd in result.output # --------------------------------------------------------------------------- # Config sub-commands # --------------------------------------------------------------------------- class TestConfigHelp: def test_config_help(self, runner: CliRunner) -> None: result = runner.invoke(cli, ["config", "--help"]) assert result.exit_code == 0 for subcmd in ("init", "show", "set"): assert subcmd in result.output @pytest.mark.parametrize("subcmd", ["init", "show", "set"]) def test_config_subcommand_help(self, runner: CliRunner, subcmd: str) -> None: result = runner.invoke(cli, ["config", subcmd, "--help"]) assert result.exit_code == 0 ================================================ FILE: cli/tests/test_commands.py ================================================ # Copyright 2026 Alibaba Group Holding Ltd. # # 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. """Tests for CLI commands with mocked SDK calls. Strategy: patch ``opensandbox_cli.main.ClientContext`` and ``resolve_config`` so the root ``cli`` callback creates our mock instead of a real SDK client. """ from __future__ import annotations import json from pathlib import Path from unittest.mock import MagicMock, patch import pytest from click.testing import CliRunner from opensandbox_cli.main import cli from opensandbox_cli.output import OutputFormatter @pytest.fixture() def runner() -> CliRunner: return CliRunner() def _build_mock_client_context( *, manager: MagicMock | None = None, sandbox: MagicMock | None = None, output_format: str = "json", ) -> MagicMock: ctx = MagicMock() ctx.resolved_config = { "api_key": "test-key", "domain": "localhost:8080", "protocol": "http", "request_timeout": 30, "output_format": output_format, "color": False, "default_image": None, "default_timeout": None, } ctx.output = OutputFormatter(output_format, color=False) ctx.get_manager.return_value = manager or MagicMock() ctx.connect_sandbox.return_value = sandbox or MagicMock() ctx.resolve_sandbox_id.side_effect = lambda prefix: prefix # passthrough ctx.connection_config = MagicMock() ctx.close = MagicMock() return ctx def _invoke( runner: CliRunner, args: list[str], *, manager: MagicMock | None = None, sandbox: MagicMock | None = None, output_format: str = "json", ) -> object: """Invoke CLI with mocked ClientContext.""" mock_ctx = _build_mock_client_context( manager=manager, sandbox=sandbox, output_format=output_format ) with patch("opensandbox_cli.main.resolve_config") as mock_resolve, \ patch("opensandbox_cli.main.ClientContext", return_value=mock_ctx), \ patch("opensandbox_cli.main.OutputFormatter", side_effect=lambda fmt, **kw: OutputFormatter(fmt, **kw)): mock_resolve.return_value = mock_ctx.resolved_config result = runner.invoke(cli, args, catch_exceptions=False) return result # --------------------------------------------------------------------------- # Config commands (no SDK mocking needed) # --------------------------------------------------------------------------- class TestConfigInit: def test_init_creates_file(self, runner: CliRunner, tmp_path: Path) -> None: cfg_path = tmp_path / "config.toml" result = runner.invoke(cli, ["config", "init", "--path", str(cfg_path)]) assert result.exit_code == 0 assert "Config file created" in result.output def test_init_refuses_overwrite(self, runner: CliRunner, tmp_path: Path) -> None: cfg_path = tmp_path / "config.toml" cfg_path.write_text("existing") result = runner.invoke(cli, ["config", "init", "--path", str(cfg_path)]) assert "already exists" in result.output def test_init_force_overwrites(self, runner: CliRunner, tmp_path: Path) -> None: cfg_path = tmp_path / "config.toml" cfg_path.write_text("old") result = runner.invoke(cli, ["config", "init", "--path", str(cfg_path), "--force"]) assert result.exit_code == 0 assert "Config file created" in result.output class TestConfigShow: def test_show_json_output(self, runner: CliRunner) -> None: result = runner.invoke(cli, ["-o", "json", "config", "show"]) assert result.exit_code == 0 data = json.loads(result.output) assert "api_key" in data def test_show_table_output(self, runner: CliRunner) -> None: result = runner.invoke(cli, ["config", "show"]) assert result.exit_code == 0 assert "api_key" in result.output class TestConfigSet: def test_set_updates_existing_field(self, runner: CliRunner, tmp_path: Path) -> None: cfg_path = tmp_path / "config.toml" runner.invoke(cli, ["config", "init", "--path", str(cfg_path)]) result = runner.invoke(cli, ["config", "set", "connection.domain", "new.host", "--path", str(cfg_path)]) assert result.exit_code == 0 assert "Set connection.domain = new.host" in result.output def test_set_rejects_flat_key(self, runner: CliRunner, tmp_path: Path) -> None: cfg_path = tmp_path / "config.toml" cfg_path.write_text("[connection]\n") result = runner.invoke(cli, ["config", "set", "flat_key", "value", "--path", str(cfg_path)]) assert "section.field" in result.output # --------------------------------------------------------------------------- # Sandbox commands # --------------------------------------------------------------------------- class TestSandboxList: def test_list_invokes_manager(self, runner: CliRunner) -> None: mock_mgr = MagicMock() mock_result = MagicMock() mock_result.sandbox_infos = [] mock_mgr.list_sandbox_infos.return_value = mock_result result = _invoke(runner, ["-o", "json", "sandbox", "list"], manager=mock_mgr) assert result.exit_code == 0 mock_mgr.list_sandbox_infos.assert_called_once() class TestSandboxKill: def test_kill_multiple(self, runner: CliRunner) -> None: mock_mgr = MagicMock() result = _invoke(runner, ["sandbox", "kill", "id1", "id2"], manager=mock_mgr) assert result.exit_code == 0 assert mock_mgr.kill_sandbox.call_count == 2 assert "Sandbox terminated: id1" in result.output assert "Sandbox terminated: id2" in result.output class TestSandboxPause: def test_pause_calls_manager(self, runner: CliRunner) -> None: mock_mgr = MagicMock() result = _invoke(runner, ["sandbox", "pause", "sb-123"], manager=mock_mgr) assert result.exit_code == 0 mock_mgr.pause_sandbox.assert_called_once_with("sb-123") assert "Sandbox paused: sb-123" in result.output class TestSandboxResume: def test_resume_calls_manager(self, runner: CliRunner) -> None: mock_mgr = MagicMock() result = _invoke(runner, ["sandbox", "resume", "sb-123"], manager=mock_mgr) assert result.exit_code == 0 mock_mgr.resume_sandbox.assert_called_once_with("sb-123") assert "Sandbox resumed: sb-123" in result.output # --------------------------------------------------------------------------- # File commands # --------------------------------------------------------------------------- class TestFileCat: def test_cat_outputs_content(self, runner: CliRunner) -> None: mock_sb = MagicMock() mock_sb.files.read_file.return_value = "hello world" result = _invoke(runner, ["file", "cat", "sb-1", "/etc/hostname"], sandbox=mock_sb) assert result.exit_code == 0 assert "hello world" in result.output mock_sb.files.read_file.assert_called_once_with("/etc/hostname", encoding="utf-8") class TestFileWrite: def test_write_with_content_flag(self, runner: CliRunner) -> None: mock_sb = MagicMock() result = _invoke( runner, ["file", "write", "sb-1", "/tmp/test.txt", "-c", "content here"], sandbox=mock_sb, ) assert result.exit_code == 0 assert "Written" in result.output mock_sb.files.write_file.assert_called_once() class TestFileRm: def test_rm_deletes_files(self, runner: CliRunner) -> None: mock_sb = MagicMock() result = _invoke( runner, ["file", "rm", "sb-1", "/tmp/a", "/tmp/b"], sandbox=mock_sb ) assert result.exit_code == 0 mock_sb.files.delete_files.assert_called_once_with(["/tmp/a", "/tmp/b"]) class TestFileMv: def test_mv_moves_file(self, runner: CliRunner) -> None: mock_sb = MagicMock() result = _invoke( runner, ["file", "mv", "sb-1", "/tmp/old", "/tmp/new"], sandbox=mock_sb ) assert result.exit_code == 0 assert "Moved: /tmp/old" in result.output and "/tmp/new" in result.output class TestFileMkdir: def test_mkdir_creates_dirs(self, runner: CliRunner) -> None: mock_sb = MagicMock() result = _invoke( runner, ["file", "mkdir", "sb-1", "/tmp/dir1", "/tmp/dir2"], sandbox=mock_sb ) assert result.exit_code == 0 assert "Created: /tmp/dir1" in result.output assert "Created: /tmp/dir2" in result.output class TestFileRmdir: def test_rmdir_removes_dirs(self, runner: CliRunner) -> None: mock_sb = MagicMock() result = _invoke( runner, ["file", "rmdir", "sb-1", "/workspace/old"], sandbox=mock_sb ) assert result.exit_code == 0 assert "Removed: /workspace/old" in result.output # --------------------------------------------------------------------------- # Command execution # --------------------------------------------------------------------------- class TestCommandRun: def test_background_run(self, runner: CliRunner) -> None: mock_sb = MagicMock() mock_execution = MagicMock() mock_execution.id = "exec-123" mock_sb.commands.run.return_value = mock_execution result = _invoke( runner, ["-o", "json", "command", "run", "sb-1", "-d", "echo", "hello"], sandbox=mock_sb, ) assert result.exit_code == 0 data = json.loads(result.output) assert data["execution_id"] == "exec-123" assert data["mode"] == "background" class TestExecShortcut: def test_exec_passes_to_run(self, runner: CliRunner) -> None: mock_sb = MagicMock() mock_execution = MagicMock() mock_execution.id = "exec-456" mock_sb.commands.run.return_value = mock_execution result = _invoke( runner, ["-o", "json", "exec", "sb-1", "-d", "--", "ls", "-la"], sandbox=mock_sb, ) assert result.exit_code == 0 mock_sb.commands.run.assert_called_once() class TestCommandInterrupt: def test_interrupt_calls_sdk(self, runner: CliRunner) -> None: mock_sb = MagicMock() result = _invoke( runner, ["command", "interrupt", "sb-1", "exec-789"], sandbox=mock_sb ) assert result.exit_code == 0 mock_sb.commands.interrupt.assert_called_once_with("exec-789") assert "Interrupted: exec-789" in result.output ================================================ FILE: cli/tests/test_config.py ================================================ # Copyright 2026 Alibaba Group Holding Ltd. # # 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. """Tests for opensandbox_cli.config — config loading and priority merging.""" from __future__ import annotations import os from pathlib import Path import pytest from opensandbox_cli.config import ( DEFAULT_CONFIG_TEMPLATE, init_config_file, load_config_file, resolve_config, ) # --------------------------------------------------------------------------- # load_config_file # --------------------------------------------------------------------------- class TestLoadConfigFile: def test_returns_empty_when_file_missing(self, tmp_path: Path) -> None: result = load_config_file(tmp_path / "nonexistent.toml") assert result == {} def test_parses_toml_file(self, tmp_path: Path) -> None: cfg = tmp_path / "config.toml" cfg.write_text( '[connection]\napi_key = "abc"\ndomain = "example.com"\n' ) result = load_config_file(cfg) assert result["connection"]["api_key"] == "abc" assert result["connection"]["domain"] == "example.com" def test_parses_all_sections(self, tmp_path: Path) -> None: cfg = tmp_path / "config.toml" cfg.write_text( '[connection]\napi_key = "k"\n\n' '[output]\nformat = "json"\ncolor = false\n\n' '[defaults]\nimage = "alpine"\ntimeout = "5m"\n' ) result = load_config_file(cfg) assert result["output"]["format"] == "json" assert result["output"]["color"] is False assert result["defaults"]["image"] == "alpine" assert result["defaults"]["timeout"] == "5m" # --------------------------------------------------------------------------- # resolve_config — priority: CLI > env > file > defaults # --------------------------------------------------------------------------- class TestResolveConfig: def test_defaults_when_nothing_configured(self, tmp_path: Path) -> None: cfg_path = tmp_path / "empty.toml" cfg_path.write_text("") result = resolve_config(config_path=cfg_path) assert result["api_key"] is None assert result["domain"] is None assert result["protocol"] == "http" assert result["request_timeout"] == 30 assert result["output_format"] == "table" assert result["color"] is True def test_file_values_override_defaults(self, tmp_path: Path) -> None: cfg = tmp_path / "config.toml" cfg.write_text( '[connection]\napi_key = "file-key"\ndomain = "file.host"\n' 'protocol = "https"\nrequest_timeout = 60\n\n' '[output]\nformat = "json"\ncolor = false\n\n' '[defaults]\nimage = "node:20"\ntimeout = "15m"\n' ) result = resolve_config(config_path=cfg) assert result["api_key"] == "file-key" assert result["domain"] == "file.host" assert result["protocol"] == "https" assert result["request_timeout"] == 60 assert result["output_format"] == "json" assert result["color"] is False assert result["default_image"] == "node:20" assert result["default_timeout"] == "15m" def test_env_overrides_file(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: cfg = tmp_path / "config.toml" cfg.write_text('[connection]\napi_key = "file-key"\ndomain = "file.host"\n') monkeypatch.setenv("OPEN_SANDBOX_API_KEY", "env-key") monkeypatch.setenv("OPEN_SANDBOX_DOMAIN", "env.host") monkeypatch.setenv("OPEN_SANDBOX_PROTOCOL", "https") monkeypatch.setenv("OPEN_SANDBOX_REQUEST_TIMEOUT", "120") monkeypatch.setenv("OPEN_SANDBOX_OUTPUT", "yaml") result = resolve_config(config_path=cfg) assert result["api_key"] == "env-key" assert result["domain"] == "env.host" assert result["protocol"] == "https" assert result["request_timeout"] == 120 assert result["output_format"] == "yaml" def test_cli_overrides_everything(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: cfg = tmp_path / "config.toml" cfg.write_text('[connection]\napi_key = "file-key"\n') monkeypatch.setenv("OPEN_SANDBOX_API_KEY", "env-key") result = resolve_config( cli_api_key="cli-key", cli_domain="cli.host", cli_protocol="https", cli_timeout=999, cli_output="yaml", config_path=cfg, ) assert result["api_key"] == "cli-key" assert result["domain"] == "cli.host" assert result["protocol"] == "https" assert result["request_timeout"] == 999 assert result["output_format"] == "yaml" def test_invalid_timeout_env_falls_through(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: cfg = tmp_path / "empty.toml" cfg.write_text("") monkeypatch.setenv("OPEN_SANDBOX_REQUEST_TIMEOUT", "not-a-number") result = resolve_config(config_path=cfg) # Falls through to default 30 assert result["request_timeout"] == 30 # --------------------------------------------------------------------------- # init_config_file # --------------------------------------------------------------------------- class TestInitConfigFile: def test_creates_default_config(self, tmp_path: Path) -> None: cfg_path = tmp_path / ".opensandbox" / "config.toml" result = init_config_file(cfg_path) assert result == cfg_path assert cfg_path.exists() content = cfg_path.read_text() assert "[connection]" in content assert "[output]" in content assert "[defaults]" in content def test_refuses_overwrite_without_force(self, tmp_path: Path) -> None: cfg_path = tmp_path / "config.toml" cfg_path.write_text("existing") with pytest.raises(FileExistsError, match="already exists"): init_config_file(cfg_path) def test_force_overwrites(self, tmp_path: Path) -> None: cfg_path = tmp_path / "config.toml" cfg_path.write_text("old content") init_config_file(cfg_path, force=True) assert cfg_path.read_text() == DEFAULT_CONFIG_TEMPLATE def test_creates_parent_directories(self, tmp_path: Path) -> None: cfg_path = tmp_path / "a" / "b" / "c" / "config.toml" init_config_file(cfg_path) assert cfg_path.exists() ================================================ FILE: cli/tests/test_output.py ================================================ # Copyright 2026 Alibaba Group Holding Ltd. # # 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. """Tests for opensandbox_cli.output — table, JSON, YAML rendering.""" from __future__ import annotations import json import pytest from pydantic import BaseModel from opensandbox_cli.output import OutputFormatter # --------------------------------------------------------------------------- # Test models # --------------------------------------------------------------------------- class FakeItem(BaseModel): id: str name: str score: int # --------------------------------------------------------------------------- # JSON output # --------------------------------------------------------------------------- class TestJsonOutput: def test_print_dict(self, capsys: pytest.CaptureFixture[str]) -> None: fmt = OutputFormatter("json", color=False) fmt.print_dict({"key": "value", "num": 42}) captured = capsys.readouterr() data = json.loads(captured.out) assert data == {"key": "value", "num": 42} def test_print_model(self, capsys: pytest.CaptureFixture[str]) -> None: fmt = OutputFormatter("json", color=False) item = FakeItem(id="abc", name="test", score=100) fmt.print_model(item) captured = capsys.readouterr() data = json.loads(captured.out) assert data["id"] == "abc" assert data["name"] == "test" assert data["score"] == 100 def test_print_models(self, capsys: pytest.CaptureFixture[str]) -> None: fmt = OutputFormatter("json", color=False) items = [ FakeItem(id="1", name="a", score=10), FakeItem(id="2", name="b", score=20), ] fmt.print_models(items, columns=["id", "name", "score"]) captured = capsys.readouterr() data = json.loads(captured.out) assert len(data) == 2 assert data[0]["id"] == "1" assert data[1]["name"] == "b" # --------------------------------------------------------------------------- # YAML output # --------------------------------------------------------------------------- class TestYamlOutput: def test_print_dict(self, capsys: pytest.CaptureFixture[str]) -> None: fmt = OutputFormatter("yaml", color=False) fmt.print_dict({"key": "value"}) captured = capsys.readouterr() assert "key: value" in captured.out def test_print_model(self, capsys: pytest.CaptureFixture[str]) -> None: fmt = OutputFormatter("yaml", color=False) item = FakeItem(id="x", name="y", score=5) fmt.print_model(item) captured = capsys.readouterr() assert "id: x" in captured.out assert "name: y" in captured.out assert "score: 5" in captured.out # --------------------------------------------------------------------------- # Table output # --------------------------------------------------------------------------- class TestTableOutput: def test_print_dict_contains_values(self, capsys: pytest.CaptureFixture[str]) -> None: fmt = OutputFormatter("table", color=False) fmt.print_dict({"host": "example.com", "port": 8080}, title="Config") captured = capsys.readouterr() assert "example.com" in captured.out assert "8080" in captured.out assert "Config" in captured.out def test_print_dict_none_renders_dash(self, capsys: pytest.CaptureFixture[str]) -> None: fmt = OutputFormatter("table", color=False) fmt.print_dict({"key": None}) captured = capsys.readouterr() assert "-" in captured.out def test_print_models_shows_headers(self, capsys: pytest.CaptureFixture[str]) -> None: fmt = OutputFormatter("table", color=False) items = [FakeItem(id="1", name="a", score=10)] fmt.print_models(items, columns=["id", "name", "score"], title="Items") captured = capsys.readouterr() assert "ID" in captured.out assert "NAME" in captured.out assert "SCORE" in captured.out def test_print_text_ignores_format(self, capsys: pytest.CaptureFixture[str]) -> None: fmt = OutputFormatter("json", color=False) fmt.print_text("hello world") captured = capsys.readouterr() assert captured.out.strip() == "hello world" ================================================ FILE: cli/tests/test_resolve_id.py ================================================ # Copyright 2026 Alibaba Group Holding Ltd. # # 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. """Tests for Docker-style sandbox ID prefix matching.""" from __future__ import annotations from unittest.mock import MagicMock import click import pytest from opensandbox_cli.client import ClientContext from opensandbox_cli.output import OutputFormatter def _make_sandbox_info(sandbox_id: str) -> MagicMock: """Create a mock SandboxInfo with given ID.""" info = MagicMock() info.id = sandbox_id return info def _make_paged_result( sandbox_ids: list[str], *, has_next_page: bool = False ) -> MagicMock: """Create a mock PagedSandboxInfos with pagination metadata.""" result = MagicMock() result.sandbox_infos = [_make_sandbox_info(sid) for sid in sandbox_ids] result.pagination = MagicMock() result.pagination.has_next_page = has_next_page return result def _make_client_context( sandbox_ids: list[str], *, pages: list[list[str]] | None = None, ) -> ClientContext: """Create a ClientContext with a mocked manager listing the given IDs. If *pages* is provided, each element is a separate page of sandbox IDs (useful for testing pagination). Otherwise all IDs are in a single page. """ ctx = ClientContext( resolved_config={ "api_key": "test-key", "domain": "localhost:8080", "protocol": "http", "request_timeout": 30, "output_format": "json", "color": False, "default_image": None, "default_timeout": None, }, output=OutputFormatter("json", color=False), ) # Mock the manager mock_mgr = MagicMock() if pages is not None: side_effects = [] for i, page_ids in enumerate(pages): has_next = i < len(pages) - 1 side_effects.append(_make_paged_result(page_ids, has_next_page=has_next)) mock_mgr.list_sandbox_infos.side_effect = side_effects else: mock_mgr.list_sandbox_infos.return_value = _make_paged_result(sandbox_ids) ctx._manager = mock_mgr return ctx class TestResolveSandboxId: """Test Docker-style prefix matching for sandbox IDs.""" def test_full_uuid_skips_listing(self) -> None: """A full UUID is returned directly without calling list.""" ctx = _make_client_context([]) full_id = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" assert ctx.resolve_sandbox_id(full_id) == full_id # Manager should NOT have been called ctx._manager.list_sandbox_infos.assert_not_called() def test_unique_prefix_resolves(self) -> None: """A unique prefix returns the full matching ID.""" ctx = _make_client_context([ "abc123-def456-7890-abcd-000000000001", "xyz789-def456-7890-abcd-000000000002", ]) result = ctx.resolve_sandbox_id("abc") assert result == "abc123-def456-7890-abcd-000000000001" def test_exact_match_among_multiple(self) -> None: """A prefix that uniquely matches one sandbox works.""" ctx = _make_client_context([ "sandbox-alpha-001", "sandbox-beta-002", "sandbox-gamma-003", ]) result = ctx.resolve_sandbox_id("sandbox-a") assert result == "sandbox-alpha-001" def test_ambiguous_prefix_raises(self) -> None: """Multiple matches raises ClickException with helpful message.""" ctx = _make_client_context([ "abc-111", "abc-222", "abc-333", ]) with pytest.raises(click.ClickException, match="Ambiguous ID prefix"): ctx.resolve_sandbox_id("abc") def test_ambiguous_error_shows_ids(self) -> None: """The ambiguous error lists the conflicting IDs.""" ctx = _make_client_context(["abc-111", "abc-222"]) with pytest.raises(click.ClickException) as exc_info: ctx.resolve_sandbox_id("abc") assert "abc-111" in str(exc_info.value) assert "abc-222" in str(exc_info.value) def test_no_match_raises(self) -> None: """No matches raises ClickException.""" ctx = _make_client_context(["xyz-001", "xyz-002"]) with pytest.raises(click.ClickException, match="No sandbox found"): ctx.resolve_sandbox_id("abc") def test_empty_sandbox_list_raises(self) -> None: """Empty sandbox list raises ClickException.""" ctx = _make_client_context([]) with pytest.raises(click.ClickException, match="No sandbox found"): ctx.resolve_sandbox_id("abc") def test_single_char_prefix(self) -> None: """Even a single character can match if unique.""" ctx = _make_client_context([ "a-sandbox-001", "b-sandbox-002", ]) result = ctx.resolve_sandbox_id("a") assert result == "a-sandbox-001" def test_full_id_matches_exactly(self) -> None: """A non-UUID full ID still matches via prefix logic.""" ctx = _make_client_context(["my-sandbox-123"]) result = ctx.resolve_sandbox_id("my-sandbox-123") assert result == "my-sandbox-123" def test_more_than_five_ambiguous_shows_ellipsis(self) -> None: """When >5 matches, the error shows '...'.""" ids = [f"sb-{i:03d}" for i in range(10)] ctx = _make_client_context(ids) with pytest.raises(click.ClickException) as exc_info: ctx.resolve_sandbox_id("sb-") assert "..." in str(exc_info.value) assert "10 sandboxes" in str(exc_info.value) # -- Pagination tests -- def test_match_on_second_page(self) -> None: """A prefix that only appears on page 2 is still found.""" ctx = _make_client_context( [], pages=[ ["xyz-001", "xyz-002"], ["abc-999"], ], ) result = ctx.resolve_sandbox_id("abc") assert result == "abc-999" def test_collision_across_pages(self) -> None: """Matches on different pages are detected as ambiguous.""" ctx = _make_client_context( [], pages=[ ["abc-001"], ["abc-002"], ], ) with pytest.raises(click.ClickException, match="Ambiguous ID prefix"): ctx.resolve_sandbox_id("abc") def test_no_match_across_all_pages(self) -> None: """No match after exhausting all pages raises ClickException.""" ctx = _make_client_context( [], pages=[ ["xyz-001"], ["xyz-002"], ], ) with pytest.raises(click.ClickException, match="No sandbox found"): ctx.resolve_sandbox_id("abc") ================================================ FILE: cli/tests/test_utils.py ================================================ # Copyright 2026 Alibaba Group Holding Ltd. # # 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. """Tests for opensandbox_cli.utils — duration parsing, key-value type, error handling.""" from __future__ import annotations from datetime import timedelta import click import pytest from opensandbox_cli.utils import DURATION, KEY_VALUE, parse_duration # --------------------------------------------------------------------------- # parse_duration # --------------------------------------------------------------------------- class TestParseDuration: @pytest.mark.parametrize( "input_str, expected", [ ("10", timedelta(seconds=10)), ("0", timedelta(seconds=0)), ("10s", timedelta(seconds=10)), ("5m", timedelta(minutes=5)), ("2h", timedelta(hours=2)), ("1h30m", timedelta(hours=1, minutes=30)), ("1h30m45s", timedelta(hours=1, minutes=30, seconds=45)), ("90s", timedelta(seconds=90)), ], ) def test_valid_durations(self, input_str: str, expected: timedelta) -> None: assert parse_duration(input_str) == expected @pytest.mark.parametrize( "input_str", [ "", "abc", "10x", "m10", "-5m", ], ) def test_invalid_durations(self, input_str: str) -> None: with pytest.raises(click.BadParameter): parse_duration(input_str) def test_strips_whitespace(self) -> None: assert parse_duration(" 10m ") == timedelta(minutes=10) # --------------------------------------------------------------------------- # DurationType (Click param type) # --------------------------------------------------------------------------- class TestDurationType: def test_converts_string(self) -> None: result = DURATION.convert("5m", None, None) assert result == timedelta(minutes=5) def test_passes_through_timedelta(self) -> None: td = timedelta(hours=1) result = DURATION.convert(td, None, None) # type: ignore[arg-type] assert result is td def test_invalid_raises_bad_parameter(self) -> None: with pytest.raises(click.exceptions.BadParameter): DURATION.convert("invalid", None, None) # --------------------------------------------------------------------------- # KeyValueType (Click param type) # --------------------------------------------------------------------------- class TestKeyValueType: def test_parses_simple_kv(self) -> None: assert KEY_VALUE.convert("FOO=bar", None, None) == ("FOO", "bar") def test_value_can_contain_equals(self) -> None: assert KEY_VALUE.convert("key=a=b=c", None, None) == ("key", "a=b=c") def test_empty_value(self) -> None: assert KEY_VALUE.convert("key=", None, None) == ("key", "") def test_missing_equals_fails(self) -> None: with pytest.raises(click.exceptions.BadParameter): KEY_VALUE.convert("no-equals", None, None) def test_passes_through_tuple(self) -> None: t = ("key", "val") result = KEY_VALUE.convert(t, None, None) # type: ignore[arg-type] assert result is t ================================================ FILE: components/egress/Dockerfile ================================================ # Copyright 2026 Alibaba Group Holding Ltd. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. FROM golang:1.24-bookworm AS builder WORKDIR /workspace ARG VERSION=dev ARG GIT_COMMIT=unknown ARG BUILD_TIME=unknown # Copy only go mod/sum first for better caching COPY components/egress/go.mod components/egress/go.sum ./components/egress/ # Bring internal module so replace ../internal works during download/build COPY components/internal ./components/internal WORKDIR /workspace/components/egress # Static-ish build (no cgo) to simplify runtime deps ENV CGO_ENABLED=0 RUN go mod download # Copy the rest of the egress sources COPY components/egress ./ RUN CGO_ENABLED=0 go build \ -ldflags "-X 'github.com/alibaba/opensandbox/internal/version.Version=${VERSION}' \ -X 'github.com/alibaba/opensandbox/internal/version.BuildTime=${BUILD_TIME}' \ -X 'github.com/alibaba/opensandbox/internal/version.GitCommit=${GIT_COMMIT}'" \ -o /out/egress . FROM debian:bookworm-slim # iptables is needed for DNS REDIRECT; ca-certificates for TLS to upstream resolvers RUN apt-get update \ && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ iptables \ iproute2 \ nftables \ ca-certificates \ sudo \ curl \ wget \ net-tools \ dnsutils \ netcat-openbsd \ iputils-ping \ traceroute \ telnet \ tcpdump \ nmap \ htop \ procps \ strace \ lsof \ && rm -rf /var/lib/apt/lists/* COPY --from=builder /out/egress /egress # Default entrypoint; expects OPENSANDBOX_NETWORK_POLICY env at runtime. ENTRYPOINT ["/egress"] ================================================ FILE: components/egress/README.md ================================================ # OpenSandbox Egress Sidecar The **Egress Sidecar** is a core component of OpenSandbox that provides **FQDN-based egress control**. It runs alongside the sandbox application container (sharing the same network namespace) and enforces declared network policies. ## Features - **FQDN-based Allowlist**: Control outbound traffic by domain name (e.g., `api.github.com`). - **Wildcard Support**: Allow subdomains using wildcards (e.g., `*.pypi.org`). - **Transparent Interception**: Uses transparent DNS proxying; no application configuration required. - **Dynamic DNS (dns+nft mode)**: When a domain is allowed and the proxy resolves it, the resolved A/AAAA IPs are added to nftables with TTL so that default-deny + domain-allow is enforced at the network layer. - **Privilege Isolation**: Requires `CAP_NET_ADMIN` only for the sidecar; the application container runs unprivileged. - **Graceful Degradation**: If `CAP_NET_ADMIN` is missing, it warns and disables enforcement instead of crashing. ## Architecture The egress control is implemented as a **Sidecar** that shares the network namespace with the sandbox application. 1. **DNS Proxy (Layer 1)**: - Runs on `127.0.0.1:15353`. - `iptables` rules redirect all port 53 (DNS) traffic to this proxy. - Filters queries based on the allowlist. - Returns `NXDOMAIN` for denied domains. 2. **Network Filter (Layer 2)** (when `OPENSANDBOX_EGRESS_MODE=dns+nft`): - Uses `nftables` to enforce IP-level allow/deny. Resolved IPs for allowed domains are added to dynamic allow sets with TTL (dynamic DNS). - At startup, the sidecar whitelists **127.0.0.1** (redirect target for the proxy) and **nameserver IPs** from `/etc/resolv.conf` so DNS resolution and proxy upstream work (including private DNS). Nameserver count is capped and invalid IPs are filtered; see [Configuration](#configuration). ## Requirements - **Runtime**: Docker or Kubernetes. - **Capabilities**: `CAP_NET_ADMIN` (for the sidecar container only). - **Kernel**: Linux kernel with `iptables` support. ## Configuration - Policy bootstrap & runtime: - Default deny-all. Seed initial policy via `OPENSANDBOX_EGRESS_RULES` (JSON, same shape as `/policy`); empty/`{}`/`null` stays deny-all. - `/policy` at runtime; empty body resets to default deny-all. - HTTP service: - Listen address: `OPENSANDBOX_EGRESS_HTTP_ADDR` (default `:18080`). - Auth: `OPENSANDBOX_EGRESS_TOKEN` with header `OPENSANDBOX-EGRESS-AUTH: `; if unset, endpoint is open. - Mode (`OPENSANDBOX_EGRESS_MODE`, default `dns`): - `dns`: DNS proxy only, no nftables (IP/CIDR rules have no effect at L2). - `dns+nft`: enable nftables; if nft apply fails, fallback to `dns`. IP/CIDR enforcement and DoH/DoT blocking require this mode. - **Nameserver exempt** Set `OPENSANDBOX_EGRESS_NAMESERVER_EXEMPT` to a comma-separated list of **nameserver IPs** (e.g. `26.26.26.26` or `26.26.26.26,100.100.2.116`). Only single IPs are supported; CIDR entries are ignored. Traffic to these IPs on port 53 is not redirected to the proxy (iptables RETURN). In `dns+nft` mode, these IPs are also merged into the nft allow set so proxy upstream traffic to them (sent without SO_MARK) is accepted. Use when the upstream is reachable only via a specific route (e.g. tunnel) and SO_MARK would send proxy traffic elsewhere. - **DNS and nft mode (nameserver whitelist)** In `dns+nft` mode, the sidecar automatically allows: - **127.0.0.1** — so packets redirected by iptables to the proxy (127.0.0.1:15353) are accepted by nft. - **Nameserver IPs** from `/etc/resolv.conf` — so client DNS and proxy upstream work (e.g. private DNS). Nameserver IPs are validated (unspecified and loopback are skipped) and capped. Use `OPENSANDBOX_EGRESS_MAX_NS` (default `3`; `0` = no cap, `1`–`10` = cap). See [SECURITY-RISKS.md](SECURITY-RISKS.md) for trust and scope of this whitelist. - **Blocked hostname webhook** - `OPENSANDBOX_EGRESS_DENY_WEBHOOK`: HTTP endpoint URL. When set, egress asynchronously POSTs JSON **only when a hostname is denied**: `{"hostname": "", "timestamp": "", "source": "opensandbox-egress", "sandboxId": ""}`. Default timeout 5s, up to 3 retries with exponential backoff starting at 1s; 4xx is not retried, 5xx/network errors are retried. - `OPENSANDBOX_EGRESS_SANDBOX_ID`: optional sandbox identifier injected into the webhook payload as `sandboxId`. The value is read once at startup (unset → empty string). - **Allow requirement**: you must allow the webhook host (or its IP/CIDR) in the policy; with default deny, if you don’t explicitly allow it, the webhook traffic will be blocked by egress itself. Example: `{"defaultAction":"deny","egress":[{"action":"allow","target":"webhook.example.com"}]}`. If a broader deny CIDR covers the resolved IP, it will still be blocked—adjust your policy accordingly. - DoH/DoT blocking: - DoT (tcp/udp 853) blocked by default. - Optional DoH over 443: `OPENSANDBOX_EGRESS_BLOCK_DOH_443=true`. If enabled without blocklist, all 443 is dropped. - DoH blocklist (IP/CIDR, comma-separated): `OPENSANDBOX_EGRESS_DOH_BLOCKLIST="9.9.9.9,1.1.1.1/32,2001:db8::/32"`. ### Runtime HTTP API - Default listen address: `:18080` (override with `OPENSANDBOX_EGRESS_HTTP_ADDR`). - Endpoints: - `GET /policy` — returns the current policy. - `POST /policy` — replaces the policy. Empty/whitespace/`{}`/`null` resets to default deny-all. - `PATCH /policy` — merge/append rules at runtime. Body **must** be a JSON array of egress rules (not wrapped in an object). New rules are placed before existing ones (same target overrides), so a later PATCH can override prior wildcard denies with a more specific allow, and vice versa. Examples: - DNS allowlist (default deny): ```bash curl -XPOST http://127.0.0.1:18080/policy \ -d '{"defaultAction":"deny","egress":[{"action":"allow","target":"*.bing.com"}]}' ``` - DNS blocklist (default allow): ```bash curl -XPOST http://127.0.0.1:18080/policy \ -d '{"defaultAction":"allow","egress":[{"action":"deny","target":"*.bing.com"}]}' ``` - IP/CIDR only: ```bash curl -XPOST http://127.0.0.1:18080/policy \ -d '{"defaultAction":"deny","egress":[{"action":"allow","target":"1.1.1.1"},{"action":"deny","target":"10.0.0.0/8"}]}' ``` - Mixed DNS + IP/CIDR: ```bash curl -XPOST http://127.0.0.1:18080/policy \ -d '{"defaultAction":"deny","egress":[{"action":"allow","target":"*.example.com"},{"action":"allow","target":"203.0.113.0/24"},{"action":"deny","target":"*.bad.com"}]}' ``` - Merge-only PATCH (override wildcard deny with a specific allow): ```bash # baseline: deny *.cloudflare.com curl -XPOST http://127.0.0.1:18080/policy \ -d '{"defaultAction":"allow","egress":[{"action":"deny","target":"*.cloudflare.com"}]}' # allow a specific host; PATCH rules are prepended, so this wins curl -XPATCH http://127.0.0.1:18080/policy \ -d '[{"action":"allow","target":"www.cloudflare.com"}]' ``` ## Build & Run ### 1. Build Docker Image ```bash # Build locally docker build -t opensandbox/egress:local . # Or use the build script (multi-arch) ./build.sh ``` ### 2. Run Locally (Docker) To test the sidecar with a sandbox application: 1. **Start the Sidecar** (creates the network namespace): ```bash docker run -d --name sandbox-egress \ --cap-add=NET_ADMIN \ opensandbox/egress:local ``` *Note: `CAP_NET_ADMIN` is required for `iptables` redirection.* After start, push policy via HTTP (empty body resets to deny-all): ```bash curl -XPOST http://11.167.84.130:18080/policy \ -H "OPENSANDBOX-EGRESS-AUTH: $OPENSANDBOX_EGRESS_TOKEN" \ -d '{"defaultAction":"deny","egress":[{"action":"allow","target":"*.bing.com"}]}' ``` 2. **Start Application** (shares sidecar's network): ```bash docker run --rm -it \ --network container:sandbox-egress \ curlimages/curl \ sh ``` 3. **Verify**: Inside the application container: ```bash # Allowed domain curl -I https://google.com # Should succeed # Denied domain curl -I https://github.com # Should fail (resolve error) ``` ## Development - **Language**: Go 1.24+ - **Key Packages**: - `pkg/dnsproxy`: DNS server and policy matching logic. - `pkg/iptables`: `iptables` rule management. - `pkg/nftables`: nftables static/dynamic rules and DNS-resolved IP sets. - `pkg/policy`: Policy parsing and definition. - **Main (egress)**: - `nameserver.go`: Builds the list of IPs to whitelist for DNS in nft mode (127.0.0.1 + validated/capped nameservers from resolv.conf). ```bash # Run tests go test ./... ``` ### E2E benchmark: dns vs dns+nft (sync dynamic IP write) An end-to-end benchmark compares **dns** (pass-through, no nft write) and **dns+nft** (sync `AddResolvedIPs` before each DNS reply) under real conditions: sidecar in Docker, iptables redirect, real DNS + HTTPS from a client container. ```bash ./tests/bench-dns-nft.sh ``` More details in [docs/benchmark.md](docs/benchmark.md). ## Troubleshooting - **"iptables setup failed"**: Ensure the sidecar container has `--cap-add=NET_ADMIN`. - **DNS resolution fails for all domains**: Check upstream reachability from the sidecar (`ip route`, `dig @ . NS +timeout=3`). In `dns+nft` mode, check logs for `[dns] whitelisting proxy listen + N nameserver(s)`. - **Traffic not blocked**: If nftables apply fails, the sidecar falls back to dns; check logs, `nft list table inet opensandbox`, and `CAP_NET_ADMIN`. ================================================ FILE: components/egress/TODO.md ================================================ # Egress Sidecar TODO (Linux MVP → Full OSEP-0001) - Layer 2 still partial: static IP/CIDR now pushed to nftables, DoH/DoT blocking added (853 + optional 443 blocklist). DNS-learned IPs/dynamic isolation planned (see Short-term priorities). - Policy surface: IP/CIDR parsing/validation done; `require_full_isolation` and richer validation messages are out of scope (see No goals). - Observability missing: no violation logs. - Capability probing missing: no CAP_NET_ADMIN/nftables detection; hostNetwork 已由 server 侧阻断。 Capability detection + mode exposure moved to No goals. - Platform integration completed: specs/SDK/server wiring done; NET_ADMIN only on sidecar. - No IPv6; startup ordering not enforced (relies on container start order). ## Short-term priorities (suggested order) 1) Layer 2 via nftables - Tune DoH/DoT rules (ordering, allow-list exceptions, counters). 4) Observability & logging - Violation logs (domain/action/upstream IP); expose current enforcement mode. - Optional lightweight health/status endpoint. 6) Security hardening - Whitelist/validate upstream DNS to avoid arbitrary 53 egress abuse. - Document bypass/limits (dns-only can be bypassed via direct IP/DoH). 7) IPv6 & tests - Handle IPv6 support or explicit non-support. - Unit/integration tests: interception, graceful degrade, nftables, DoH blocking, hostNetwork rejection. ## No goals (explicitly excluded) - Capability probing & mode exposure (CAP_NET_ADMIN/nft detection, mode surfacing). - Policy expansion: `require_full_isolation` and richer validation errors. ## Dev notes - Current behavior: default deny-all baseline even when no policy is provided; POST /policy empty resets to deny-all; env bootstrap defaults to deny-all. - DNS proxy always runs; SO_MARK=0x1 bypass for proxy’s own upstream DNS; iptables only redirects port 53, no other DROP rules. - nftables: static IP/CIDR applied on start and policy update; retry without delete-table if table absent; failures fall back to DNS-only. - Runtime deps: Linux, `CAP_NET_ADMIN`, `iptables`/`nft` binaries; upstream DNS must be reachable and recursive. ================================================ FILE: components/egress/build.sh ================================================ #!/bin/bash # Copyright 2026 Alibaba Group Holding Ltd. # # 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. set -ex TAG=${TAG:-latest} VERSION=${VERSION:-$(git describe --tags --always --dirty 2>/dev/null || echo "dev")} GIT_COMMIT=${GIT_COMMIT:-$(git rev-parse HEAD 2>/dev/null || echo "unknown")} BUILD_TIME=${BUILD_TIME:-$(date -u +"%Y-%m-%dT%H:%M:%SZ")} REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || realpath "$(dirname "$0")/../..") cd "${REPO_ROOT}" docker buildx rm egress-builder || true docker buildx create --use --name egress-builder docker buildx inspect --bootstrap docker buildx ls LATEST_TAGS=() if [[ "${TAG}" == v* ]]; then LATEST_TAGS+=(-t opensandbox/egress:latest -t sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/egress:latest) fi docker buildx build \ -t opensandbox/egress:${TAG} \ -t sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/egress:${TAG} \ "${LATEST_TAGS[@]}" \ -f components/egress/Dockerfile \ --build-arg VERSION="${VERSION}" \ --build-arg GIT_COMMIT="${GIT_COMMIT}" \ --build-arg BUILD_TIME="${BUILD_TIME}" \ --platform linux/amd64,linux/arm64 \ --push \ . ================================================ FILE: components/egress/docs/benchmark.md ================================================ # Egress Benchmark This document describes the **Egress Sidecar** end-to-end benchmark: it compares **dns** and **dns+nft** modes under real conditions for latency and throughput. ## Purpose - **dns**: DNS proxy only (pass-through), no nftables writes; used as the baseline. - **dns+nft**: DNS proxy plus synchronous `AddResolvedIPs` before each DNS reply, writing resolved IPs into nftables for L2 egress enforcement. The benchmark runs the same workload in both modes and reports end-to-end latency (P50, P99) and throughput (Req/s) to measure the overhead of the synchronous nft write path. ## Environment and Flow - **Environment**: The Egress sidecar runs in a Docker container on the host. The container includes the sidecar (DNS proxy and optional nft), iptables redirect of port 53 to the proxy, and the policy server on port 18080. The workload runs **inside the same container**: DNS and HTTPS traffic go through the proxy. - **Flow** (per phase): 1. Start the sidecar with the chosen mode (`dns` or `dns+nft`). 2. Wait for health checks, then push the allow list to `/policy` (see domain list below). 3. Write the domain list into the container as `/tmp/bench-domains.txt` (one `https://` per line). 4. **Warm-up**: One request to each of the first 10 domains (10 concurrent), 1 round. 5. **Timed run**: One request per domain for all domains (N concurrent per round), for 10 rounds; each request records `time_namelookup` and `time_total`. 6. Copy results from the container and compute P50, P99, average latency, and Req/s. - **Execution order**: **dns+nft** runs first, then **dns**; the comparison table is printed at the end. ## Workload - **Domain list**: Read from `components/egress/tests/hostname.txt`, one domain per line (lines starting with `#` and empty lines are ignored). Default is about 100 resolvable domains. - **Rounds and concurrency**: The script uses `ROUNDS=10`. Each round issues one HTTPS request per domain in `hostname.txt`, with all requests in that round concurrent; 10 rounds total. - **Total requests**: `TOTAL_REQUESTS = ROUNDS × NUM_DOMAINS` (e.g. 10 × 100 = 1000). - **Per request**: Inside the container, `curl -o /dev/null -s -w "%{time_namelookup}\t%{time_total}\n"` is used against `https://`, with a 10s timeout per request; the whole benchmark run has a 300s wall-clock timeout. ## Policy - Policy is default-deny with explicit allow rules: one `{"action":"allow","target":""}` per domain in `hostname.txt` is sent via `POST /policy`, so every domain used in the benchmark is allowed. ## How to Run **Script**: `components/egress/tests/bench-e2e-dns-nft.sh` **Requirements**: Docker and `curl` on the host (for pushing policy); the Egress image includes `curl` for the workload. **Commands** (from repo root or from `components/egress`): ```bash ./tests/bench-dns-nft.sh ``` The script resolves `tests/hostname.txt` relative to its own path, so the working directory does not need to be changed. ## Configuration | Item | Location / variable | Default / notes | |---------------------|----------------------------------------|------------------------------------------------| | Domain list | `components/egress/tests/hostname.txt` | One domain per line; `#` comments allowed | | Rounds | `ROUNDS` in script | 10 | | Per-request timeout | `CURL_TIMEOUT` in script | 10 seconds | | Benchmark timeout | `BENCH_EXEC_TIMEOUT` in script | 300 seconds (max wall time for the timed run) | | Image | `IMG` in script | See script; override for a locally built image | Changing the number of domains or rounds updates the total request count; the report shows “N rounds × M domains” for the current config. ## Output and Metrics - **Terminal**: A table with **Req/s**, **Avg(s)**, **P50(s)**, **P99(s)** for both modes, plus short notes (dns vs dns+nft, warm-up, first-resolution cost). - **Artifacts** (on the host under `/tmp`): `bench-e2e-dns-total.txt`, `bench-e2e-dns+nft-total.txt` (one `time_total` per line), and `-namelookup.txt`, `-wall.txt`, etc., for further analysis or plotting. ## Notes - The first resolution of a domain in dns+nft triggers a DNS lookup and an nft write, so cost is higher; later requests for the same domain hit the set and are cheaper. The multi-round, multi-domain design mixes cold and warm resolution. - In CI (e.g. GitHub Actions), the script wraps the timed-run `docker exec` with `timeout` inside the shell function so `timeout` runs a real command, not a function name, avoiding “No such file or directory” errors. ================================================ FILE: components/egress/go.mod ================================================ module github.com/alibaba/opensandbox/egress go 1.24.0 require ( github.com/alibaba/opensandbox/internal v0.0.0 github.com/miekg/dns v1.1.61 github.com/stretchr/testify v1.11.1 golang.org/x/sys v0.31.0 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect go.uber.org/multierr v1.10.0 // indirect go.uber.org/zap v1.27.0 // indirect golang.org/x/mod v0.18.0 // indirect golang.org/x/net v0.38.0 // indirect golang.org/x/sync v0.7.0 // indirect golang.org/x/tools v0.22.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) replace github.com/alibaba/opensandbox/internal => ../internal ================================================ FILE: components/egress/go.sum ================================================ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/miekg/dns v1.1.61 h1:nLxbwF3XxhwVSm8g9Dghm9MHPaUZuqhPiGL+675ZmEs= github.com/miekg/dns v1.1.61/go.mod h1:mnAarhS3nWaW+NVP2wTkYVIZyHNJ098SJZUki3eykwQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0= golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: components/egress/main.go ================================================ // Copyright 2026 Alibaba Group Holding Ltd. // // 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. package main import ( "context" "net/netip" "os" "os/signal" "strings" "syscall" "github.com/alibaba/opensandbox/egress/pkg/constants" "github.com/alibaba/opensandbox/egress/pkg/dnsproxy" "github.com/alibaba/opensandbox/egress/pkg/events" "github.com/alibaba/opensandbox/egress/pkg/iptables" "github.com/alibaba/opensandbox/egress/pkg/log" slogger "github.com/alibaba/opensandbox/internal/logger" "github.com/alibaba/opensandbox/internal/version" ) func main() { version.EchoVersion("OpenSandbox Egress") ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer cancel() ctx = withLogger(ctx) defer log.Logger.Sync() initialRules, err := dnsproxy.LoadPolicyFromEnvVar(constants.EnvEgressRules) if err != nil { log.Fatalf("failed to parse %s: %v", constants.EnvEgressRules, err) } allowIPs := AllowIPsForNft("/etc/resolv.conf") // Merge nameserver exempt IPs into nft allow set so proxy traffic to them (no SO_MARK) is allowed in dns+nft mode. for _, addr := range dnsproxy.ParseNameserverExemptList() { if !containsAddr(allowIPs, addr) { allowIPs = append(allowIPs, addr) } } mode := parseMode() log.Infof("enforcement mode: %s", mode) nftMgr := createNftManager(mode) proxy, err := dnsproxy.New(initialRules, "") if err != nil { log.Fatalf("failed to init dns proxy: %v", err) } if err := proxy.Start(ctx); err != nil { log.Fatalf("failed to start dns proxy: %v", err) } log.Infof("dns proxy started on 127.0.0.1:15353") if blockWebhookURL := strings.TrimSpace(os.Getenv(constants.EnvBlockedWebhook)); blockWebhookURL != "" { blockedBroadcaster := events.NewBroadcaster(ctx, events.BroadcasterConfig{QueueSize: 256}) blockedBroadcaster.AddSubscriber(events.NewWebhookSubscriber(blockWebhookURL)) proxy.SetBlockedBroadcaster(blockedBroadcaster) defer blockedBroadcaster.Close() log.Infof("denied hostname webhook enabled") } exemptDst := dnsproxy.ParseNameserverExemptList() if len(exemptDst) > 0 { log.Infof("nameserver exempt list: %v (proxy upstream in this list will not set SO_MARK)", exemptDst) } if err := iptables.SetupRedirect(15353, exemptDst); err != nil { log.Fatalf("failed to install iptables redirect: %v", err) } log.Infof("iptables redirect configured (OUTPUT 53 -> 15353) with SO_MARK bypass for proxy upstream traffic") setupNft(ctx, nftMgr, initialRules, proxy, allowIPs) // start policy server httpAddr := envOrDefault(constants.EnvEgressHTTPAddr, constants.DefaultEgressServerAddr) if err = startPolicyServer(ctx, proxy, nftMgr, mode, httpAddr, os.Getenv(constants.EnvEgressToken), allowIPs); err != nil { log.Fatalf("failed to start policy server: %v", err) } log.Infof("policy server listening on %s (POST /policy)", httpAddr) <-ctx.Done() log.Infof("received shutdown signal; exiting") _ = os.Stderr.Sync() } func withLogger(ctx context.Context) context.Context { level := envOrDefault(constants.EnvEgressLogLevel, "info") logger := slogger.MustNew(slogger.Config{Level: level}).Named("opensandbox.egress") return log.WithLogger(ctx, logger) } func envOrDefault(key, defaultVal string) string { if v := strings.TrimSpace(os.Getenv(key)); v != "" { return v } return defaultVal } func isTruthy(v string) bool { switch strings.ToLower(strings.TrimSpace(v)) { case "1", "true", "yes", "y", "on": return true default: return false } } func containsAddr(addrs []netip.Addr, a netip.Addr) bool { for _, x := range addrs { if x == a { return true } } return false } func parseMode() string { mode := strings.ToLower(strings.TrimSpace(os.Getenv(constants.EnvEgressMode))) switch mode { case "", constants.PolicyDnsOnly: return constants.PolicyDnsOnly case constants.PolicyDnsNft: return constants.PolicyDnsNft default: log.Warnf("invalid %s=%s, falling back to dns", constants.EnvEgressMode, mode) return constants.PolicyDnsOnly } } ================================================ FILE: components/egress/nameserver.go ================================================ // Copyright 2026 Alibaba Group Holding Ltd. // // 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. package main import ( "net/netip" "os" "strconv" "github.com/alibaba/opensandbox/egress/pkg/constants" "github.com/alibaba/opensandbox/egress/pkg/dnsproxy" "github.com/alibaba/opensandbox/egress/pkg/log" ) // AllowIPsForNft returns the list of IPs to merge into the nft allow set for DNS in dns+nft mode: // 127.0.0.1 (proxy listen / iptables redirect target) plus validated, capped nameserver IPs from resolvPath. // Validation: skips unspecified (0.0.0.0, ::) and loopback (127.x, ::1). // Cap: at most max nameservers (default 3; set EGRESS_MAX_NAMESERVERS=0 for no cap, or 1–10). func AllowIPsForNft(resolvPath string) []netip.Addr { raw, _ := dnsproxy.ResolvNameserverIPs(resolvPath) maxNsCount := maxNameserversFromEnv() var validated []netip.Addr for _, ip := range raw { if maxNsCount > 0 && len(validated) >= maxNsCount { break } if !isValidNameserverIP(ip) { continue } validated = append(validated, ip) } // 127.0.0.1 first so packets redirected to proxy are accepted by nft. out := make([]netip.Addr, 0, 1+len(validated)) out = append(out, netip.MustParseAddr("127.0.0.1")) out = append(out, validated...) if len(out) > 1 { log.Infof("[dns] whitelisting proxy listen + %d nameserver(s) for nft: %v", len(validated), formatIPs(out)) } else { log.Infof("[dns] whitelisting proxy listen (127.0.0.1); no valid nameserver IPs from %s", resolvPath) } return out } func maxNameserversFromEnv() int { s := os.Getenv(constants.EnvMaxNameservers) if s == "" { return constants.DefaultMaxNameservers } n, err := strconv.Atoi(s) if err != nil || n < 0 { return constants.DefaultMaxNameservers } if n > 10 { return 10 } // 0 = no cap return n } func isValidNameserverIP(ip netip.Addr) bool { if ip.IsUnspecified() { return false } if ip.IsLoopback() { return false } return true } func formatIPs(ips []netip.Addr) []string { out := make([]string, len(ips)) for i, ip := range ips { out[i] = ip.String() } return out } ================================================ FILE: components/egress/nameserver_test.go ================================================ // Copyright 2026 Alibaba Group Holding Ltd. // // 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. package main import ( "net/netip" "os" "path/filepath" "testing" "github.com/alibaba/opensandbox/egress/pkg/constants" "github.com/stretchr/testify/require" ) func TestAllowIPsForNft_EmptyResolv(t *testing.T) { dir := t.TempDir() resolv := filepath.Join(dir, "resolv.conf") require.NoError(t, os.WriteFile(resolv, []byte("# empty\n"), 0644)) ips := AllowIPsForNft(resolv) require.Len(t, ips, 1, "expected 1 IP (127.0.0.1)") require.Equal(t, netip.MustParseAddr("127.0.0.1"), ips[0]) } func TestAllowIPsForNft_ValidNameservers(t *testing.T) { dir := t.TempDir() resolv := filepath.Join(dir, "resolv.conf") // Standard resolv.conf with two nameservers content := "nameserver 192.168.65.7\nnameserver 10.0.0.1\n" require.NoError(t, os.WriteFile(resolv, []byte(content), 0644)) ips := AllowIPsForNft(resolv) require.Len(t, ips, 3, "expected 3 IPs (127.0.0.1 + 2 nameservers)") require.Equal(t, netip.MustParseAddr("127.0.0.1"), ips[0], "expected first 127.0.0.1") require.Equal(t, netip.MustParseAddr("192.168.65.7"), ips[1], "expected 192.168.65.7") require.Equal(t, netip.MustParseAddr("10.0.0.1"), ips[2], "expected 10.0.0.1") } func TestAllowIPsForNft_FiltersInvalid(t *testing.T) { dir := t.TempDir() resolv := filepath.Join(dir, "resolv.conf") // 0.0.0.0 and 127.0.0.11 should be filtered; 192.168.1.1 kept content := "nameserver 0.0.0.0\nnameserver 192.168.1.1\nnameserver 127.0.0.11\n" require.NoError(t, os.WriteFile(resolv, []byte(content), 0644)) ips := AllowIPsForNft(resolv) require.Len(t, ips, 2, "expected 2 IPs (127.0.0.1 + 192.168.1.1)") require.Equal(t, netip.MustParseAddr("127.0.0.1"), ips[0], "expected first 127.0.0.1") require.Equal(t, netip.MustParseAddr("192.168.1.1"), ips[1], "expected 192.168.1.1") } func TestAllowIPsForNft_Cap(t *testing.T) { dir := t.TempDir() resolv := filepath.Join(dir, "resolv.conf") content := "nameserver 10.0.0.1\nnameserver 10.0.0.2\nnameserver 10.0.0.3\nnameserver 10.0.0.4\n" require.NoError(t, os.WriteFile(resolv, []byte(content), 0644)) old := os.Getenv(constants.EnvMaxNameservers) defer os.Setenv(constants.EnvMaxNameservers, old) os.Setenv(constants.EnvMaxNameservers, "2") ips := AllowIPsForNft(resolv) // 127.0.0.1 + 2 nameservers (cap) require.Len(t, ips, 3, "expected 3 IPs (127.0.0.1 + 2 capped)") require.Equal(t, netip.MustParseAddr("10.0.0.1"), ips[1], "expected first nameserver to be 10.0.0.1") require.Equal(t, netip.MustParseAddr("10.0.0.2"), ips[2], "expected second nameserver to be 10.0.0.2") } func TestIsValidNameserverIP(t *testing.T) { tests := []struct { ip string want bool }{ {"0.0.0.0", false}, {"::", false}, {"127.0.0.1", false}, {"127.0.0.11", false}, {"::1", false}, {"192.168.65.7", true}, {"10.0.0.1", true}, {"8.8.8.8", true}, } for _, tt := range tests { ip := netip.MustParseAddr(tt.ip) got := isValidNameserverIP(ip) if got != tt.want { t.Errorf("isValidNameserverIP(%s) = %v, want %v", tt.ip, got, tt.want) } } } func TestMaxNameserversFromEnv(t *testing.T) { old := os.Getenv(constants.EnvMaxNameservers) defer os.Setenv(constants.EnvMaxNameservers, old) for _, s := range []string{"", "x", "-1"} { os.Setenv(constants.EnvMaxNameservers, s) if got := maxNameserversFromEnv(); got != constants.DefaultMaxNameservers { t.Errorf("maxNameserversFromEnv(%q) = %d, want default %d", s, got, constants.DefaultMaxNameservers) } } os.Setenv(constants.EnvMaxNameservers, "0") if got := maxNameserversFromEnv(); got != 0 { t.Errorf("maxNameserversFromEnv(0) = %d, want 0", got) } os.Setenv(constants.EnvMaxNameservers, "5") if got := maxNameserversFromEnv(); got != 5 { t.Errorf("maxNameserversFromEnv(5) = %d, want 5", got) } os.Setenv(constants.EnvMaxNameservers, "99") if got := maxNameserversFromEnv(); got != 10 { t.Errorf("maxNameserversFromEnv(99) = %d, want 10 (capped)", got) } } ================================================ FILE: components/egress/nft.go ================================================ // Copyright 2026 Alibaba Group Holding Ltd. // // 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. package main import ( "context" "net/netip" "os" "strings" "github.com/alibaba/opensandbox/egress/pkg/constants" "github.com/alibaba/opensandbox/egress/pkg/dnsproxy" "github.com/alibaba/opensandbox/egress/pkg/log" "github.com/alibaba/opensandbox/egress/pkg/nftables" "github.com/alibaba/opensandbox/egress/pkg/policy" ) // createNftManager returns an nft manager for dns+nft mode, or nil for dns-only. func createNftManager(mode string) nftApplier { if mode != constants.PolicyDnsNft { return nil } return nftables.NewManagerWithOptions(parseNftOptions()) } // setupNft applies static policy to nft and wires DNS-resolved IPs into the proxy when nft is enabled. // nameserverIPs are merged into the allow set at startup so system DNS works (client + proxy upstream, e.g. private DNS). func setupNft(ctx context.Context, nftMgr nftApplier, initialPolicy *policy.NetworkPolicy, proxy *dnsproxy.Proxy, nameserverIPs []netip.Addr) { if nftMgr == nil { log.Warnf("nftables disabled (dns-only mode)") return } log.Infof("applying nftables static policy (dns+nft mode) with %d nameserver IP(s) merged into allow set", len(nameserverIPs)) policyWithNS := initialPolicy.WithExtraAllowIPs(nameserverIPs) if err := nftMgr.ApplyStatic(ctx, policyWithNS); err != nil { log.Fatalf("nftables static apply failed: %v", err) } log.Infof("nftables static policy applied (table inet opensandbox); DNS-resolved IPs will be added to dynamic allow sets") proxy.SetOnResolved(func(domain string, ips []nftables.ResolvedIP) { if err := nftMgr.AddResolvedIPs(ctx, ips); err != nil { log.Warnf("[dns] add resolved IPs to nft failed for domain %q: %v", domain, err) } }) } func parseNftOptions() nftables.Options { opts := nftables.Options{BlockDoT: true} if isTruthy(os.Getenv(constants.EnvBlockDoH443)) { opts.BlockDoH443 = true } if raw := os.Getenv(constants.EnvDoHBlocklist); strings.TrimSpace(raw) != "" { parts := strings.Split(raw, ",") for _, p := range parts { target := strings.TrimSpace(p) if target == "" { continue } if addr, err := netip.ParseAddr(target); err == nil { if addr.Is4() { opts.DoHBlocklistV4 = append(opts.DoHBlocklistV4, target) } else if addr.Is6() { opts.DoHBlocklistV6 = append(opts.DoHBlocklistV6, target) } continue } if prefix, err := netip.ParsePrefix(target); err == nil { if prefix.Addr().Is4() { opts.DoHBlocklistV4 = append(opts.DoHBlocklistV4, target) } else if prefix.Addr().Is6() { opts.DoHBlocklistV6 = append(opts.DoHBlocklistV6, target) } continue } log.Warnf("ignoring invalid DoH blocklist entry: %s", target) } } return opts } ================================================ FILE: components/egress/pkg/constants/configuration.go ================================================ // Copyright 2026 Alibaba Group Holding Ltd. // // 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. package constants const ( EnvBlockDoH443 = "OPENSANDBOX_EGRESS_BLOCK_DOH_443" EnvDoHBlocklist = "OPENSANDBOX_EGRESS_DOH_BLOCKLIST" // comma-separated IP/CIDR EnvEgressMode = "OPENSANDBOX_EGRESS_MODE" // dns | dns+nft EnvEgressHTTPAddr = "OPENSANDBOX_EGRESS_HTTP_ADDR" EnvEgressToken = "OPENSANDBOX_EGRESS_TOKEN" EnvEgressRules = "OPENSANDBOX_EGRESS_RULES" EnvEgressLogLevel = "OPENSANDBOX_EGRESS_LOG_LEVEL" EnvMaxNameservers = "OPENSANDBOX_EGRESS_MAX_NS" EnvBlockedWebhook = "OPENSANDBOX_EGRESS_DENY_WEBHOOK" ENVSandboxID = "OPENSANDBOX_EGRESS_SANDBOX_ID" // EnvNameserverExempt comma-separated IPs; proxy upstream to these is not marked and is allowed in nft allow set EnvNameserverExempt = "OPENSANDBOX_EGRESS_NAMESERVER_EXEMPT" ) const ( PolicyDnsOnly = "dns" PolicyDnsNft = "dns+nft" ) const ( DefaultEgressServerAddr = ":18080" DefaultMaxNameservers = 3 ) ================================================ FILE: components/egress/pkg/constants/constants.go ================================================ // Copyright 2026 Alibaba Group Holding Ltd. // // 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. package constants const ( MarkValue = 0x1 MarkHex = "0x1" ) const ( EgressAuthTokenHeader = "OPENSANDBOX-EGRESS-AUTH" ) ================================================ FILE: components/egress/pkg/dnsproxy/exempt.go ================================================ // Copyright 2026 Alibaba Group Holding Ltd. // // 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. package dnsproxy import ( "net/netip" "os" "strings" "sync" "github.com/alibaba/opensandbox/egress/pkg/constants" ) var ( exemptListOnce sync.Once exemptAddrs []netip.Addr exemptSet map[netip.Addr]struct{} ) // ParseNameserverExemptList returns IPs from OPENSANDBOX_EGRESS_NAMESERVER_EXEMPT (comma-separated). // Only single IPs are accepted; invalid or CIDR entries are skipped. Result is cached. Used for nft allow set, iptables, and UpstreamInExemptList. func ParseNameserverExemptList() []netip.Addr { exemptListOnce.Do(func() { parseNameserverExemptListUncached() }) return exemptAddrs } func parseNameserverExemptListUncached() { raw := strings.TrimSpace(os.Getenv(constants.EnvNameserverExempt)) if raw == "" { exemptAddrs = nil exemptSet = nil return } set := make(map[netip.Addr]struct{}) var out []netip.Addr for _, s := range strings.Split(raw, ",") { s = strings.TrimSpace(s) if s == "" { continue } if addr, err := netip.ParseAddr(s); err == nil { if _, exists := set[addr]; exists { continue } set[addr] = struct{}{} out = append(out, addr) } } exemptAddrs = out exemptSet = set } // UpstreamInExemptList returns true when upstreamHost is in the nameserver exempt list (exact IP match). // When true, the proxy should not set SO_MARK so upstream traffic follows normal routing (e.g. via tun). func UpstreamInExemptList(upstreamHost string) bool { addr, err := netip.ParseAddr(upstreamHost) if err != nil { return false } ParseNameserverExemptList() // ensure cache is initialized _, ok := exemptSet[addr] return ok } ================================================ FILE: components/egress/pkg/dnsproxy/exempt_test.go ================================================ // Copyright 2026 Alibaba Group Holding Ltd. // // 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. package dnsproxy import ( "net/netip" "sync" "testing" "github.com/alibaba/opensandbox/egress/pkg/constants" "github.com/stretchr/testify/require" ) func resetNameserverExemptCache(t *testing.T) { t.Helper() exemptAddrs = nil exemptSet = nil exemptListOnce = sync.Once{} } func TestParseNameserverExemptList_IPOnly(t *testing.T) { t.Setenv(constants.EnvNameserverExempt, "1.1.1.1, 2001:db8::1 ,invalid, 10.0.0.0/8, ,") resetNameserverExemptCache(t) got := ParseNameserverExemptList() want := []netip.Addr{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2001:db8::1")} require.Equal(t, want, got, "ParseNameserverExemptList() mismatch") // Cached result should stay the same on subsequent calls. require.Equal(t, want, ParseNameserverExemptList(), "cached ParseNameserverExemptList() mismatch") } func TestUpstreamInExemptList_IPOnly(t *testing.T) { t.Setenv(constants.EnvNameserverExempt, "1.1.1.1,2001:db8::1") resetNameserverExemptCache(t) require.True(t, UpstreamInExemptList("1.1.1.1"), "expected IPv4 upstream to be exempt") require.True(t, UpstreamInExemptList("2001:db8::1"), "expected IPv6 upstream to be exempt") require.False(t, UpstreamInExemptList("10.0.0.2"), "unexpected exempt match for non-listed IP") require.False(t, UpstreamInExemptList("not-an-ip"), "invalid IP string should not match") } func TestUpstreamInExemptList_CIDRIgnored(t *testing.T) { t.Setenv(constants.EnvNameserverExempt, "10.0.0.0/24") resetNameserverExemptCache(t) require.Empty(t, ParseNameserverExemptList(), "CIDR should be ignored in exempt list") require.False(t, UpstreamInExemptList("10.0.0.5"), "CIDR should not make upstream exempt") } ================================================ FILE: components/egress/pkg/dnsproxy/proxy.go ================================================ // Copyright 2026 Alibaba Group Holding Ltd. // // 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. package dnsproxy import ( "context" "fmt" "net" "net/netip" "os" "strings" "sync" "time" "github.com/miekg/dns" "github.com/alibaba/opensandbox/egress/pkg/events" "github.com/alibaba/opensandbox/egress/pkg/log" "github.com/alibaba/opensandbox/egress/pkg/nftables" "github.com/alibaba/opensandbox/egress/pkg/policy" ) const defaultListenAddr = "127.0.0.1:15353" type Proxy struct { policyMu sync.RWMutex policy *policy.NetworkPolicy listenAddr string upstream string // single upstream for MVP servers []*dns.Server // optional; called in goroutine when A/AAAA are present onResolved func(domain string, ips []nftables.ResolvedIP) // optional broadcaster to notify blocked hostnames blockedBroadcaster *events.Broadcaster } // New builds a proxy with resolved upstream; listenAddr can be empty for default. func New(p *policy.NetworkPolicy, listenAddr string) (*Proxy, error) { if listenAddr == "" { listenAddr = defaultListenAddr } if p == nil { p = policy.DefaultDenyPolicy() } upstream, err := discoverUpstream() if err != nil { return nil, err } proxy := &Proxy{ listenAddr: listenAddr, upstream: upstream, policy: ensurePolicyDefaults(p), } return proxy, nil } func (p *Proxy) Start(ctx context.Context) error { handler := dns.HandlerFunc(p.serveDNS) udpServer := &dns.Server{Addr: p.listenAddr, Net: "udp", Handler: handler} tcpServer := &dns.Server{Addr: p.listenAddr, Net: "tcp", Handler: handler} p.servers = []*dns.Server{udpServer, tcpServer} errCh := make(chan error, len(p.servers)) for _, srv := range p.servers { s := srv go func() { if err := s.ListenAndServe(); err != nil { errCh <- err } }() } // Shutdown on context done go func() { <-ctx.Done() for _, srv := range p.servers { _ = srv.Shutdown() } }() select { case err := <-errCh: return fmt.Errorf("dns proxy failed: %w", err) case <-time.After(200 * time.Millisecond): // small grace window; running fine return nil } } func (p *Proxy) serveDNS(w dns.ResponseWriter, r *dns.Msg) { if len(r.Question) == 0 { _ = w.WriteMsg(new(dns.Msg)) // empty response return } q := r.Question[0] domain := q.Name p.policyMu.RLock() currentPolicy := p.policy p.policyMu.RUnlock() if currentPolicy != nil && currentPolicy.Evaluate(domain) == policy.ActionDeny { p.publishBlocked(domain) resp := new(dns.Msg) resp.SetRcode(r, dns.RcodeNameError) _ = w.WriteMsg(resp) return } resp, err := p.forward(r) if err != nil { log.Warnf("[dns] forward error for %s: %v", domain, err) fail := new(dns.Msg) fail.SetRcode(r, dns.RcodeServerFailure) _ = w.WriteMsg(fail) return } p.maybeNotifyResolved(domain, resp) _ = w.WriteMsg(resp) } // maybeNotifyResolved calls onResolved synchronously when resp contains A/AAAA, // so that IPs are in nft before the client receives the DNS response and connects. func (p *Proxy) maybeNotifyResolved(domain string, resp *dns.Msg) { if p.onResolved == nil { return } ips := extractResolvedIPs(resp) if len(ips) == 0 { return } p.onResolved(domain, ips) } func (p *Proxy) forward(r *dns.Msg) (*dns.Msg, error) { c := &dns.Client{ Timeout: 5 * time.Second, Dialer: p.dialerWithMark(), } resp, _, err := c.Exchange(r, p.upstream) return resp, err } // UpstreamHost returns the host part of the upstream resolver, empty on parse error. func (p *Proxy) UpstreamHost() string { host, _, err := net.SplitHostPort(p.upstream) if err != nil { return "" } return host } // UpdatePolicy swaps the in-memory policy used by the proxy. // Passing nil reverts to the default deny-all policy. func (p *Proxy) UpdatePolicy(newPolicy *policy.NetworkPolicy) { p.policyMu.Lock() p.policy = ensurePolicyDefaults(newPolicy) p.policyMu.Unlock() } // CurrentPolicy returns the policy currently enforced by the proxy. func (p *Proxy) CurrentPolicy() *policy.NetworkPolicy { p.policyMu.RLock() defer p.policyMu.RUnlock() return p.policy } // SetOnResolved sets the callback invoked when an allowed domain resolves to A/AAAA. // Called in a goroutine; pass nil to disable. Only used when L2 dynamic IP is enabled (e.g. dns+nft mode). func (p *Proxy) SetOnResolved(fn func(domain string, ips []nftables.ResolvedIP)) { p.onResolved = fn } // SetBlockedBroadcaster wires a broadcaster used to notify blocked hostnames. func (p *Proxy) SetBlockedBroadcaster(b *events.Broadcaster) { p.blockedBroadcaster = b } func (p *Proxy) publishBlocked(domain string) { if p.blockedBroadcaster == nil { return } normalized := strings.ToLower(strings.TrimSuffix(domain, ".")) if normalized == "" { return } p.blockedBroadcaster.Publish(events.BlockedEvent{ Hostname: normalized, Timestamp: time.Now().UTC(), }) } // extractResolvedIPs parses A and AAAA records from resp.Answer into ResolvedIP slice. // // Uses netip.ParseAddr(v.A.String()) which allocates a temporary string per record; typically // one or a few records per resolution, so the cost is small compared to DNS RTT and nft writes. func extractResolvedIPs(resp *dns.Msg) []nftables.ResolvedIP { if resp == nil || len(resp.Answer) == 0 { return nil } var out []nftables.ResolvedIP for _, rr := range resp.Answer { switch v := rr.(type) { case *dns.A: if v.A == nil { continue } addr, err := netip.ParseAddr(v.A.String()) if err != nil { continue } out = append(out, nftables.ResolvedIP{Addr: addr, TTL: time.Duration(v.Hdr.Ttl) * time.Second}) case *dns.AAAA: if v.AAAA == nil { continue } addr, err := netip.ParseAddr(v.AAAA.String()) if err != nil { continue } out = append(out, nftables.ResolvedIP{Addr: addr, TTL: time.Duration(v.Hdr.Ttl) * time.Second}) } } return out } const fallbackUpstream = "8.8.8.8:53" func discoverUpstream() (string, error) { cfg, err := dns.ClientConfigFromFile("/etc/resolv.conf") if err != nil || len(cfg.Servers) == 0 { if err != nil { log.Warnf("[dns] fallback upstream resolver due to error: %v", err) } return fallbackUpstream, nil } // Prefer first non-loopback nameserver (e.g. K8s cluster DNS after 127.0.0.11). // If only loopback exists (e.g. Docker 127.0.0.11), use it: proxy upstream traffic // is marked and bypasses the redirect, so loopback is reachable from the sidecar. var chosen string for _, s := range cfg.Servers { if ip := net.ParseIP(s); ip != nil && ip.IsLoopback() { if chosen == "" { chosen = s } continue } chosen = s break } if chosen == "" { chosen = cfg.Servers[0] } return net.JoinHostPort(chosen, cfg.Port), nil } // ResolvNameserverIPs reads nameserver lines from resolvPath and returns parsed IPv4/IPv6 addresses. // Used at startup to whitelist the system DNS so client traffic to it is allowed and proxy can use it as upstream. func ResolvNameserverIPs(resolvPath string) ([]netip.Addr, error) { cfg, err := dns.ClientConfigFromFile(resolvPath) if err != nil || len(cfg.Servers) == 0 { return nil, nil } var out []netip.Addr for _, s := range cfg.Servers { ip, err := netip.ParseAddr(s) if err != nil { continue } out = append(out, ip) } return out, nil } // LoadPolicyFromEnvVar reads the given env var and parses a policy; empty falls back to default deny-all. func LoadPolicyFromEnvVar(envName string) (*policy.NetworkPolicy, error) { raw := os.Getenv(envName) if raw == "" { return policy.DefaultDenyPolicy(), nil } return policy.ParsePolicy(raw) } func ensurePolicyDefaults(p *policy.NetworkPolicy) *policy.NetworkPolicy { if p == nil { return policy.DefaultDenyPolicy() } if p.DefaultAction == "" { p.DefaultAction = policy.ActionDeny } return p } ================================================ FILE: components/egress/pkg/dnsproxy/proxy_linux.go ================================================ // Copyright 2026 Alibaba Group Holding Ltd. // // 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. //go:build linux package dnsproxy import ( "net" "sync" "syscall" "time" "golang.org/x/sys/unix" "github.com/alibaba/opensandbox/egress/pkg/constants" "github.com/alibaba/opensandbox/egress/pkg/log" ) var exemptDialerLogOnce sync.Once // dialerWithMark sets SO_MARK so iptables can RETURN marked packets (bypass // redirect for proxy's own upstream DNS queries). When upstream is in the nameserver // exempt list, returns a plain dialer (no mark) so upstream traffic follows normal // routing (e.g. via tun); iptables still does not redirect by destination exempt. func (p *Proxy) dialerWithMark() *net.Dialer { if UpstreamInExemptList(p.UpstreamHost()) { exemptDialerLogOnce.Do(func() { log.Infof("[dns] upstream %s in nameserver exempt list, not setting SO_MARK", p.UpstreamHost()) }) return &net.Dialer{Timeout: 5 * time.Second} } return &net.Dialer{ Timeout: 5 * time.Second, Control: func(network, address string, c syscall.RawConn) error { var opErr error if err := c.Control(func(fd uintptr) { opErr = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_MARK, constants.MarkValue) }); err != nil { return err } return opErr }, } } ================================================ FILE: components/egress/pkg/dnsproxy/proxy_other.go ================================================ // Copyright 2026 Alibaba Group Holding Ltd. // // 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. //go:build !linux package dnsproxy import ( "net" "time" ) // Non-linux: no SO_MARK; return basic dialer. func (p *Proxy) dialerWithMark() *net.Dialer { return &net.Dialer{Timeout: 5 * time.Second} } ================================================ FILE: components/egress/pkg/dnsproxy/proxy_test.go ================================================ // Copyright 2026 Alibaba Group Holding Ltd. // // 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. package dnsproxy import ( "net" "testing" "time" "github.com/miekg/dns" "github.com/stretchr/testify/require" "github.com/alibaba/opensandbox/egress/pkg/nftables" "github.com/alibaba/opensandbox/egress/pkg/policy" ) func TestProxyUpdatePolicy(t *testing.T) { proxy, err := New(nil, "127.0.0.1:15353") require.NoError(t, err, "init proxy") require.NotNil(t, proxy.CurrentPolicy(), "expected default deny policy (non-nil)") require.Equal(t, policy.ActionDeny, proxy.CurrentPolicy().Evaluate("example.com."), "expected default deny") pol, err := policy.ParsePolicy(`{"defaultAction":"deny","egress":[{"action":"allow","target":"example.com"}]}`) require.NoError(t, err, "parse policy") proxy.UpdatePolicy(pol) require.NotNil(t, proxy.CurrentPolicy(), "expected policy after update") require.Equal(t, policy.ActionAllow, proxy.CurrentPolicy().Evaluate("example.com."), "policy evaluation mismatch") proxy.UpdatePolicy(nil) require.NotNil(t, proxy.CurrentPolicy(), "expected default deny policy after clearing") require.Equal(t, policy.ActionDeny, proxy.CurrentPolicy().Evaluate("example.com."), "expected default deny after clearing") } func TestLoadPolicyFromEnvVar(t *testing.T) { const envName = "TEST_EGRESS_POLICY" t.Setenv(envName, `{"defaultAction":"deny","egress":[{"action":"allow","target":"example.com"}]}`) pol, err := LoadPolicyFromEnvVar(envName) require.NoError(t, err, "unexpected error") require.NotNil(t, pol, "expected parsed policy") require.Equal(t, policy.ActionAllow, pol.Evaluate("example.com."), "expected parsed policy to allow example.com") t.Setenv(envName, "") pol, err = LoadPolicyFromEnvVar(envName) require.NoError(t, err, "unexpected error on empty env") require.NotNil(t, pol, "expected default deny policy when env is empty") require.Equal(t, policy.ActionDeny, pol.DefaultAction, "expected default deny when env is empty") } func TestExtractResolvedIPs(t *testing.T) { msg := new(dns.Msg) msg.Answer = []dns.RR{ &dns.A{Hdr: dns.RR_Header{Name: "example.com.", Ttl: 120}, A: net.ParseIP("1.2.3.4")}, &dns.AAAA{Hdr: dns.RR_Header{Name: "example.com.", Ttl: 60}, AAAA: net.ParseIP("2001:db8::1")}, &dns.A{Hdr: dns.RR_Header{Name: "example.com.", Ttl: 90}, A: net.ParseIP("5.6.7.8")}, } ips := extractResolvedIPs(msg) require.Len(t, ips, 3, "expected 3 IPs") // Order follows Answer; check first A and AAAA require.Equal(t, "1.2.3.4", ips[0].Addr.String(), "first IP mismatch") require.Equal(t, 120*time.Second, ips[0].TTL, "first IP TTL mismatch") require.Equal(t, "2001:db8::1", ips[1].Addr.String(), "second IP mismatch") require.Equal(t, 60*time.Second, ips[1].TTL, "second IP TTL mismatch") require.Equal(t, "5.6.7.8", ips[2].Addr.String(), "third IP mismatch") require.Equal(t, 90*time.Second, ips[2].TTL, "third IP TTL mismatch") } func TestExtractResolvedIPs_EmptyOrNil(t *testing.T) { require.Nil(t, extractResolvedIPs(nil), "nil msg: expected nil") msg := new(dns.Msg) require.Nil(t, extractResolvedIPs(msg), "empty answer: expected nil") msg.Answer = []dns.RR{&dns.CNAME{Hdr: dns.RR_Header{Name: "x."}, Target: "y."}} require.Nil(t, extractResolvedIPs(msg), "CNAME only: expected nil") } func TestSetOnResolved(t *testing.T) { proxy, err := New(policy.DefaultDenyPolicy(), "") require.NoError(t, err) var called bool var capturedDomain string var capturedIPs []nftables.ResolvedIP proxy.SetOnResolved(func(domain string, ips []nftables.ResolvedIP) { called = true capturedDomain = domain capturedIPs = ips }) require.NotNil(t, proxy.onResolved, "SetOnResolved did not set callback") proxy.SetOnResolved(nil) require.Nil(t, proxy.onResolved, "SetOnResolved(nil) did not clear callback") _ = called _ = capturedDomain _ = capturedIPs } func TestMaybeNotifyResolved_CallsCallbackWhenAOrAAAA(t *testing.T) { proxy, err := New(policy.DefaultDenyPolicy(), "") require.NoError(t, err) ch := make(chan struct { domain string ips []nftables.ResolvedIP }, 1) proxy.SetOnResolved(func(domain string, ips []nftables.ResolvedIP) { ch <- struct { domain string ips []nftables.ResolvedIP }{domain, ips} }) msg := new(dns.Msg) msg.Answer = []dns.RR{ &dns.A{Hdr: dns.RR_Header{Name: "example.com.", Ttl: 120}, A: net.ParseIP("1.2.3.4")}, } proxy.maybeNotifyResolved("example.com.", msg) select { case got := <-ch: require.Equal(t, "example.com.", got.domain, "domain mismatch") require.Len(t, got.ips, 1, "expected one resolved IP") require.Equal(t, "1.2.3.4", got.ips[0].Addr.String(), "resolved IP mismatch") case <-time.After(2 * time.Second): require.FailNow(t, "callback was not invoked") } } func TestMaybeNotifyResolved_NoCallWhenOnResolvedNil(t *testing.T) { proxy, err := New(policy.DefaultDenyPolicy(), "") require.NoError(t, err) msg := new(dns.Msg) msg.Answer = []dns.RR{&dns.A{Hdr: dns.RR_Header{Name: "x.", Ttl: 60}, A: net.ParseIP("10.0.0.1")}} proxy.maybeNotifyResolved("x.", msg) // No callback set; should not panic. No assertion needed. } func TestMaybeNotifyResolved_NoCallWhenNoAOrAAAA(t *testing.T) { proxy, err := New(policy.DefaultDenyPolicy(), "") require.NoError(t, err) ch := make(chan struct { domain string ips []nftables.ResolvedIP }, 1) proxy.SetOnResolved(func(domain string, ips []nftables.ResolvedIP) { ch <- struct { domain string ips []nftables.ResolvedIP }{domain, ips} }) msg := new(dns.Msg) msg.Answer = []dns.RR{&dns.CNAME{Hdr: dns.RR_Header{Name: "x."}, Target: "y."}} proxy.maybeNotifyResolved("x.", msg) select { case <-ch: require.FailNow(t, "callback should not be invoked when resp has no A/AAAA") case <-time.After(200 * time.Millisecond): // Expected: no callback } } ================================================ FILE: components/egress/pkg/events/broadcaster.go ================================================ // Copyright 2026 Alibaba Group Holding Ltd. // // 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. package events import ( "context" "sync" "sync/atomic" "time" "github.com/alibaba/opensandbox/egress/pkg/log" ) const defaultQueueSize = 128 // BlockedEvent describes a blocked hostname notification. type BlockedEvent struct { Hostname string `json:"hostname"` Timestamp time.Time `json:"timestamp"` } // Subscriber consumes blocked events. type Subscriber interface { HandleBlocked(ctx context.Context, ev BlockedEvent) } // BroadcasterConfig defines queue sizing for the broadcaster. type BroadcasterConfig struct { QueueSize int } // Broadcaster fans out blocked events to one or more subscribers via channels. type Broadcaster struct { ctx context.Context cancel context.CancelFunc mu sync.RWMutex subscribers []chan BlockedEvent queueSize int closed atomic.Bool } // NewBroadcaster builds a broadcaster with the given queue size (defaults to 128). func NewBroadcaster(ctx context.Context, cfg BroadcasterConfig) *Broadcaster { if cfg.QueueSize <= 0 { cfg.QueueSize = defaultQueueSize } cctx, cancel := context.WithCancel(ctx) return &Broadcaster{ ctx: cctx, cancel: cancel, queueSize: cfg.QueueSize, } } // AddSubscriber registers a new subscriber with its own buffered queue and worker. func (b *Broadcaster) AddSubscriber(sub Subscriber) { if sub == nil { return } ch := make(chan BlockedEvent, b.queueSize) b.mu.Lock() b.subscribers = append(b.subscribers, ch) b.mu.Unlock() go func() { for { select { case <-b.ctx.Done(): return case ev, ok := <-ch: if !ok { return } sub.HandleBlocked(b.ctx, ev) } } }() } // Publish sends an event to all subscribers; drops and logs when a subscriber queue is full. func (b *Broadcaster) Publish(event BlockedEvent) { if b.closed.Load() { return } b.mu.RLock() defer b.mu.RUnlock() for _, ch := range b.subscribers { select { case ch <- event: default: log.Warnf("[events] blocked-event queue full; dropping hostname %s", event.Hostname) } } } // Close stops all workers and closes subscriber queues. func (b *Broadcaster) Close() { if b.closed.Load() { return } b.cancel() b.mu.Lock() defer b.mu.Unlock() subs := b.subscribers b.subscribers = nil for _, ch := range subs { close(ch) } b.closed.Store(true) } ================================================ FILE: components/egress/pkg/events/events_test.go ================================================ // Copyright 2026 Alibaba Group Holding Ltd. // // 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. package events import ( "context" "encoding/json" "io" "net/http" "net/http/httptest" "testing" "time" "github.com/alibaba/opensandbox/egress/pkg/constants" "github.com/stretchr/testify/require" ) type captureSubscriber struct { recv chan BlockedEvent } func (c *captureSubscriber) HandleBlocked(_ context.Context, ev BlockedEvent) { c.recv <- ev } type blockingSubscriber struct { block chan struct{} } func (b *blockingSubscriber) HandleBlocked(_ context.Context, ev BlockedEvent) { // Block until the channel is closed to simulate a slow consumer and trigger backpressure. <-b.block _ = ev } func TestBroadcasterFanout(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() b := NewBroadcaster(ctx, BroadcasterConfig{QueueSize: 2}) sub1 := &captureSubscriber{recv: make(chan BlockedEvent, 1)} sub2 := &captureSubscriber{recv: make(chan BlockedEvent, 1)} b.AddSubscriber(sub1) b.AddSubscriber(sub2) ev := BlockedEvent{Hostname: "example.com.", Timestamp: time.Now()} b.Publish(ev) select { case got := <-sub1.recv: require.Equal(t, ev.Hostname, got.Hostname, "sub1 expected hostname") case <-time.After(2 * time.Second): require.FailNow(t, "sub1 did not receive event") } select { case got := <-sub2.recv: require.Equal(t, ev.Hostname, got.Hostname, "sub2 expected hostname") case <-time.After(2 * time.Second): require.FailNow(t, "sub2 did not receive event") } b.Close() } func TestBroadcasterDropsWhenSubscriberBackedUp(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() // Small queue; blocking subscriber will hold the first event. b := NewBroadcaster(ctx, BroadcasterConfig{QueueSize: 1}) block := make(chan struct{}) sub := &blockingSubscriber{block: block} b.AddSubscriber(sub) ev1 := BlockedEvent{Hostname: "first.example", Timestamp: time.Now()} ev2 := BlockedEvent{Hostname: "second.example", Timestamp: time.Now()} b.Publish(ev1) // This publish should drop because subscriber is blocked and queue size is 1. b.Publish(ev2) // Allow subscriber to drain and exit. close(block) b.Close() } func TestWebhookSubscriberSendsPayload(t *testing.T) { var ( gotMethod string gotPayload webhookPayload ) const ( sandboxIDInitial = "sandbox-test" sandboxIDLater = "sandbox-updated" ) t.Setenv(constants.ENVSandboxID, sandboxIDInitial) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotMethod = r.Method body, _ := io.ReadAll(r.Body) _ = r.Body.Close() _ = json.Unmarshal(body, &gotPayload) w.WriteHeader(http.StatusOK) })) defer server.Close() sub := NewWebhookSubscriber(server.URL) require.NotNil(t, sub, "webhook subscriber should not be nil") t.Setenv(constants.ENVSandboxID, sandboxIDLater) ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) ev := BlockedEvent{Hostname: "Example.com.", Timestamp: ts} sub.HandleBlocked(context.Background(), ev) require.Equal(t, http.MethodPost, gotMethod, "expected POST") require.Equal(t, ev.Hostname, gotPayload.Hostname, "expected hostname") require.Equal(t, webhookSource, gotPayload.Source, "expected source") require.Equal(t, sandboxIDInitial, gotPayload.SandboxID, "expected sandboxId captured at init") require.NotEmpty(t, gotPayload.Timestamp, "expected timestamp to be set") } ================================================ FILE: components/egress/pkg/events/webhook.go ================================================ // Copyright 2026 Alibaba Group Holding Ltd. // // 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. package events import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "os" "time" "github.com/alibaba/opensandbox/egress/pkg/constants" "github.com/alibaba/opensandbox/egress/pkg/log" ) const ( webhookSource = "opensandbox-egress" defaultWebhookTimeout = 5 * time.Second defaultWebhookRetries = 3 defaultWebhookBackoff = 1 * time.Second ) // WebhookSubscriber delivers blocked events to an HTTP endpoint. type WebhookSubscriber struct { url string client *http.Client timeout time.Duration maxRetries int backoff time.Duration sandboxID string } type webhookPayload struct { Hostname string `json:"hostname"` Timestamp string `json:"timestamp"` Source string `json:"source"` SandboxID string `json:"sandboxId"` } // NewWebhookSubscriber builds a webhook subscriber with hardcoded timeout/retry settings. func NewWebhookSubscriber(url string) *WebhookSubscriber { if url == "" { return nil } return &WebhookSubscriber{ url: url, client: &http.Client{}, timeout: defaultWebhookTimeout, maxRetries: defaultWebhookRetries, backoff: defaultWebhookBackoff, sandboxID: os.Getenv(constants.ENVSandboxID), } } // HandleBlocked sends the blocked event to the configured webhook with retries. func (w *WebhookSubscriber) HandleBlocked(ctx context.Context, ev BlockedEvent) { payload := webhookPayload{ Hostname: ev.Hostname, Timestamp: ev.Timestamp.UTC().Format(time.RFC3339), Source: webhookSource, SandboxID: w.sandboxID, } body, err := json.Marshal(payload) if err != nil { log.Warnf("[webhook] failed to marshal payload for hostname %s: %v", ev.Hostname, err) return } var lastErr error for attempt := 0; attempt <= w.maxRetries; attempt++ { reqCtx := ctx cancel := func() {} if w.timeout > 0 { reqCtx, cancel = context.WithTimeout(ctx, w.timeout) } req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, w.url, bytes.NewReader(body)) if err != nil { cancel() lastErr = err break } req.Header.Set("Content-Type", "application/json") resp, err := w.client.Do(req) if err == nil { _, _ = io.Copy(io.Discard, resp.Body) _ = resp.Body.Close() if resp.StatusCode < 300 { cancel() return } if resp.StatusCode < 500 { cancel() log.Warnf("[webhook] non-retriable status %d for hostname %s", resp.StatusCode, payload.Hostname) return } err = fmt.Errorf("status %d", resp.StatusCode) } cancel() lastErr = err if attempt < w.maxRetries { time.Sleep(w.backoff * time.Duration(1< port). // // exemptDst: optional list of destination IPs; traffic to these is not redirected. Packets carrying mark are also RETURNed (proxy's own upstream). Requires CAP_NET_ADMIN. func SetupRedirect(port int, exemptDst []netip.Addr) error { log.Infof("installing iptables DNS redirect: OUTPUT port 53 -> %d (mark %s bypass)", port, constants.MarkHex) targetPort := strconv.Itoa(port) var rules [][]string for _, d := range exemptDst { addr := d dStr := d.String() if addr.Is4() { rules = append(rules, []string{"iptables", "-t", "nat", "-A", "OUTPUT", "-p", "udp", "--dport", "53", "-d", dStr, "-j", "RETURN"}, []string{"iptables", "-t", "nat", "-A", "OUTPUT", "-p", "tcp", "--dport", "53", "-d", dStr, "-j", "RETURN"}, ) } else { rules = append(rules, []string{"ip6tables", "-t", "nat", "-A", "OUTPUT", "-p", "udp", "--dport", "53", "-d", dStr, "-j", "RETURN"}, []string{"ip6tables", "-t", "nat", "-A", "OUTPUT", "-p", "tcp", "--dport", "53", "-d", dStr, "-j", "RETURN"}, ) } } // Bypass packets marked by the proxy itself (see dnsproxy dialer). markAndRedirect := [][]string{ {"iptables", "-t", "nat", "-A", "OUTPUT", "-p", "udp", "--dport", "53", "-m", "mark", "--mark", constants.MarkHex, "-j", "RETURN"}, {"iptables", "-t", "nat", "-A", "OUTPUT", "-p", "tcp", "--dport", "53", "-m", "mark", "--mark", constants.MarkHex, "-j", "RETURN"}, // Redirect all other DNS traffic to local proxy port. {"iptables", "-t", "nat", "-A", "OUTPUT", "-p", "udp", "--dport", "53", "-j", "REDIRECT", "--to-port", targetPort}, {"iptables", "-t", "nat", "-A", "OUTPUT", "-p", "tcp", "--dport", "53", "-j", "REDIRECT", "--to-port", targetPort}, // IPv6 equivalents (ip6tables) {"ip6tables", "-t", "nat", "-A", "OUTPUT", "-p", "udp", "--dport", "53", "-m", "mark", "--mark", constants.MarkHex, "-j", "RETURN"}, {"ip6tables", "-t", "nat", "-A", "OUTPUT", "-p", "tcp", "--dport", "53", "-m", "mark", "--mark", constants.MarkHex, "-j", "RETURN"}, {"ip6tables", "-t", "nat", "-A", "OUTPUT", "-p", "udp", "--dport", "53", "-j", "REDIRECT", "--to-port", targetPort}, {"ip6tables", "-t", "nat", "-A", "OUTPUT", "-p", "tcp", "--dport", "53", "-j", "REDIRECT", "--to-port", targetPort}, } rules = append(rules, markAndRedirect...) for _, args := range rules { if output, err := exec.Command(args[0], args[1:]...).CombinedOutput(); err != nil { return fmt.Errorf("iptables command failed: %v (output: %s)", err, output) } } log.Infof("iptables DNS redirect installed successfully") return nil } ================================================ FILE: components/egress/pkg/log/logger.go ================================================ // Copyright 2026 Alibaba Group Holding Ltd. // // 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. package log import ( "context" "os" slogger "github.com/alibaba/opensandbox/internal/logger" ) // Logger is the shared logger instance for egress. var Logger slogger.Logger = slogger.MustNew(slogger.Config{Level: "info"}).Named("opensandbox.egress") // WithLogger replaces the global logger used by egress components. func WithLogger(ctx context.Context, logger slogger.Logger) context.Context { if logger != nil { Logger = logger } return ctx } func Debugf(template string, args ...any) { Logger.Debugf(template, args...) } func Infof(template string, args ...any) { Logger.Infof(template, args...) } func Warnf(template string, args ...any) { Logger.Warnf(template, args...) } func Errorf(template string, args ...any) { Logger.Errorf(template, args...) } func Fatalf(template string, args ...any) { Logger.Errorf(template, args...) _ = Logger.Sync() os.Exit(1) } ================================================ FILE: components/egress/pkg/nftables/dynamic.go ================================================ // Copyright 2026 Alibaba Group Holding Ltd. // // 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. package nftables import ( "fmt" "net/netip" "strings" "time" ) const ( dynAllowV4Set = "dyn_allow_v4" dynAllowV6Set = "dyn_allow_v6" dynSetTimeoutS = 300 minTTLSec = 60 maxTTLSec = 300 ) // ResolvedIP is a single IP learned from DNS with TTL for dynamic nft set. type ResolvedIP struct { Addr netip.Addr TTL time.Duration } // buildAddResolvedIPsScript returns a nft script fragment that // adds resolved IPs to dyn_allow_v4/v6 with timeout. func buildAddResolvedIPsScript(table string, ips []ResolvedIP) string { var v4, v6 []string for _, r := range ips { sec := clampTTL(r.TTL) if r.Addr.Is4() { v4 = append(v4, fmt.Sprintf("%s timeout %ds", r.Addr.String(), sec)) } else if r.Addr.Is6() { v6 = append(v6, fmt.Sprintf("%s timeout %ds", r.Addr.String(), sec)) } } var b strings.Builder if len(v4) > 0 { fmt.Fprintf(&b, "add element inet %s %s { %s }\n", table, dynAllowV4Set, strings.Join(v4, ", ")) } if len(v6) > 0 { fmt.Fprintf(&b, "add element inet %s %s { %s }\n", table, dynAllowV6Set, strings.Join(v6, ", ")) } return b.String() } func clampTTL(d time.Duration) int { sec := int(d.Seconds()) if sec < minTTLSec { return minTTLSec } if sec > maxTTLSec { return maxTTLSec } return sec } ================================================ FILE: components/egress/pkg/nftables/manager.go ================================================ // Copyright 2026 Alibaba Group Holding Ltd. // // 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. package nftables import ( "context" "fmt" "os/exec" "strings" "sync" "github.com/alibaba/opensandbox/egress/pkg/constants" "github.com/alibaba/opensandbox/egress/pkg/log" "github.com/alibaba/opensandbox/egress/pkg/policy" ) const ( tableName = "opensandbox" chainName = "egress" allowV4Set = "allow_v4" allowV6Set = "allow_v6" denyV4Set = "deny_v4" denyV6Set = "deny_v6" dohBlockV4Set = "doh_block_v4" dohBlockV6Set = "doh_block_v6" ) type runner func(ctx context.Context, script string) ([]byte, error) // Options controls nftables enforcement extras. type Options struct { // BlockDoT drops tcp/udp 853 to prevent DNS-over-TLS bypass. BlockDoT bool // BlockDoH443 drops HTTPS DoH endpoints; when blocklist is empty and enabled, 443 is dropped. BlockDoH443 bool DoHBlocklistV4 []string DoHBlocklistV6 []string } // Manager applies static IP/CIDR policy into nftables and dynamic DNS-learned IPs. type Manager struct { run runner opts Options mu sync.Mutex } // NewManager builds an nftables manager that shells out to `nft -f -` with defaults. func NewManager() *Manager { return &Manager{run: defaultRunner, opts: Options{BlockDoT: true}} } // NewManagerWithRunner is for tests; allows capturing the rendered ruleset (defaults to BlockDoT=true). func NewManagerWithRunner(r runner) *Manager { return &Manager{run: r, opts: Options{BlockDoT: true}} } // NewManagerWithRunnerAndOptions is for tests needing custom options. func NewManagerWithRunnerAndOptions(r runner, opts Options) *Manager { return &Manager{run: r, opts: opts} } // NewManagerWithOptions allows customizing behavior (used by main()). func NewManagerWithOptions(opts Options) *Manager { return &Manager{run: defaultRunner, opts: opts} } // ApplyStatic reconciles static allow/deny IP and CIDR entries into nftables. // // It creates a dedicated table/chain and overwrites previous state. // Uses the same mutex as AddResolvedIPs so a /policy update never overlaps a DNS // callback: without this, add-element could run while the table is being deleted/recreated // and fail, causing a transient deny for a client that already got an allowed DNS answer. func (m *Manager) ApplyStatic(ctx context.Context, p *policy.NetworkPolicy) error { if p == nil { p = policy.DefaultDenyPolicy() } allowV4, allowV6, denyV4, denyV6 := p.StaticIPSets() log.Infof("nftables: applying static policy: default=%s, allow_v4=%d, allow_v6=%d, deny_v4=%d, deny_v6=%d", p.DefaultAction, len(allowV4), len(allowV6), len(denyV4), len(denyV6)) m.mu.Lock() defer m.mu.Unlock() script := buildRuleset(p, m.opts) if _, err := m.run(ctx, script); err != nil { // On a fresh host the delete-table may fail; retry once without the delete line. if isMissingTableError(err) { fallback := removeDeleteTableLine(script) if fallback != script { if _, retryErr := m.run(ctx, fallback); retryErr == nil { return nil } } } return err } log.Infof("nftables: static policy applied successfully") return nil } // AddResolvedIPs adds DNS-learned IPs to dynamic allow sets with TTL-based timeout. // TTL is clamped to minTTLSec–maxTTLSec. Call only when table exists (dns+nft mode). func (m *Manager) AddResolvedIPs(ctx context.Context, ips []ResolvedIP) error { if len(ips) == 0 { return nil } m.mu.Lock() defer m.mu.Unlock() script := buildAddResolvedIPsScript(tableName, ips) if script == "" { return nil } log.Infof("nftables: adding %d resolved IP(s) to dynamic allow sets with script statement %s", len(ips), script) _, err := m.run(ctx, script) return err } func buildRuleset(p *policy.NetworkPolicy, opts Options) string { allowV4, allowV6, denyV4, denyV6 := p.StaticIPSets() var b strings.Builder // Reset and re-create table, sets, and chain. fmt.Fprintf(&b, "delete table inet %s\n", tableName) fmt.Fprintf(&b, "add table inet %s\n", tableName) fmt.Fprintf(&b, "add set inet %s %s { type ipv4_addr; flags interval; }\n", tableName, allowV4Set) fmt.Fprintf(&b, "add set inet %s %s { type ipv4_addr; flags interval; }\n", tableName, denyV4Set) fmt.Fprintf(&b, "add set inet %s %s { type ipv6_addr; flags interval; }\n", tableName, allowV6Set) fmt.Fprintf(&b, "add set inet %s %s { type ipv6_addr; flags interval; }\n", tableName, denyV6Set) fmt.Fprintf(&b, "add set inet %s %s { type ipv4_addr; timeout %ds; }\n", tableName, dynAllowV4Set, dynSetTimeoutS) fmt.Fprintf(&b, "add set inet %s %s { type ipv6_addr; timeout %ds; }\n", tableName, dynAllowV6Set, dynSetTimeoutS) if len(opts.DoHBlocklistV4) > 0 { fmt.Fprintf(&b, "add set inet %s %s { type ipv4_addr; flags interval; }\n", tableName, dohBlockV4Set) } if len(opts.DoHBlocklistV6) > 0 { fmt.Fprintf(&b, "add set inet %s %s { type ipv6_addr; flags interval; }\n", tableName, dohBlockV6Set) } writeElements(&b, allowV4Set, allowV4) writeElements(&b, denyV4Set, denyV4) writeElements(&b, allowV6Set, allowV6) writeElements(&b, denyV6Set, denyV6) writeElements(&b, dohBlockV4Set, opts.DoHBlocklistV4) writeElements(&b, dohBlockV6Set, opts.DoHBlocklistV6) chainPolicy := "drop" if p.DefaultAction == policy.ActionAllow { chainPolicy = "accept" } fmt.Fprintf(&b, "add chain inet %s %s { type filter hook output priority 0; policy %s; }\n", tableName, chainName, chainPolicy) fmt.Fprintf(&b, "add rule inet %s %s ct state established,related accept\n", tableName, chainName) fmt.Fprintf(&b, "add rule inet %s %s meta mark %s accept\n", tableName, chainName, constants.MarkHex) fmt.Fprintf(&b, "add rule inet %s %s oifname \"lo\" accept\n", tableName, chainName) if opts.BlockDoT { fmt.Fprintf(&b, "add rule inet %s %s tcp dport 853 drop\n", tableName, chainName) fmt.Fprintf(&b, "add rule inet %s %s udp dport 853 drop\n", tableName, chainName) } if opts.BlockDoH443 { if len(opts.DoHBlocklistV4) == 0 && len(opts.DoHBlocklistV6) == 0 { // strict: drop all 443 when enabled but no blocklist provided fmt.Fprintf(&b, "add rule inet %s %s tcp dport 443 drop\n", tableName, chainName) } else { if len(opts.DoHBlocklistV4) > 0 { fmt.Fprintf(&b, "add rule inet %s %s ip daddr @%s tcp dport 443 drop\n", tableName, chainName, dohBlockV4Set) } if len(opts.DoHBlocklistV6) > 0 { fmt.Fprintf(&b, "add rule inet %s %s ip6 daddr @%s tcp dport 443 drop\n", tableName, chainName, dohBlockV6Set) } } } fmt.Fprintf(&b, "add rule inet %s %s ip daddr @%s drop\n", tableName, chainName, denyV4Set) fmt.Fprintf(&b, "add rule inet %s %s ip6 daddr @%s drop\n", tableName, chainName, denyV6Set) fmt.Fprintf(&b, "add rule inet %s %s ip daddr @%s accept\n", tableName, chainName, dynAllowV4Set) fmt.Fprintf(&b, "add rule inet %s %s ip6 daddr @%s accept\n", tableName, chainName, dynAllowV6Set) fmt.Fprintf(&b, "add rule inet %s %s ip daddr @%s accept\n", tableName, chainName, allowV4Set) fmt.Fprintf(&b, "add rule inet %s %s ip6 daddr @%s accept\n", tableName, chainName, allowV6Set) if chainPolicy == "drop" { fmt.Fprintf(&b, "add rule inet %s %s counter drop\n", tableName, chainName) } return b.String() } func writeElements(b *strings.Builder, setName string, elems []string) { if len(elems) == 0 { return } fmt.Fprintf(b, "add element inet %s %s { %s }\n", tableName, setName, strings.Join(elems, ", ")) } func defaultRunner(ctx context.Context, script string) ([]byte, error) { cmd := exec.CommandContext(ctx, "nft", "-f", "-") cmd.Stdin = strings.NewReader(script) output, err := cmd.CombinedOutput() if err != nil { return output, fmt.Errorf("nft apply failed: %w (output: %s)", err, strings.TrimSpace(string(output))) } return output, nil } func isMissingTableError(err error) bool { if err == nil { return false } msg := strings.ToLower(err.Error()) return strings.Contains(msg, "no such file or directory") && strings.Contains(msg, "delete table inet "+tableName) } func removeDeleteTableLine(script string) string { lines := strings.Split(script, "\n") var filtered []string for _, l := range lines { if strings.HasPrefix(l, "delete table inet "+tableName) { continue } if strings.TrimSpace(l) == "" { continue } filtered = append(filtered, l) } return strings.Join(filtered, "\n") } ================================================ FILE: components/egress/pkg/nftables/manager_test.go ================================================ // Copyright 2026 Alibaba Group Holding Ltd. // // 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. package nftables import ( "context" "fmt" "net/netip" "testing" "time" "github.com/alibaba/opensandbox/egress/pkg/policy" "github.com/stretchr/testify/require" ) func TestApplyStatic_BuildsRuleset_DefaultDeny(t *testing.T) { var rendered string m := NewManagerWithRunner(func(_ context.Context, script string) ([]byte, error) { rendered = script return nil, nil }) p, err := policy.ParsePolicy(`{ "defaultAction":"deny", "egress":[ {"action":"allow","target":"1.1.1.1"}, {"action":"allow","target":"2.2.0.0/16"}, {"action":"deny","target":"2001:db8::/32"} ] }`) require.NoError(t, err, "unexpected parse error") require.NoError(t, m.ApplyStatic(context.Background(), p), "ApplyStatic returned error") expectContains(t, rendered, "add chain inet opensandbox egress { type filter hook output priority 0; policy drop; }") expectContains(t, rendered, "add rule inet opensandbox egress ct state established,related accept") expectContains(t, rendered, "add rule inet opensandbox egress meta mark 0x1 accept") expectContains(t, rendered, "add rule inet opensandbox egress oifname \"lo\" accept") expectContains(t, rendered, "add rule inet opensandbox egress tcp dport 853 drop") expectContains(t, rendered, "add rule inet opensandbox egress udp dport 853 drop") expectContains(t, rendered, "add set inet opensandbox dyn_allow_v4 { type ipv4_addr; timeout 300s; }") expectContains(t, rendered, "add set inet opensandbox dyn_allow_v6 { type ipv6_addr; timeout 300s; }") expectContains(t, rendered, "add element inet opensandbox allow_v4 { 1.1.1.1, 2.2.0.0/16 }") expectContains(t, rendered, "add element inet opensandbox deny_v6 { 2001:db8::/32 }") expectContains(t, rendered, "add rule inet opensandbox egress ip daddr @dyn_allow_v4 accept") expectContains(t, rendered, "add rule inet opensandbox egress ip6 daddr @dyn_allow_v6 accept") expectContains(t, rendered, "add rule inet opensandbox egress counter drop") } func TestApplyStatic_DefaultAllowUsesAcceptPolicy(t *testing.T) { var rendered string m := NewManagerWithRunner(func(_ context.Context, script string) ([]byte, error) { rendered = script return nil, nil }) p, err := policy.ParsePolicy(`{ "defaultAction":"allow", "egress":[{"action":"deny","target":"10.0.0.0/8"}] }`) require.NoError(t, err, "unexpected parse error") require.NoError(t, m.ApplyStatic(context.Background(), p), "ApplyStatic returned error") expectContains(t, rendered, "policy accept;") expectContains(t, rendered, "add rule inet opensandbox egress tcp dport 853 drop") require.NotContains(t, rendered, "counter drop", "did not expect drop counter when defaultAction is allow:\n%s", rendered) expectContains(t, rendered, "add element inet opensandbox deny_v4 { 10.0.0.0/8 }") } func expectContains(t *testing.T, s, substr string) { t.Helper() require.Contains(t, s, substr, "expected rendered ruleset to contain %q\nrendered:\n%s", substr, s) } func TestApplyStatic_RetryWhenTableMissing(t *testing.T) { var calls int var scripts []string m := NewManagerWithRunner(func(_ context.Context, script string) ([]byte, error) { calls++ scripts = append(scripts, script) if calls == 1 { return nil, fmt.Errorf("nft apply failed: exit status 1 (output: /dev/stdin:1:19-29: Error: No such file or directory; did you mean table ‘opensandbox’ in family inet?\ndelete table inet opensandbox\n ^^^^^^^^^^^)") } return nil, nil }) p, _ := policy.ParsePolicy(`{"egress":[]}`) require.NoError(t, m.ApplyStatic(context.Background(), p), "expected retry to succeed") require.Equal(t, 2, calls, "expected 2 calls (fail then retry)") require.GreaterOrEqual(t, len(scripts), 2, "expected second attempt script to be recorded") require.NotContains(t, scripts[1], "delete table inet opensandbox", "expected second attempt to drop delete-table line") } func TestApplyStatic_DoHBlocklist(t *testing.T) { var rendered string opts := Options{ BlockDoT: true, BlockDoH443: true, DoHBlocklistV4: []string{"9.9.9.9"}, DoHBlocklistV6: []string{"2001:db8::/32"}, } m := NewManagerWithRunnerAndOptions(func(_ context.Context, script string) ([]byte, error) { rendered = script return nil, nil }, opts) p, _ := policy.ParsePolicy(`{"defaultAction":"allow","egress":[]}`) require.NoError(t, m.ApplyStatic(context.Background(), p), "ApplyStatic returned error") expectContains(t, rendered, "add set inet opensandbox doh_block_v4 { type ipv4_addr; flags interval; }") expectContains(t, rendered, "add element inet opensandbox doh_block_v4 { 9.9.9.9 }") expectContains(t, rendered, "add rule inet opensandbox egress ip daddr @doh_block_v4 tcp dport 443 drop") expectContains(t, rendered, "add rule inet opensandbox egress ip6 daddr @doh_block_v6 tcp dport 443 drop") } func TestAddResolvedIPs_BuildsDynamicElements(t *testing.T) { var rendered string m := NewManagerWithRunner(func(_ context.Context, script string) ([]byte, error) { rendered = script return nil, nil }) ips := []ResolvedIP{ {Addr: netip.MustParseAddr("1.1.1.1"), TTL: 120 * time.Second}, {Addr: netip.MustParseAddr("2001:db8::1"), TTL: 60 * time.Second}, } require.NoError(t, m.AddResolvedIPs(context.Background(), ips), "AddResolvedIPs returned error") expectContains(t, rendered, "add element inet opensandbox dyn_allow_v4 { 1.1.1.1 timeout 120s }") expectContains(t, rendered, "add element inet opensandbox dyn_allow_v6 { 2001:db8::1 timeout 60s }") } func TestAddResolvedIPs_ClampsTTL(t *testing.T) { var rendered string m := NewManagerWithRunner(func(_ context.Context, script string) ([]byte, error) { rendered = script return nil, nil }) ips := []ResolvedIP{ {Addr: netip.MustParseAddr("10.0.0.1"), TTL: 10 * time.Second}, {Addr: netip.MustParseAddr("10.0.0.2"), TTL: 9999 * time.Second}, } require.NoError(t, m.AddResolvedIPs(context.Background(), ips), "AddResolvedIPs returned error") expectContains(t, rendered, "10.0.0.1 timeout 60s") expectContains(t, rendered, "10.0.0.2 timeout 300s") } func TestAddResolvedIPs_EmptyNoOp(t *testing.T) { m := NewManagerWithRunner(func(_ context.Context, script string) ([]byte, error) { require.FailNow(t, "runner should not be called for empty ips") return nil, nil }) require.NoError(t, m.AddResolvedIPs(context.Background(), nil), "AddResolvedIPs returned error") require.NoError(t, m.AddResolvedIPs(context.Background(), []ResolvedIP{}), "AddResolvedIPs returned error") } ================================================ FILE: components/egress/pkg/policy/policy.go ================================================ // Copyright 2026 Alibaba Group Holding Ltd. // // 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. package policy import ( "encoding/json" "fmt" "net/netip" "strings" ) const ( ActionAllow = "allow" ActionDeny = "deny" ) type targetKind int const ( targetUnknown targetKind = iota targetDomain targetIP targetCIDR ) // DefaultDenyPolicy returns a new policy that denies all traffic. func DefaultDenyPolicy() *NetworkPolicy { return &NetworkPolicy{DefaultAction: ActionDeny} } // NetworkPolicy is the minimal MVP shape for egress control. // Only domain/wildcard targets are honored in this MVP. type NetworkPolicy struct { Egress []EgressRule `json:"egress"` DefaultAction string `json:"defaultAction"` } type EgressRule struct { Action string `json:"action"` Target string `json:"target"` targetKind targetKind ip netip.Addr prefix netip.Prefix } // ParsePolicy parses JSON from env/config into a NetworkPolicy. // Default action falls back to "deny" to align with proposal. func ParsePolicy(raw string) (*NetworkPolicy, error) { trimmed := strings.TrimSpace(raw) if trimmed == "" || trimmed == "null" || trimmed == "{}" { return DefaultDenyPolicy(), nil } var p NetworkPolicy if err := json.Unmarshal([]byte(trimmed), &p); err != nil { return nil, err } if err := normalizePolicy(&p); err != nil { return nil, err } return ensureDefaults(&p), nil } // Evaluate returns allow/deny for a given domain (lowercased). func (p *NetworkPolicy) Evaluate(domain string) string { if p == nil { return ActionDeny } domain = strings.ToLower(strings.TrimSuffix(domain, ".")) for _, r := range p.Egress { if r.targetKind != targetDomain { continue } if r.matchesDomain(domain) { if r.Action == "" { return ActionDeny } return r.Action } } if p.DefaultAction == "" { return ActionDeny } return p.DefaultAction } // ensureDefaults guarantees a policy always has a default action. func ensureDefaults(p *NetworkPolicy) *NetworkPolicy { if p == nil { return DefaultDenyPolicy() } if p.DefaultAction == "" { p.DefaultAction = ActionDeny } return p } func normalizePolicy(p *NetworkPolicy) error { p.DefaultAction = strings.ToLower(strings.TrimSpace(p.DefaultAction)) if p.DefaultAction == "" { p.DefaultAction = ActionDeny } for i := range p.Egress { r := &p.Egress[i] r.Action = strings.ToLower(strings.TrimSpace(r.Action)) if r.Action == "" { r.Action = ActionDeny } if r.Action != ActionAllow && r.Action != ActionDeny { return fmt.Errorf("unsupported action %q", r.Action) } r.Target = strings.TrimSpace(r.Target) if r.Target == "" { return fmt.Errorf("egress target cannot be empty") } if ip, err := netip.ParseAddr(r.Target); err == nil { r.targetKind = targetIP r.ip = ip continue } if prefix, err := netip.ParsePrefix(r.Target); err == nil { r.targetKind = targetCIDR r.prefix = prefix continue } r.targetKind = targetDomain } return nil } // WithExtraAllowIPs returns a copy of the policy with additional allow rules for each IP. // Used at startup to whitelist system nameservers so client DNS and proxy upstream work with private DNS. func (p *NetworkPolicy) WithExtraAllowIPs(ips []netip.Addr) *NetworkPolicy { if p == nil || len(ips) == 0 { return p } out := *p out.Egress = make([]EgressRule, len(p.Egress), len(p.Egress)+len(ips)) copy(out.Egress, p.Egress) for _, ip := range ips { out.Egress = append(out.Egress, EgressRule{ Action: ActionAllow, Target: ip.String(), targetKind: targetIP, ip: ip, }) } return &out } // StaticIPSets splits static IP/CIDR rules into allow/deny IPv4/IPv6 buckets. // Empty or nil policy returns empty slices. func (p *NetworkPolicy) StaticIPSets() (allowV4, allowV6, denyV4, denyV6 []string) { if p == nil { return } for _, r := range p.Egress { switch r.targetKind { case targetIP: addr := r.ip target := addr.String() if r.Action == ActionAllow { if addr.Is4() { allowV4 = append(allowV4, target) } else if addr.Is6() { allowV6 = append(allowV6, target) } } else { if addr.Is4() { denyV4 = append(denyV4, target) } else if addr.Is6() { denyV6 = append(denyV6, target) } } case targetCIDR: pfx := r.prefix target := pfx.String() if r.Action == ActionAllow { if pfx.Addr().Is4() { allowV4 = append(allowV4, target) } else if pfx.Addr().Is6() { allowV6 = append(allowV6, target) } } else { if pfx.Addr().Is4() { denyV4 = append(denyV4, target) } else if pfx.Addr().Is6() { denyV6 = append(denyV6, target) } } default: continue } } return } func (r *EgressRule) matchesDomain(domain string) bool { pattern := strings.ToLower(strings.TrimSpace(r.Target)) domain = strings.ToLower(domain) if pattern == "" { return false } if pattern == domain { return true } if strings.HasPrefix(pattern, "*.") { // "*.example.com" matches "a.example.com" but not "example.com" suffix := strings.TrimPrefix(pattern, "*") return strings.HasSuffix(domain, suffix) && domain != strings.TrimPrefix(pattern, "*.") } return false } ================================================ FILE: components/egress/pkg/policy/policy_test.go ================================================ // Copyright 2026 Alibaba Group Holding Ltd. // // 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. package policy import ( "net/netip" "testing" "github.com/stretchr/testify/require" ) func TestParsePolicy_EmptyOrNullDefaultsDeny(t *testing.T) { cases := []string{ "", " ", "null", "{}\n", } for _, raw := range cases { p, err := ParsePolicy(raw) require.NoErrorf(t, err, "raw %q returned error", raw) require.NotNilf(t, p, "raw %q expected default deny policy, got nil", raw) require.Equalf(t, ActionDeny, p.DefaultAction, "raw %q expected defaultAction deny", raw) require.Equalf(t, ActionDeny, p.Evaluate("example.com."), "raw %q expected deny evaluation", raw) } } func TestParsePolicy_DefaultActionFallback(t *testing.T) { p, err := ParsePolicy(`{"egress":[{"action":"allow","target":"example.com"}]}`) require.NoError(t, err) require.NotNil(t, p, "expected policy object, got nil") require.Equal(t, ActionDeny, p.DefaultAction, "expected defaultAction fallback to deny") } func TestParsePolicy_EmptyEgressDefaultsDeny(t *testing.T) { p, err := ParsePolicy(`{"defaultAction":""}`) require.NoError(t, err) require.Equal(t, ActionDeny, p.DefaultAction, "expected default deny when defaultAction missing") require.Equal(t, ActionDeny, p.Evaluate("anything.com."), "expected evaluation deny for empty egress") } func TestParsePolicy_IPAndCIDRSupported(t *testing.T) { raw := `{ "defaultAction":"deny", "egress":[ {"action":"allow","target":"1.1.1.1"}, {"action":"allow","target":"2.2.0.0/16"}, {"action":"deny","target":"2001:db8::/32"}, {"action":"deny","target":"2001:db8::1"} ] }` p, err := ParsePolicy(raw) require.NoError(t, err) allowV4, allowV6, denyV4, denyV6 := p.StaticIPSets() require.Len(t, allowV4, 2, "allowV4 length mismatch") require.Equal(t, "1.1.1.1", allowV4[0]) require.Equal(t, "2.2.0.0/16", allowV4[1]) require.Len(t, denyV6, 2, "expected 2 denyV6 entries") require.Empty(t, allowV6, "allowV6 should be empty") require.Empty(t, denyV4, "denyV4 should be empty") } func TestParsePolicy_InvalidAction(t *testing.T) { _, err := ParsePolicy(`{"egress":[{"action":"foo","target":"example.com"}]}`) require.Error(t, err, "expected error for invalid action") } func TestParsePolicy_EmptyTargetError(t *testing.T) { _, err := ParsePolicy(`{"egress":[{"action":"allow","target":""}]}`) require.Error(t, err, "expected error for empty target") } func TestWithExtraAllowIPs(t *testing.T) { p, err := ParsePolicy(`{"defaultAction":"deny","egress":[{"action":"allow","target":"example.com"}]}`) require.NoError(t, err) allowV4, allowV6, _, _ := p.StaticIPSets() require.Empty(t, allowV4, "domain-only policy should have no static allowV4 IPs") require.Empty(t, allowV6, "domain-only policy should have no static allowV6 IPs") ips := []netip.Addr{ netip.MustParseAddr("192.168.65.7"), netip.MustParseAddr("2001:db8::1"), } merged := p.WithExtraAllowIPs(ips) require.NotSame(t, p, merged, "expected new policy instance") allowV4, allowV6, _, _ = merged.StaticIPSets() require.Len(t, allowV4, 1, "allowV4 length mismatch") require.Equal(t, "192.168.65.7", allowV4[0]) require.Len(t, allowV6, 1, "allowV6 length mismatch") require.Equal(t, "2001:db8::1", allowV6[0]) // nil/empty ips returns same policy require.Same(t, p, p.WithExtraAllowIPs(nil), "WithExtraAllowIPs(nil) should return same policy") require.Same(t, p, p.WithExtraAllowIPs([]netip.Addr{}), "WithExtraAllowIPs([]) should return same policy") } ================================================ FILE: components/egress/policy_server.go ================================================ // Copyright 2026 Alibaba Group Holding Ltd. // // 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. package main import ( "context" "crypto/subtle" "encoding/json" "errors" "fmt" "io" "net/http" "net/netip" "strings" "sync" "time" "github.com/alibaba/opensandbox/egress/pkg/constants" "github.com/alibaba/opensandbox/egress/pkg/log" "github.com/alibaba/opensandbox/egress/pkg/nftables" "github.com/alibaba/opensandbox/egress/pkg/policy" ) type policyUpdater interface { CurrentPolicy() *policy.NetworkPolicy UpdatePolicy(*policy.NetworkPolicy) } // enforcementReporter reports the current enforcement mode (dns | dns+nft). type enforcementReporter interface { EnforcementMode() string } // nftApplier applies static policy and optional dynamic DNS-learned IPs to nftables. type nftApplier interface { ApplyStatic(context.Context, *policy.NetworkPolicy) error AddResolvedIPs(context.Context, []nftables.ResolvedIP) error } // startPolicyServer launches a lightweight HTTP API for updating the egress policy at runtime. // Supported endpoints: // - GET /policy : returns the currently enforced policy. // - POST /policy : replace the policy; empty body resets to default deny-all. // // nameserverIPs are merged into every applied policy so system DNS stays allowed (e.g. private DNS). func startPolicyServer(ctx context.Context, proxy policyUpdater, nft nftApplier, enforcementMode string, addr string, token string, nameserverIPs []netip.Addr) error { mux := http.NewServeMux() handler := &policyServer{proxy: proxy, nft: nft, token: token, enforcementMode: enforcementMode, nameserverIPs: nameserverIPs} mux.HandleFunc("/policy", handler.handlePolicy) mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) }) srv := &http.Server{Addr: addr, Handler: mux} handler.server = srv // Shutdown listener when context ends. go func() { <-ctx.Done() shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := srv.Shutdown(shutdownCtx); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Warnf("policy server shutdown error: %v", err) } }() errCh := make(chan error, 1) go func() { if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { errCh <- err } }() select { case err := <-errCh: return err case <-time.After(200 * time.Millisecond): // assume healthy start; keep logging future errors go func() { if err := <-errCh; err != nil { log.Errorf("policy server error: %v", err) } }() return nil } } type policyServer struct { proxy policyUpdater nft nftApplier server *http.Server token string enforcementMode string nameserverIPs []netip.Addr mu sync.Mutex // serializes read-merge-apply to avoid lost updates across POST/PATCH } type policyStatusResponse struct { Status string `json:"status,omitempty"` Mode string `json:"mode,omitempty"` EnforcementMode string `json:"enforcementMode,omitempty"` Reason string `json:"reason,omitempty"` Policy any `json:"policy,omitempty"` } func (s *policyServer) handlePolicy(w http.ResponseWriter, r *http.Request) { if !s.authorize(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } switch r.Method { case http.MethodGet: s.handleGet(w) case http.MethodPost, http.MethodPut: s.handlePost(w, r) case http.MethodPatch: s.handlePatch(w, r) default: w.Header().Set("Allow", "GET, POST, PUT, PATCH") http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } func (s *policyServer) handleGet(w http.ResponseWriter) { current := s.proxy.CurrentPolicy() mode := modeFromPolicy(current) writeJSON(w, http.StatusOK, policyStatusResponse{ Status: "ok", Mode: mode, EnforcementMode: s.enforcementMode, Policy: current, }) } func (s *policyServer) handlePost(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() s.mu.Lock() defer s.mu.Unlock() body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit if err != nil { http.Error(w, fmt.Sprintf("failed to read body: %v", err), http.StatusBadRequest) return } raw := strings.TrimSpace(string(body)) if raw == "" { log.Infof("policy API: reset to default deny-all") def := policy.DefaultDenyPolicy() if s.nft != nil { defWithNS := def.WithExtraAllowIPs(s.nameserverIPs) if err := s.nft.ApplyStatic(r.Context(), defWithNS); err != nil { log.Errorf("policy API: nftables apply failed on reset: %v", err) http.Error(w, fmt.Sprintf("failed to apply nftables: %v", err), http.StatusInternalServerError) return } } s.proxy.UpdatePolicy(def) log.Infof("policy API: proxy and nftables updated to deny_all") writeJSON(w, http.StatusOK, policyStatusResponse{ Status: "ok", Mode: "deny_all", Reason: "policy reset to default deny-all", }) return } pol, err := policy.ParsePolicy(raw) if err != nil { http.Error(w, fmt.Sprintf("invalid policy: %v", err), http.StatusBadRequest) return } mode := modeFromPolicy(pol) log.Infof("policy API: updating policy to mode=%s, enforcement=%s", mode, s.enforcementMode) if s.nft != nil { polWithNS := pol.WithExtraAllowIPs(s.nameserverIPs) if err := s.nft.ApplyStatic(r.Context(), polWithNS); err != nil { log.Errorf("policy API: nftables apply failed: %v", err) http.Error(w, fmt.Sprintf("failed to apply nftables policy: %v", err), http.StatusInternalServerError) return } } s.proxy.UpdatePolicy(pol) log.Infof("policy API: proxy and nftables updated successfully") writeJSON(w, http.StatusOK, policyStatusResponse{ Status: "ok", Mode: mode, EnforcementMode: s.enforcementMode, }) } // handlePatch adds or replaces egress rules by merging with the current policy. // It is a convenience wrapper over the full replace flow: we still read -> merge -> apply. // Request body supports {"egress":[{"action":"allow","target":"example.com"}, ...]}. func (s *policyServer) handlePatch(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() s.mu.Lock() defer s.mu.Unlock() body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit if err != nil { http.Error(w, fmt.Sprintf("failed to read body: %v", err), http.StatusBadRequest) return } raw := strings.TrimSpace(string(body)) if raw == "" { http.Error(w, "patch body cannot be empty", http.StatusBadRequest) return } var patchRules []policy.EgressRule if err = json.Unmarshal([]byte(raw), &patchRules); err != nil { http.Error(w, fmt.Sprintf("invalid patch rules: %v", err), http.StatusBadRequest) return } if len(patchRules) == 0 { http.Error(w, "patch must include at least one egress rule", http.StatusBadRequest) return } base := s.proxy.CurrentPolicy() if base == nil { base = policy.DefaultDenyPolicy() } baseCopy := *base baseCopy.Egress = append([]policy.EgressRule(nil), base.Egress...) merged := mergeEgressRules(baseCopy.Egress, patchRules) // Reuse parser to normalize targets/actions. rawMerged, _ := json.Marshal(policy.NetworkPolicy{ DefaultAction: baseCopy.DefaultAction, Egress: merged, }) newPolicy, err := policy.ParsePolicy(string(rawMerged)) if err != nil { http.Error(w, fmt.Sprintf("invalid merged policy: %v", err), http.StatusBadRequest) return } mode := modeFromPolicy(newPolicy) log.Infof("policy API: patching policy with %d new rule(s), mode=%s, enforcement=%s", len(patchRules), mode, s.enforcementMode) if s.nft != nil { polWithNS := newPolicy.WithExtraAllowIPs(s.nameserverIPs) if err := s.nft.ApplyStatic(r.Context(), polWithNS); err != nil { log.Errorf("policy API: nftables apply failed on patch: %v", err) http.Error(w, fmt.Sprintf("failed to apply nftables policy: %v", err), http.StatusInternalServerError) return } } s.proxy.UpdatePolicy(newPolicy) log.Infof("policy API: patch applied successfully") writeJSON(w, http.StatusOK, policyStatusResponse{ Status: "ok", Mode: mode, EnforcementMode: s.enforcementMode, }) } func (s *policyServer) authorize(r *http.Request) bool { if s.token == "" { return true } provided := r.Header.Get(constants.EgressAuthTokenHeader) if provided == "" { return false } if len(provided) != len(s.token) { return false } return subtle.ConstantTimeCompare([]byte(provided), []byte(s.token)) == 1 } func writeJSON(w http.ResponseWriter, status int, payload any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(payload) } func modeFromPolicy(p *policy.NetworkPolicy) string { if p == nil { return "deny_all" } if p.DefaultAction == policy.ActionAllow && len(p.Egress) == 0 { return "allow_all" } else if p.DefaultAction == policy.ActionDeny && len(p.Egress) == 0 { return "deny_all" } return "enforcing" } // mergeEgressRules joins base rules and additions, deduping by target (last writer wins). func mergeEgressRules(base, additions []policy.EgressRule) []policy.EgressRule { if len(additions) == 0 { return base } out := make([]policy.EgressRule, 0, len(base)+len(additions)) seen := make(map[string]struct{}) // Priority: additions first; base rules only if target not overridden. for _, r := range additions { key := mergeKey(r) if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} out = append(out, r) } for _, r := range base { key := mergeKey(r) if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} out = append(out, r) } return out } // mergeKey normalizes domain targets to lowercase for dedupe; // IP/CIDR targets are kept as-is. func mergeKey(r policy.EgressRule) string { if r.Target == "" { return r.Target } return strings.ToLower(r.Target) } ================================================ FILE: components/egress/policy_server_test.go ================================================ // Copyright 2026 Alibaba Group Holding Ltd. // // 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. package main import ( "context" "errors" "io" "net/http" "net/http/httptest" "strings" "testing" "github.com/alibaba/opensandbox/egress/pkg/nftables" "github.com/alibaba/opensandbox/egress/pkg/policy" "github.com/stretchr/testify/require" ) type stubProxy struct { updated *policy.NetworkPolicy } func (s *stubProxy) CurrentPolicy() *policy.NetworkPolicy { return s.updated } func (s *stubProxy) UpdatePolicy(p *policy.NetworkPolicy) { s.updated = p } type stubNft struct { err error calls int applied *policy.NetworkPolicy } func (s *stubNft) ApplyStatic(_ context.Context, p *policy.NetworkPolicy) error { s.calls++ s.applied = p return s.err } func (s *stubNft) AddResolvedIPs(_ context.Context, _ []nftables.ResolvedIP) error { return nil } func TestHandlePolicy_AppliesNftAndUpdatesProxy(t *testing.T) { proxy := &stubProxy{} nft := &stubNft{} srv := &policyServer{proxy: proxy, nft: nft, enforcementMode: "dns+nft"} body := `{"defaultAction":"deny","egress":[{"action":"allow","target":"1.1.1.1"}]}` req := httptest.NewRequest(http.MethodPost, "/policy", strings.NewReader(body)) w := httptest.NewRecorder() srv.handlePolicy(w, req) resp := w.Result() require.Equal(t, http.StatusOK, resp.StatusCode, "expected 200 OK") require.Contains(t, resp.Header.Get("Content-Type"), "application/json", "expected json response") require.Equal(t, 1, nft.calls, "expected nft ApplyStatic called once") require.NotNil(t, proxy.updated, "expected proxy policy to be updated") require.Equal(t, policy.ActionDeny, proxy.updated.DefaultAction, "unexpected defaultAction") } func TestHandlePolicy_NftFailureReturns500(t *testing.T) { proxy := &stubProxy{} nft := &stubNft{err: errors.New("boom")} srv := &policyServer{proxy: proxy, nft: nft, enforcementMode: "dns+nft"} body := `{"defaultAction":"deny","egress":[{"action":"allow","target":"1.1.1.1"}]}` req := httptest.NewRequest(http.MethodPost, "/policy", strings.NewReader(body)) w := httptest.NewRecorder() srv.handlePolicy(w, req) resp := w.Result() require.Equal(t, http.StatusInternalServerError, resp.StatusCode, "expected 500") require.Equal(t, 1, nft.calls, "expected nft ApplyStatic called once") require.Nil(t, proxy.updated, "expected proxy policy not updated on nft failure") } func TestHandleGet_ReturnsEnforcementMode(t *testing.T) { proxy := &stubProxy{updated: policy.DefaultDenyPolicy()} srv := &policyServer{proxy: proxy, nft: nil, enforcementMode: "dns"} req := httptest.NewRequest(http.MethodGet, "/policy", nil) w := httptest.NewRecorder() srv.handlePolicy(w, req) resp := w.Result() require.Equal(t, http.StatusOK, resp.StatusCode, "expected 200") body, err := io.ReadAll(resp.Body) require.NoError(t, err) require.Contains(t, string(body), `"enforcementMode":"dns"`, "expected enforcementMode dns in response") } func TestHandlePatch_MergesAndApplies(t *testing.T) { initial := &policy.NetworkPolicy{ DefaultAction: policy.ActionDeny, Egress: []policy.EgressRule{ {Action: policy.ActionAllow, Target: "example.com"}, {Action: policy.ActionDeny, Target: "*.example.com"}, }, } proxy := &stubProxy{updated: initial} nft := &stubNft{} srv := &policyServer{proxy: proxy, nft: nft, enforcementMode: "dns+nft"} body := `[{"action":"deny","target":"blocked.com"},{"action":"allow","target":"example.com"}]` req := httptest.NewRequest(http.MethodPatch, "/policy", strings.NewReader(body)) w := httptest.NewRecorder() srv.handlePolicy(w, req) resp := w.Result() require.Equal(t, http.StatusOK, resp.StatusCode, "expected 200") require.Equal(t, 1, nft.calls, "expected nft ApplyStatic called once") require.NotNil(t, proxy.updated, "expected proxy policy to be updated") require.Equal(t, policy.ActionDeny, proxy.updated.DefaultAction, "default action should be preserved") require.Len(t, proxy.updated.Egress, 3, "expected 3 egress rules") require.Equal(t, policy.ActionDeny, proxy.updated.Egress[0].Action, "first rule action mismatch") require.Equal(t, "blocked.com", proxy.updated.Egress[0].Target, "first rule target mismatch") require.Equal(t, policy.ActionAllow, proxy.updated.Egress[1].Action, "second rule action mismatch") require.Equal(t, "example.com", proxy.updated.Egress[1].Target, "second rule target mismatch") require.Equal(t, policy.ActionDeny, proxy.updated.Egress[2].Action, "base wildcard rule action mismatch") require.Equal(t, "*.example.com", proxy.updated.Egress[2].Target, "base wildcard rule target mismatch") } func TestHandlePatch_DomainCaseOverride(t *testing.T) { initial := &policy.NetworkPolicy{ DefaultAction: policy.ActionDeny, Egress: []policy.EgressRule{ {Action: policy.ActionDeny, Target: "Example.COM"}, }, } proxy := &stubProxy{updated: initial} nft := &stubNft{} srv := &policyServer{proxy: proxy, nft: nft, enforcementMode: "dns+nft"} body := `[{"action":"allow","target":"example.com"}]` req := httptest.NewRequest(http.MethodPatch, "/policy", strings.NewReader(body)) w := httptest.NewRecorder() srv.handlePolicy(w, req) resp := w.Result() require.Equal(t, http.StatusOK, resp.StatusCode, "expected 200") require.NotNil(t, proxy.updated, "expected proxy policy to be updated") require.Len(t, proxy.updated.Egress, 1, "expected deduped rule count 1") require.Equal(t, policy.ActionAllow, proxy.updated.Egress[0].Action, "expected allow action") require.Equal(t, "example.com", proxy.updated.Egress[0].Target, "expected allow example.com to override") } ================================================ FILE: components/egress/tests/bench-dns-nft.sh ================================================ #!/bin/bash # Copyright 2026 Alibaba Group Holding Ltd. # # 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. # E2E benchmark: baseline (no egress) vs dns (pass-through) vs dns+nft (sync dynamic IP write). # Baseline: plain curl container, same workload, no container. Then egress dns and dns+nft. # Metrics: E2E latency (p50, p99), throughput (req/s). # # Usage: ./tests/bench-dns-nft.sh # Optional: BENCH_SAMPLE_SIZE=n to randomly use n domains from hostname.txt (default: use all). # Requires: Docker, curl in PATH (for policy push). Egress image and baseline image (default curlimages/curl:latest) must have curl. # Domain list: tests/hostname.txt (one domain per line). set -euo pipefail info() { echo "[$(date +%H:%M:%S)] $*"; } SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" HOSTNAME_FILE="${SCRIPT_DIR}/hostname.txt" # tests/ is two levels under repo root: components/egress/tests -> climb 3 levels. REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" IMG="opensandbox/egress:local" BASELINE_IMG="${BASELINE_IMG:-curlimages/curl:latest}" CONTAINER_NAME="egress-bench-e2e" POLICY_PORT=18080 ROUNDS=10 # Optional: where to write egress logs on host. Override via LOG_HOST_DIR / LOG_FILE. LOG_HOST_DIR="${LOG_HOST_DIR:-/tmp/egress-logs}" LOG_FILE="${LOG_FILE:-egress.log}" LOG_CONTAINER_DIR="/var/log/opensandbox" LOG_CONTAINER_FILE="${LOG_CONTAINER_DIR}/${LOG_FILE}" # Load benchmark domains from hostname.txt (one domain per line). if [[ ! -f "${HOSTNAME_FILE}" ]] || [[ ! -s "${HOSTNAME_FILE}" ]]; then echo "Error: domain file not found or empty: ${HOSTNAME_FILE}" >&2 exit 1 fi BENCH_DOMAINS=() while IFS= read -r line; do line="${line%%#*}" line="${line#"${line%%[![:space:]]*}"}" line="${line%"${line##*[![:space:]]}"}" [[ -n "$line" ]] && BENCH_DOMAINS+=( "$line" ) done < "${HOSTNAME_FILE}" total_in_file=${#BENCH_DOMAINS[@]} if [[ "$total_in_file" -eq 0 ]]; then echo "Error: no domains in ${HOSTNAME_FILE}" >&2 exit 1 fi # Optionally randomly sample n domains (BENCH_SAMPLE_SIZE); if unset or 0, use all. if [[ -n "${BENCH_SAMPLE_SIZE:-}" ]] && [[ "${BENCH_SAMPLE_SIZE}" -gt 0 ]]; then if [[ "${BENCH_SAMPLE_SIZE}" -ge "$total_in_file" ]]; then NUM_DOMAINS=$total_in_file else # Portable shuffle: shuf (Linux), gshuf (macOS coreutils), else awk if command -v shuf >/dev/null 2>&1; then BENCH_DOMAINS=( $(printf '%s\n' "${BENCH_DOMAINS[@]}" | shuf -n "${BENCH_SAMPLE_SIZE}") ) elif command -v gshuf >/dev/null 2>&1; then BENCH_DOMAINS=( $(printf '%s\n' "${BENCH_DOMAINS[@]}" | gshuf -n "${BENCH_SAMPLE_SIZE}") ) else BENCH_DOMAINS=( $(printf '%s\n' "${BENCH_DOMAINS[@]}" | awk 'BEGIN{srand()} {printf "%s\t%s\n", rand(), $0}' | sort -n | cut -f2- | head -n "${BENCH_SAMPLE_SIZE}") ) fi NUM_DOMAINS=${#BENCH_DOMAINS[@]} info "Using ${NUM_DOMAINS} randomly sampled domains (of ${total_in_file}) from ${HOSTNAME_FILE}" fi else NUM_DOMAINS=$total_in_file fi TOTAL_REQUESTS=$((ROUNDS * NUM_DOMAINS)) CURL_TIMEOUT=10 # Max wall time for the benchmark loop (docker exec); avoid hanging forever. BENCH_EXEC_TIMEOUT=300 cleanup() { docker rm -f "${CONTAINER_NAME}" >/dev/null 2>&1 || true } trap cleanup EXIT # Compute stats from a file with one numeric value per line (e.g. time_total in seconds). # Output: count avg_s p50_s p99_s stats() { local file="$1" if [[ ! -f "$file" ]] || [[ ! -s "$file" ]]; then echo "0 0 0 0" return fi sort -n "$file" > "${file}.sorted" local n n=$(wc -l < "${file}.sorted") if [[ "$n" -eq 0 ]]; then echo "0 0 0 0" return fi local avg p50 p99 avg=$(awk '{s+=$1; c++} END { if(c>0) print s/c; else print 0 }' "$file") p50=$(awk -v n="$n" 'NR==int(n*0.5+0.5){print $1; exit}' "${file}.sorted") p99=$(awk -v n="$n" 'NR==int(n*0.99+0.5){print $1; exit}' "${file}.sorted") echo "$n $avg $p50 $p99" } # Run workload inside CONTAINER_NAME; /tmp/bench-domains.txt must already exist in container. # Usage: run_bench_to [limit] [rounds] [timeout] run_bench_to() { local outfile="$1" local limit="${2:-9999}" local rounds="${3:-1}" local use_timeout="${4:-}" local cmd=( docker exec -e BENCH_TIMEOUT="${CURL_TIMEOUT}" -e BENCH_OUTFILE="${outfile}" -e BENCH_LIMIT="${limit}" -e BENCH_ROUNDS="${rounds}" \ "${CONTAINER_NAME}" sh -c ' : > "$BENCH_OUTFILE" r=1 while [ "$r" -le "$BENCH_ROUNDS" ]; do n=0 while IFS= read -r url && [ "$n" -lt "$BENCH_LIMIT" ]; do ( curl -o /dev/null -s -I -w "%{time_namelookup}\t%{time_total}\n" --max-time "$BENCH_TIMEOUT" "$url" >> "$BENCH_OUTFILE" ) & n=$((n+1)) done < /tmp/bench-domains.txt wait r=$((r+1)) done ' ) if [[ "$use_timeout" == "timeout" ]] && command -v timeout >/dev/null 2>&1; then timeout "${BENCH_EXEC_TIMEOUT}" "${cmd[@]}" else "${cmd[@]}" fi } # Copy URL file into container (create temp file, docker cp, rm). Uses BENCH_DOMAINS. copy_url_file_to_container() { local url_file="/tmp/bench-e2e-domains-$$.txt" : > "${url_file}" for d in "${BENCH_DOMAINS[@]}"; do echo "https://${d}" >> "${url_file}" done docker cp "${url_file}" "${CONTAINER_NAME}:/tmp/bench-domains.txt" rm -f "${url_file}" } # Run warm-up + timed benchmark, collect timings. Writes /tmp/bench-e2e-{mode}-total.txt, -namelookup.txt, -wall.txt. # Requires: CONTAINER_NAME running, /tmp/bench-domains.txt inside container. run_workload() { local mode="$1" local out_total="/tmp/bench-e2e-${mode}-total.txt" local out_namelookup="/tmp/bench-e2e-${mode}-namelookup.txt" : > "$out_total" : > "$out_namelookup" local first_url="https://${BENCH_DOMAINS[0]}" sleep 1 # HEAD request: no response body, only check DNS + TCP + TLS + HTTP response. if ! docker exec "${CONTAINER_NAME}" curl -o /dev/null -s -I --max-time "${CURL_TIMEOUT}" "${first_url}"; then info "Warm-up curl failed; stderr from one attempt:" docker exec "${CONTAINER_NAME}" curl -o /dev/null -s -I --max-time 5 "${first_url}" 2>&1 || true return 1 fi info "Warm-up: first 10 domains, 1 round..." bench_ret=0 run_bench_to /tmp/bench-warmup.txt 10 1 2>/tmp/bench-e2e-stderr.txt || bench_ret=$? if [[ "$bench_ret" -ne 0 ]]; then info "Warm-up run failed (exit $bench_ret); continuing with timed run anyway." fi info "Running ${TOTAL_REQUESTS} E2E requests (${ROUNDS} rounds × ${NUM_DOMAINS} domains) inside container (max ${BENCH_EXEC_TIMEOUT}s)..." local start_ts start_ts=$(date +%s.%N) bench_ret=0 run_bench_to /tmp/bench-raw.txt 9999 "${ROUNDS}" timeout 2>/tmp/bench-e2e-stderr.txt || bench_ret=$? if [[ "$bench_ret" -ne 0 ]]; then info "Benchmark run failed (exit $bench_ret) or hit timeout; using partial results if any." fi docker cp "${CONTAINER_NAME}:/tmp/bench-raw.txt" /tmp/bench-e2e-raw.txt 2>/dev/null || true local end_ts end_ts=$(date +%s.%N) if [[ -s /tmp/bench-e2e-stderr.txt ]]; then info "docker exec stderr (first 10 lines):" head -10 /tmp/bench-e2e-stderr.txt >&2 fi if [[ ! -f /tmp/bench-e2e-raw.txt ]]; then : > /tmp/bench-e2e-raw.txt fi local lines lines=$(wc -l < /tmp/bench-e2e-raw.txt 2>/dev/null || echo 0) if [[ "$lines" -lt $((TOTAL_REQUESTS / 2)) ]]; then info "WARN: only ${lines}/${TOTAL_REQUESTS} responses captured; curl may be failing inside container." fi awk -F'\t' '{print $2}' /tmp/bench-e2e-raw.txt 2>/dev/null > "$out_total" awk -F'\t' '{print $1}' /tmp/bench-e2e-raw.txt 2>/dev/null > "$out_namelookup" local wall_s wall_s=$(awk -v s="$start_ts" -v e="$end_ts" 'BEGIN { print e - s }') echo "$wall_s" > "/tmp/bench-e2e-${mode}-wall.txt" } # Run one benchmark phase: start container with given mode, push policy, run client workload, collect timings. # Usage: run_phase "dns" | "dns+nft" run_phase() { local mode="$1" info "Phase: ${mode}" cleanup mkdir -p "${LOG_HOST_DIR}" docker run -d --name "${CONTAINER_NAME}" \ --cap-add=NET_ADMIN \ --sysctl net.ipv6.conf.all.disable_ipv6=1 \ --sysctl net.ipv6.conf.default.disable_ipv6=1 \ -e OPENSANDBOX_EGRESS_MODE="${mode}" \ -e OPENSANDBOX_LOG_OUTPUT="${LOG_CONTAINER_FILE}" \ -v "${LOG_HOST_DIR}:${LOG_CONTAINER_DIR}" \ -p "${POLICY_PORT}:18080" \ "${IMG}" for i in $(seq 1 30); do if curl -sf "http://127.0.0.1:${POLICY_PORT}/healthz" >/dev/null 2>&1; then break fi sleep 0.5 done local policy_egress="" for d in "${BENCH_DOMAINS[@]}"; do policy_egress="${policy_egress}{\"action\":\"allow\",\"target\":\"${d}\"}," done policy_egress="${policy_egress%,}" local policy_json="{\"defaultAction\":\"deny\",\"egress\":[${policy_egress}]}" curl -sf -XPOST "http://127.0.0.1:${POLICY_PORT}/policy" -d "${policy_json}" >/dev/null copy_url_file_to_container run_workload "${mode}" } # Run baseline phase: plain curl container, no egress container. Same workload for comparison. run_phase_baseline() { info "Phase: baseline (no egress)" cleanup docker pull "${BASELINE_IMG}" > /dev/null 2>&1 docker run -d --name "${CONTAINER_NAME}" "${BASELINE_IMG}" sleep 3600 sleep 2 copy_url_file_to_container run_workload "baseline" } # Print comparison table (baseline, dns, dns+nft) report() { local nb n1 n2 avg0 avg1 avg2 p50_0 p50_1 p50_2 p99_0 p99_1 p99_2 wall0 wall1 wall2 read -r nb avg0 p50_0 p99_0 <<< "$(stats /tmp/bench-e2e-baseline-total.txt)" read -r n1 avg1 p50_1 p99_1 <<< "$(stats /tmp/bench-e2e-dns-total.txt)" read -r n2 avg2 p50_2 p99_2 <<< "$(stats /tmp/bench-e2e-dns+nft-total.txt)" wall0=$(cat /tmp/bench-e2e-baseline-wall.txt 2>/dev/null || echo "0") wall1=$(cat /tmp/bench-e2e-dns-wall.txt 2>/dev/null || echo "0") wall2=$(cat /tmp/bench-e2e-dns+nft-wall.txt 2>/dev/null || echo "0") if [[ "${nb:-0}" -eq 0 ]] || [[ "${n1:-0}" -eq 0 ]] || [[ "${n2:-0}" -eq 0 ]]; then echo "WARN: some phases had no successful requests; check container logs and network." fi local rps0 rps1 rps2 rps0=$(awk -v n="$nb" -v w="$wall0" 'BEGIN { print (w>0 && n>0) ? n/w : 0 }') rps1=$(awk -v n="$n1" -v w="$wall1" 'BEGIN { print (w>0 && n>0) ? n/w : 0 }') rps2=$(awk -v n="$n2" -v w="$wall2" 'BEGIN { print (w>0 && n>0) ? n/w : 0 }') echo "" echo "========== E2E benchmark: baseline vs dns vs dns+nft ==========" echo "Workload: ${TOTAL_REQUESTS} requests (${ROUNDS} rounds × ${NUM_DOMAINS} domains)" echo "" local ov_avg1 ov_p50_1 ov_p99_1 ov_rps1 ov_avg2 ov_p50_2 ov_p99_2 ov_rps2 ov_avg1=$(awk -v a="$avg1" -v b="$avg0" 'BEGIN { printf "%+.1f", (b>0 && b!="") ? (a-b)/b*100 : 0 }') ov_p50_1=$(awk -v a="$p50_1" -v b="$p50_0" 'BEGIN { printf "%+.1f", (b>0 && b!="") ? (a-b)/b*100 : 0 }') ov_p99_1=$(awk -v a="$p99_1" -v b="$p99_0" 'BEGIN { printf "%+.1f", (b>0 && b!="") ? (a-b)/b*100 : 0 }') ov_rps1=$(awk -v a="$rps1" -v b="$rps0" 'BEGIN { printf "%+.1f", (b>0 && b!="") ? (b-a)/b*100 : 0 }') ov_avg2=$(awk -v a="$avg2" -v b="$avg0" 'BEGIN { printf "%+.1f", (b>0 && b!="") ? (a-b)/b*100 : 0 }') ov_p50_2=$(awk -v a="$p50_2" -v b="$p50_0" 'BEGIN { printf "%+.1f", (b>0 && b!="") ? (a-b)/b*100 : 0 }') ov_p99_2=$(awk -v a="$p99_2" -v b="$p99_0" 'BEGIN { printf "%+.1f", (b>0 && b!="") ? (a-b)/b*100 : 0 }') ov_rps2=$(awk -v a="$rps2" -v b="$rps0" 'BEGIN { printf "%+.1f", (b>0 && b!="") ? (b-a)/b*100 : 0 }') printf "%-10s %14s %20s %20s %20s\n" "Mode" "Req/s" "Avg(s)" "P50(s)" "P99(s)" printf "%-10s %14s %20s %20s %20s\n" "baseline" "$rps0" "$avg0" "$p50_0" "$p99_0" printf "%-10s %14s %20s %20s %20s\n" "dns" "$(printf '%.2f(%s%%)' "$rps1" "$ov_rps1")" "$(printf '%.3f(%s%%)' "$avg1" "$ov_avg1")" "$(printf '%.3f(%s%%)' "$p50_1" "$ov_p50_1")" "$(printf '%.3f(%s%%)' "$p99_1" "$ov_p99_1")" printf "%-10s %14s %20s %20s %20s\n" "dns+nft" "$(printf '%.2f(%s%%)' "$rps2" "$ov_rps2")" "$(printf '%.3f(%s%%)' "$avg2" "$ov_avg2")" "$(printf '%.3f(%s%%)' "$p50_2" "$ov_p50_2")" "$(printf '%.3f(%s%%)' "$p99_2" "$ov_p99_2")" echo "" echo "Overhead in parentheses vs baseline: latency +%% = slower, Req/s -%% = lower throughput." echo "baseline: Plain container (${BASELINE_IMG}), no egress container." echo "dns: DNS proxy only, no nft write (pass-through)." echo "dns+nft: DNS proxy + sync AddResolvedIPs before each DNS reply (L2 enforcement)." echo "" echo "Note: Warm-up runs before each phase. Baseline gives no-proxy comparison." echo "==========" } info "Building image ${IMG}" docker build -t "${IMG}" -f "${REPO_ROOT}/components/egress/Dockerfile" "${REPO_ROOT}" > /dev/null 2>&1 run_phase_baseline run_phase "dns+nft" run_phase "dns" report info "Cleaning up" cleanup ================================================ FILE: components/egress/tests/egress-in-webhook.sh ================================================ #!/bin/bash # Copyright 2026 Alibaba Group Holding Ltd. # # 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. docker run -d --name egress \ --rm \ --cap-add=NET_ADMIN \ --sysctl net.ipv6.conf.all.disable_ipv6=1 \ --sysctl net.ipv6.conf.default.disable_ipv6=1 \ -e OPENSANDBOX_EGRESS_MODE=dns+nft \ -e OPENSANDBOX_EGRESS_DENY_WEBHOOK=http://:8000 \ -e OPENSANDBOX_EGRESS_SANDBOX_ID=mytest \ -p 18080:18080 \ "sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/egress:latest" sleep 5 curl -sSf -XPOST "http://127.0.0.1:18080/policy" \ -d '{"defaultAction":"allow","egress":[{"action":"deny","target":"*.github.com"},{"action":"deny","target":"10.0.0.0/8"}]}' ================================================ FILE: components/egress/tests/hostname.txt ================================================ example.com example.org example.net example.edu example.io github.com github.io google.com cloudflare.com amazon.com wikipedia.org mozilla.org apple.com microsoft.com yahoo.com facebook.com twitter.com instagram.com linkedin.com reddit.com stackoverflow.com npmjs.com python.org golang.org rust-lang.org docker.com kubernetes.io apache.org gnu.org kernel.org ibm.com oracle.com openai.com anthropic.com stripe.com slack.com dropbox.com spotify.com netflix.com twitch.tv discord.com zoom.us medium.com substack.com blogger.com tumblr.com imgur.com flickr.com vimeo.com soundcloud.com bandcamp.com patreon.com kickstarter.com etsy.com ebay.com craigslist.org alibaba.com bing.com duckduckgo.com brave.com opera.com protonmail.com fastmail.com zoho.com notion.so trello.com asana.com atlassian.com bitbucket.org gitlab.com sourceforge.net codepen.io vercel.com netlify.com heroku.com digitalocean.com linode.com vultr.com ovh.com hetzner.com scaleway.com archlinux.org debian.org ubuntu.com fedoraproject.org opensuse.org freebsd.org openbsd.org mysql.com mongodb.com redis.io elastic.co nodejs.org reactjs.org vuejs.org svelte.dev nextjs.org nuxtjs.org jquery.com bootstrap.com tailwindcss.com ================================================ FILE: components/egress/tests/smoke-dns.sh ================================================ #!/bin/bash # Copyright 2026 Alibaba Group Holding Ltd. # # 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. # Simple smoke test using local image. # Requires Docker with --cap-add=NET_ADMIN available. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # tests/ is two levels under repo root: components/egress/tests -> climb 3 levels. REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" IMG="opensandbox/egress:local" containerName="egress-smoke-dns" POLICY_PORT=18080 info() { echo "[$(date +%H:%M:%S)] $*"; } cleanup() { docker rm -f "${containerName}" >/dev/null 2>&1 || true } trap cleanup EXIT info "Building image ${IMG}" docker build -t "${IMG}" -f "${REPO_ROOT}/components/egress/Dockerfile" "${REPO_ROOT}" info "Starting containerName" docker run -d --name "${containerName}" \ --cap-add=NET_ADMIN \ --sysctl net.ipv6.conf.all.disable_ipv6=1 \ --sysctl net.ipv6.conf.default.disable_ipv6=1 \ -e OPENSANDBOX_EGRESS_MODE=dns \ -p ${POLICY_PORT}:18080 \ "${IMG}" info "Waiting for policy server..." for i in {1..50}; do if curl -sf "http://127.0.0.1:${POLICY_PORT}/healthz" >/dev/null; then break fi sleep 0.5 done info "Pushing policy (allow by default; deny github.com & 10.0.0.0/8)" curl -sSf -XPOST "http://127.0.0.1:${POLICY_PORT}/policy" \ -d '{"defaultAction":"deny","egress":[{"action":"allow","target":"*.github.com"}]}' run_in_app() { docker run --rm --network container:"${containerName}" curlimages/curl "$@" } pass() { info "PASS: $*"; } fail() { echo "FAIL: $*" >&2; exit 1; } info "Test: denied domain should fail (google.com)" if run_in_app -I https://google.com --max-time 5 >/dev/null 2>&1; then fail "google.com should be blocked" else pass "google.com blocked" fi info "Test: allowed domain should succeed (api.github.com)" run_in_app -I https://api.github.com --max-time 10 >/dev/null 2>&1 || fail "api.github.com should succeed" pass "api.github.com allowed" info "All smoke tests passed." ================================================ FILE: components/egress/tests/smoke-dynamic-ip.sh ================================================ #!/bin/bash # Copyright 2026 Alibaba Group Holding Ltd. # # 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. # Smoke test: default deny + domain allow in dns+nft mode. # Verifies that allowing a domain causes its resolved IP to be added to nft (dynamic IP), # so that curl to that domain succeeds without static IP/CIDR in policy. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # tests/ is two levels under repo root: components/egress/tests -> climb 3 levels. REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" IMG="opensandbox/egress:local" containerName="egress-smoke-dynamic-ip" POLICY_PORT=18080 info() { echo "[$(date +%H:%M:%S)] $*"; } cleanup() { docker rm -f "${containerName}" >/dev/null 2>&1 || true } trap cleanup EXIT info "Building image ${IMG}" docker build -t "${IMG}" -f "${REPO_ROOT}/components/egress/Dockerfile" "${REPO_ROOT}" info "Starting sidecar (dns+nft)" docker run -d --name "${containerName}" \ --cap-add=NET_ADMIN \ --sysctl net.ipv6.conf.all.disable_ipv6=1 \ --sysctl net.ipv6.conf.default.disable_ipv6=1 \ -e OPENSANDBOX_EGRESS_MODE=dns+nft \ -p ${POLICY_PORT}:18080 \ "${IMG}" info "Waiting for policy server..." for i in $(seq 1 50); do if curl -sf "http://127.0.0.1:${POLICY_PORT}/healthz" >/dev/null; then break fi sleep 0.5 done info "Pushing policy (default deny; allow google.com only)" curl -sSf -XPOST "http://127.0.0.1:${POLICY_PORT}/policy" \ -d '{"defaultAction":"deny","egress":[{"action":"allow","target":"google.com"}]}' run_in_app() { docker run --rm --network container:"${containerName}" curlimages/curl "$@" } pass() { info "PASS: $*"; } fail() { echo "FAIL: $*" >&2; exit 1; } info "Test: allowed domain (google.com) should succeed via dynamic IP" run_in_app -I https://google.com --max-time 15 >/dev/null 2>&1 || fail "google.com should succeed (DNS allow + dynamic IP in nft)" pass "google.com allowed" info "Test: denied domain (api.github.com) should fail" if run_in_app -I https://api.github.com --max-time 8 >/dev/null 2>&1; then fail "api.github.com should be blocked" else pass "api.github.com blocked" fi info "Test: denied IP (1.1.1.1) should fail" if run_in_app -I 1.1.1.1 --max-time 8 >/dev/null 2>&1; then fail "1.1.1.1 should be blocked" else pass "1.1.1.1 blocked" fi info "All smoke tests (dynamic IP) passed." ================================================ FILE: components/egress/tests/smoke-nft.sh ================================================ #!/bin/bash # Copyright 2026 Alibaba Group Holding Ltd. # # 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. # Simple smoke test using local image. # Requires Docker with --cap-add=NET_ADMIN available. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # tests/ is two levels under repo root: components/egress/tests -> climb 3 levels. REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" IMG="opensandbox/egress:local" containerName="egress-smoke-nft" POLICY_PORT=18080 info() { echo "[$(date +%H:%M:%S)] $*"; } cleanup() { docker rm -f "${containerName}" >/dev/null 2>&1 || true } trap cleanup EXIT info "Building image ${IMG}" docker build -t "${IMG}" -f "${REPO_ROOT}/components/egress/Dockerfile" "${REPO_ROOT}" info "Starting containerName" docker run -d --name "${containerName}" \ --cap-add=NET_ADMIN \ --sysctl net.ipv6.conf.all.disable_ipv6=1 \ --sysctl net.ipv6.conf.default.disable_ipv6=1 \ -e OPENSANDBOX_EGRESS_MODE=dns+nft \ -p ${POLICY_PORT}:18080 \ "${IMG}" info "Waiting for policy server..." for i in {1..50}; do if curl -sf "http://127.0.0.1:${POLICY_PORT}/healthz" >/dev/null; then break fi sleep 0.5 done info "Pushing policy (allow by default; deny github.com & 10.0.0.0/8)" curl -sSf -XPOST "http://127.0.0.1:${POLICY_PORT}/policy" \ -d '{"defaultAction":"allow","egress":[{"action":"deny","target":"*.github.com"},{"action":"deny","target":"10.0.0.0/8"}]}' run_in_app() { docker run --rm --network container:"${containerName}" curlimages/curl "$@" } pass() { info "PASS: $*"; } fail() { echo "FAIL: $*" >&2; exit 1; } info "Test: allowed domain should succeed (google.com)" run_in_app -I https://google.com --max-time 10 >/dev/null 2>&1 || fail "google.com should succeed" pass "google.com allowed" info "Test: denied domain should fail (api.github.com)" if run_in_app -I https://api.github.com --max-time 8 >/dev/null 2>&1; then fail "api.github.com should be blocked" else pass "api.github.com blocked" fi info "Test: allowed IP should succeed (1.1.1.1)" run_in_app -I https://1.1.1.1 --max-time 10 >/dev/null 2>&1 || fail "1.1.1.1 should succeed" pass "1.1.1.1 allowed" info "Test: denied CIDR should fail (10.0.0.1)" if run_in_app -I http://10.0.0.1 --max-time 5 >/dev/null 2>&1; then fail "10.0.0.1 should be blocked" else pass "10.0.0.1 blocked" fi info "Test: DoT (853) should be blocked" if run_in_app -k https://1.1.1.1:853 --max-time 5 >/dev/null 2>&1; then fail "DoT 853 should be blocked" else pass "DoT 853 blocked" fi info "Rules update: wildcard deny -> patch allow specific (dns+nft)" curl -sSf -XPOST "http://127.0.0.1:${POLICY_PORT}/policy" \ -d '{"defaultAction":"allow","egress":[{"action":"deny","target":"*.cloudflare.com"}]}' info "Test: www.cloudflare.com should be blocked initially (deny via wildcard)" if run_in_app -I https://www.cloudflare.com --max-time 8 >/dev/null 2>&1; then fail "www.cloudflare.com should be blocked before patch" else pass "www.cloudflare.com blocked before patch" fi info "Patching allow for www.cloudflare.com (specific should override earlier deny)" curl -sSf -XPATCH "http://127.0.0.1:${POLICY_PORT}/policy" \ -d '[{"action":"allow","target":"www.cloudflare.com"}]' info "Test: www.cloudflare.com should be allowed after patch" run_in_app -I https://www.cloudflare.com --max-time 10 >/dev/null 2>&1 || fail "www.cloudflare.com should succeed after patch" pass "www.cloudflare.com allowed after patch" info "Rules update: wildcard allow -> patch deny specific (dns+nft)" curl -sSf -XPOST "http://127.0.0.1:${POLICY_PORT}/policy" \ -d '{"defaultAction":"deny","egress":[{"action":"allow","target":"*.mozilla.org"}]}' info "Test: www.mozilla.org should be allowed initially (allow via wildcard)" run_in_app -I https://www.mozilla.org --max-time 10 >/dev/null 2>&1 || fail "www.mozilla.org should succeed before patch" pass "www.mozilla.org allowed before patch" info "Patching deny for www.mozilla.org (specific should override earlier allow)" curl -sSf -XPATCH "http://127.0.0.1:${POLICY_PORT}/policy" \ -d '[{"action":"deny","target":"www.mozilla.org"}]' info "Test: www.mozilla.org should be blocked after patch" if run_in_app -I https://www.mozilla.org --max-time 8 >/dev/null 2>&1; then fail "www.mozilla.org should be blocked after patch" else pass "www.mozilla.org blocked after patch" fi info "All smoke tests passed." ================================================ FILE: components/egress/tests/webhook-server.py ================================================ #!/usr/bin/env python3 # Copyright 2026 Alibaba Group Holding Ltd. # # 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. """ Lightweight HTTP server to receive OPENSANDBOX_EGRESS_DENY_WEBHOOK callbacks. Config: - WEBHOOK_HOST: listen address (default 0.0.0.0) - WEBHOOK_PORT: listen port (default 8000) - WEBHOOK_PATH: webhook path (default /) Run: python webhook_server.py Then point OPENSANDBOX_EGRESS_DENY_WEBHOOK to http://: """ import http.server import json import os import socketserver from datetime import datetime HOST = os.getenv("WEBHOOK_HOST", "0.0.0.0") PORT = int(os.getenv("WEBHOOK_PORT", "8000")) PATH = os.getenv("WEBHOOK_PATH", "/") class WebhookHandler(http.server.BaseHTTPRequestHandler): def _send(self, code: int = 200, body: str = "ok") -> None: self.send_response(code) self.send_header("Content-Type", "text/plain; charset=utf-8") self.end_headers() self.wfile.write(body.encode("utf-8")) def do_POST(self) -> None: # noqa: N802 (BaseHTTPRequestHandler API) # Only allow the configured path if self.path != PATH: self._send(404, "not found") return length = int(self.headers.get("Content-Length", 0)) raw = self.rfile.read(length) if length else b"" payload = raw.decode("utf-8", errors="replace") try: parsed = json.loads(payload) except json.JSONDecodeError: parsed = None # Log request info for debugging print(f"\n[{datetime.utcnow().isoformat()}Z] Received webhook") print(f"Path: {self.path}") print(f"Headers: {dict(self.headers)}") print(f"Raw body: {payload}") if parsed is not None: print("Parsed JSON:") print(json.dumps(parsed, indent=2)) self._send(200, "received") # Silence default logging to reduce noise def log_message(self, *args) -> None: return def main() -> None: with socketserver.TCPServer((HOST, PORT), WebhookHandler) as httpd: print(f"Listening on http://{HOST}:{PORT}{PATH} ...") httpd.serve_forever() if __name__ == "__main__": main() ================================================ FILE: components/execd/.golangci.yml ================================================ run: skip-dirs: - vendor - tests - scripts skip-files: - .*/zz_generated.deepcopy.go - .*/mock/*.go tests: false timeout: 10m linters-settings: funlen: lines: 500 statements: 200 gocyclo: min-complexity: 40 gosimple: checks: ["S1019", "S1002"] staticcheck: checks: ["SA4006"] govet: enable: - asmdecl - assign - atomic - atomicalign - bools - buildtag - cgocall - copylocks - deepequalerrors - errorsas - findcall - framepointer - httpresponse - ifaceassert - lostcancel - nilfunc - nilness - reflectvaluecompare - shift - sigchanyzer - sortslice - stdmethods - stringintconv - testinggoroutine - tests - unmarshal - unreachable - unsafeptr - unusedresult - printf disable: - composites - loopclosure - fieldalignment - shadow - structtag - unusedwrite errcheck: exclude-functions: - flag.Set - os.Setenv - os.Unsetenv - logger.Sync - fmt.Fprintf - fmt.Fprintln - (io.Closer).Close - (io.ReadCloser).Close - (k8s.io/client-go/tools/cache.SharedInformer).AddEventHandler nestif: min-complexity: 32 goconst: # Minimal length of string constant. # Default: 3 min-len: 3 # Minimum occurrences of constant string count to trigger issue. # Default: 3 min-occurrences: 3 # Ignore test files. # Default: false ignore-tests: true match-constant: false numbers: true min: 2 max: 10 ignore-calls: true gosec: includes: - G101 # Look for hard coded credentials - G102 # Bind to all interfaces - G103 # Audit the use of unsafe block - G104 # Audit errors not checked - G106 # Audit the use of ssh.InsecureIgnoreHostKey - G107 # Url provided to HTTP request as taint input - G108 # Profiling endpoint automatically exposed on /debug/pprof - G109 # Potential Integer overflow made by strconv.Atoi result conversion to int16/32 - G110 # Potential DoS vulnerability via decompression bomb - G111 # Potential directory traversal - G112 # Potential slowloris attack - G113 # Usage of Rat.SetString in math/big with an overflow (CVE-2022-23772) # - G114 # Use of net/http serve function that has no support for setting timeouts - G201 # SQL query construction using format string - G202 # SQL query construction using string concatenation - G203 # Use of unescaped data in HTML templates #- G204 # Audit use of command execution - G301 # Poor file permissions used when creating a directory - G302 # Poor file permissions used with chmod - G303 # Creating tempfile using a predictable path - G304 # File path provided as taint input - G305 # File traversal when extracting zip/tar archive - G306 # Poor file permissions used when writing to a new file - G307 # Deferring a method which returns an error #- G401 # Detect the usage of DES, RC4, MD5 or SHA1 - G402 # Look for bad TLS connection settings - G403 # Ensure minimum RSA key length of 2048 bits - G404 # Insecure random number source (rand) #- G501 # Import blocklist: crypto/md5 - G502 # Import blocklist: crypto/des - G503 # Import blocklist: crypto/rc4 - G504 # Import blocklist: net/http/cgi - G505 # Import blocklist: crypto/sha1 - G601 # Implicit memory aliasing of items from a range statement # Exclude generated files # Default: false exclude-generated: true # Filter out the issues with a lower severity than the given value. # Valid options are: low, medium, high. # Default: low severity: medium # Filter out the issues with a lower confidence than the given value. # Valid options are: low, medium, high. # Default: low confidence: medium # Concurrency value. # Default: the number of logical CPUs usable by the current process. concurrency: 12 # To specify the configuration of rules. config: # Globals are applicable to all rules. global: nosec: true show-ignored: true audit: true G101: # Regexp pattern for variables and constants to find. # Default: "(?i)passwd|pass|password|pwd|secret|token|pw|apiKey|bearer|cred" pattern: "(?i)example" # If true, complain about all cases (even with low entropy). # Default: false ignore_entropy: false # Maximum allowed entropy of the string. # Default: "80.0" entropy_threshold: "80.0" per_char_threshold: "3.0" truncate: "32" G104: fmt: - Fscanf G111: # Regexp pattern to find potential directory traversal. # Default: "http\\.Dir\\(\"\\/\"\\)|http\\.Dir\\('\\/'\\)" pattern: "custom\\.Dir\\(\\)" # Maximum allowed permissions mode for os.Mkdir and os.MkdirAll # Default: "0750" G301: "0750" # Maximum allowed permissions mode for os.OpenFile and os.Chmod # Default: "0600" G302: "0600" # Maximum allowed permissions mode for os.WriteFile and ioutil.WriteFile # Default: "0600" G306: "0600" nilnil: checked-types: - ptr - map - chan depguard: rules: prevent_unmaintained_packages: list-mode: lax # allow unless explicitely denied files: - $all - "!$test" allow: - $gostd - path/filepath deny: - pkg: io/ioutil desc: "replaced by io and os packages since Go 1.16: https://tip.golang.org/doc/go1.16#ioutil" - pkg: path desc: "replaced by cross-platform package path/filepath" gci: # Section configuration to compare against. # Section names are case-insensitive and may contain parameters in (). # The default order of sections is `standard > default > custom > blank > dot > alias > localmodule`, # If `custom-order` is `true`, it follows the order of `sections` option. # Default: ["standard", "default"] sections: - standard # Standard section: captures all standard packages. - default # Default section: contains all imports that could not be matched to another section type.: - prefix(github.com/org/project) # Custom section: groups all imports with the specified Prefix. - blank # Blank section: contains all blank imports. This section is not present unless explicitly enabled. - dot # Dot section: contains all dot imports. This section is not present unless explicitly enabled. - localmodule # Local module section: contains all local packages. This section is not present unless explicitly enabled. # Skip generated files. # Default: true skip-generated: true # Enable custom order of sections. # If `true`, make the section order the same as the order of `sections`. # Default: false custom-order: true # Drops lexical ordering for custom sections. # Default: false no-lex-order: true forbidigo: forbid: # Forbid spew Dump, whether it is called as function or method. # Depends on analyze-types below. - ^spew\.(ConfigState\.)?Dump$ # The package name might be ambiguous. # The full import path can be used as additional criteria. # Depends on analyze-types below. - p: ^v1.Dump$ pkg: ^example.com/pkg/api/v1$ linters: enable: - asasalint - asciicheck - bidichk - bodyclose # - cyclop - decorder - depguard - errcheck # - errchkjson - errorlint - forbidigo # - forcetypeassert - funlen - ineffassign - gocognit - gocyclo - goheader - gomodguard - goprintffuncname - gosimple - gosec - grouper - importas - maintidx - misspell - nakedret - nilerr - nilnil # - noctx - nosprintfhostport - paralleltest - predeclared # - promlinter - reassign - sqlclosecheck - staticcheck - tenv - testpackage - tparallel # del # - typecheck - usestdlibvars - nestif - unused - makezero - govet - goconst - gci # - rowserrcheck # 1.59 version no new lints # 1.58 version new lints # - fatcontext - canonicalheader # 1.57 version new lints - copyloopvar - intrange # 1.56 version new lints - spancheck # 1.55 version new lints - gochecksumtype - perfsprint - sloglint - testifylint - mirror - zerologlint # 1.51 version new lints - gocheckcompilerdirectives # 1.50 version new lints - testableexamples issues: # Note: path identifiers are regular expressions, hence the \.go suffixes. exclude-rules: - path: main\.go linters: - forbidigo - path: _test\.go linters: - dogsled - errcheck - goconst - gosec - ineffassign - maintidx - typecheck - path: \.go$ text: "should have a package comment" - path: \.go$ text: 'exported (.+) should have comment( \(or a comment on this block\))? or be unexported' - path: \.go$ text: "fmt.Sprintf can be replaced with string concatenation" ================================================ FILE: components/execd/DEVELOPMENT.md ================================================ # Development Guide - execd This comprehensive guide explains how to work on `execd` as a contributor or maintainer. It covers environment setup, development workflows, testing strategies, architectural patterns, and subsystem-specific implementation details. ## Table of Contents - [Getting Started](#getting-started) - [Project Structure](#project-structure) - [Coding Standards](#coding-standards) - [Testing Strategy](#testing-strategy) - [Subsystem Guides](#subsystem-guides) - [Common Development Tasks](#common-development-tasks) - [Debugging Techniques](#debugging-techniques) - [Performance Optimization](#performance-optimization) - [Contributing Guidelines](#contributing-guidelines) - [Additional Resources](#additional-resources) ## Getting Started ### Prerequisites #### Required Tools - **Go 1.24+** - Match the version declared in `go.mod` - **Git** - Version control - **Make** - Build automation (optional but recommended) #### Optional but Recommended - **golangci-lint** - For comprehensive linting - **Docker/Podman** - For containerized testing and deployment - **Jupyter Server** - Required for integration tests with real kernels - **VS Code/GoLand** - IDE with Go support ### Initial Setup ```bash # Clone the repository git clone https://github.com/alibaba/OpenSandbox.git cd OpenSandbox/components/execd # Download dependencies go mod download # Verify setup go build -o bin/execd . ``` ## Project Structure ### Project Structure Deep Dive ``` execd/ ├── main.go # Application entry point ├── go.mod # Go module definition ├── Makefile # Build automation ├── Dockerfile # Container image definition │ ├── pkg/ # Public packages │ ├── flag/ # CLI flag parsing │ ├── web/ # HTTP layer │ │ ├── router.go # Route registration │ │ ├── controller/ # Request handlers │ │ └── model/ # API models │ ├── runtime/ # Execution engine │ │ ├── ctrl.go # Main controller │ │ ├── jupyter.go # Jupyter execution │ │ └── command.go # Shell command execution │ ├── jupyter/ # Jupyter client │ │ ├── client.go # HTTP/WebSocket client │ │ ├── session/ # Session management │ │ └── execute/ # Execution protocol │ └── util/ # Utilities │ └── tests/ # Integration test scripts ``` ### Key Design Patterns #### 1. Controller Pattern (pkg/web/controller) Controllers are thin HTTP handlers that parse requests, validate, delegate to runtime, and stream responses via SSE. #### 2. Runtime Controller Pattern (pkg/runtime) The runtime controller dispatches requests to appropriate executors (Jupyter, Command, SQL) and manages session lifecycle. #### 3. Hook Pattern for Streaming Execution results are streamed via hooks, allowing controllers to transform runtime events into SSE events without tight coupling. ## Coding Standards ### Go Conventions #### Formatting **Always use `gofmt`** before committing: ```bash gofmt -w . # or make fmt ``` #### Import Organization Three groups separated by blank lines: ```go import ( // Standard library "context" "fmt" // Third-party "github.com/beego/beego/v2/core/logs" // Internal "github.com/alibaba/opensandbox/execd/pkg/runtime" ) ``` #### Error Handling Always handle errors explicitly: ```go // Good result, err := someOperation() if err != nil { logs.Error("operation failed: %v", err) return fmt.Errorf("failed to do something: %w", err) } // Bad - silent failure result, _ := someOperation() ``` #### Logging Use Beego's structured logger: ```go logs.Info("starting execution: sessionID=%s", sessionID) logs.Warning("session busy: sessionID=%s", sessionID) logs.Error("execution failed: error=%v", err) logs.Debug("received event: type=%s", eventType) ``` ### Concurrency Best Practices #### Use safego for goroutines Always use `safego.Go` to prevent panics: ```go import "github.com/alibaba/opensandbox/execd/pkg/util/safego" safego.Go(func() { processInBackground() }) ``` #### Context Propagation Always respect context cancellation: ```go func (c *Controller) runCommand(ctx context.Context, req *ExecuteCodeRequest) error { cmd := exec.CommandContext(ctx, "bash", "-c", req.Code) go func() { <-ctx.Done() if cmd.Process != nil { cmd.Process.Kill() } }() return cmd.Run() } ``` ## Testing Strategy ### Unit Tests Located in `*_test.go` files alongside source code. **Example:** ```go func TestController_Execute_Python(t *testing.T) { ctrl := NewController("http://jupyter:8888", "test-token") req := &ExecuteCodeRequest{ Language: Python, Code: "print('hello')", } err := ctrl.Execute(req) assert.NoError(t, err) } ``` **Running Unit Tests:** ```bash go test ./pkg/... # with coverage go test -v -cover ./pkg/... ``` ### Integration Tests Located in `*_integration_test.go`, require real dependencies. **Running Integration Tests:** ```bash export JUPYTER_URL=http://localhost:8888 export JUPYTER_TOKEN=your-token go test -v ./pkg/jupyter/... ``` ### Test Coverage Check coverage: ```bash go test -coverprofile=coverage.out ./pkg/... go tool cover -html=coverage.out -o coverage.html ``` **Coverage Goals:** - Core packages (`pkg/runtime`, `pkg/jupyter`): > 80% - Controllers (`pkg/web/controller`): > 70% - Utilities (`pkg/util`): > 90% ## Subsystem Guides ### Working with Jupyter Integration #### Architecture ``` pkg/jupyter/ ├── client.go # Main client ├── transport.go # Connection handling ├── session/ # Session lifecycle ├── execute/ # Execution protocol └── auth/ # Authentication ``` #### Adding New Kernel Support 1. Define language in `pkg/runtime/language.go`: ```go const Ruby Language = "ruby" ``` 2. Map to kernel in `pkg/runtime/jupyter.go` 3. Test with real kernel: ```bash # Install Ruby kernel gem install iruby iruby register --force # Run test export JUPYTER_URL=http://localhost:8888 go test -v ./pkg/jupyter/integration_test.go ``` #### Debugging Jupyter Communication Run debug integration test: ```bash go test -v ./pkg/jupyter/debug_integration_test.go ``` This dumps complete HTTP request/response pairs. ### Working with Command Execution #### Key Implementation Details **Process Group Management:** ```go cmd.SysProcAttr = &syscall.SysProcAttr{ Setpgid: true, // Create new process group } ``` This allows signal forwarding to all child processes: ```go syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM) ``` **Signal Forwarding:** ```go signals := make(chan os.Signal, 1) signal.Notify(signals) go func() { for sig := range signals { if sig != syscall.SIGCHLD && sig != syscall.SIGURG { syscall.Kill(-cmd.Process.Pid, sig.(syscall.Signal)) } } }() ``` **Stdout/Stderr Streaming:** Commands write to temporary log files, which are tailed and streamed to hooks. ## Common Development Tasks ### Adding a New API Endpoint 1. **Define model** in `pkg/web/model/`: ```go type NewFeatureRequest struct { Param1 string `json:"param1" validate:"required"` Param2 int `json:"param2"` } ``` 2. **Add controller method** in `pkg/web/controller/`: ```go func (c *MyController) NewFeature() { var req model.NewFeatureRequest json.Unmarshal(c.Ctx.Input.RequestBody, &req) // Business logic result := processNewFeature(req) c.Data["json"] = result c.ServeJSON() } ``` 3. **Register route** in `pkg/web/router.go`: ```go myNamespace := web.NewNamespace("/my-feature", web.NSRouter("", &controller.MyController{}, "post:NewFeature"), ) web.AddNamespace(myNamespace) ``` ### Adding Configuration Flag 1. **Declare in `pkg/flag/flags.go`:** ```go var NewFeatureTimeout time.Duration ``` 2. **Parse in `pkg/flag/parser.go`:** ```go func InitFlags() { flag.DurationVar(&NewFeatureTimeout, "new-feature-timeout", 30*time.Second, "Description") // Parse environment variable if env := os.Getenv("NEW_FEATURE_TIMEOUT"); env != "" { if d, err := time.ParseDuration(env); err == nil { NewFeatureTimeout = d } } flag.Parse() } ``` 3. **Update README** with new flag documentation ## Debugging Techniques ### Local Debugging with Delve ```bash # Install delve go install github.com/go-delve/delve/cmd/dlv@latest # Start debugging dlv debug . -- \ --jupyter-host=http://localhost:8888 \ --jupyter-token=test # Set breakpoint (dlv) break pkg/runtime/ctrl.go:57 (dlv) continue ``` ### Debugging SSE Streams **Test with curl:** ```bash curl -N -H "x-access-token: dev" \ -H "Content-Type: application/json" \ -d '{"language":"python","code":"print(\"test\")"}' \ http://localhost:44772/code ``` The `-N` flag disables buffering for real-time events. **Debug in browser:** ```javascript const eventSource = new EventSource('/code'); eventSource.addEventListener('stdout', (e) => { console.log('stdout:', e.data); }); eventSource.addEventListener('error', (e) => { console.error('error:', e.data); }); ``` ### Performance Profiling **CPU Profile:** ```bash # Add to main.go import _ "net/http/pprof" go func() { http.ListenAndServe("localhost:6060", nil) }() # Collect profile go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30 ``` **Memory Profile:** ```bash go tool pprof http://localhost:6060/debug/pprof/heap ``` **Goroutine Inspection:** ```bash curl http://localhost:6060/debug/pprof/goroutine?debug=2 ``` ## Performance Optimization ### Optimization Guidelines 1. **Profile before optimizing** - Use pprof to identify bottlenecks 2. **Benchmark changes** - Measure impact of optimizations 3. **Use `sync.Pool`** for frequently allocated objects 4. **Minimize allocations** in hot paths 5. **Buffer channels** appropriately ### Example: Optimizing SSE Writer **Before:** ```go func writeEvent(w http.ResponseWriter, event, data string) { fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, data) w.(http.Flusher).Flush() } ``` **After:** ```go var bufPool = sync.Pool{ New: func() interface{} { return new(bytes.Buffer) }, } func writeEvent(w http.ResponseWriter, event, data string) { buf := bufPool.Get().(*bytes.Buffer) buf.Reset() defer bufPool.Put(buf) buf.WriteString("event: ") buf.WriteString(event) buf.WriteString("\ndata: ") buf.WriteString(data) buf.WriteString("\n\n") w.Write(buf.Bytes()) w.(http.Flusher).Flush() } ``` **Benchmark:** ```go func BenchmarkWriteEvent(b *testing.B) { w := httptest.NewRecorder() b.ResetTimer() for i := 0; i < b.N; i++ { writeEvent(w, "test", "data") } } ``` ## Contributing Guidelines ### Pull Request Process 1. **Fork and clone** the repository 2. **Create feature branch** from `main` 3. **Implement changes** following coding standards 4. **Add tests** for new functionality 5. **Run all tests** and ensure they pass 6. **Update documentation** as needed 7. **Submit PR** with clear description ### Code Review Standards Reviewers check for: - [ ] Correctness and functionality - [ ] Test coverage - [ ] Code style and formatting - [ ] Documentation completeness - [ ] Performance implications - [ ] Security considerations - [ ] Error handling - [ ] Backwards compatibility ### Release Checklist Before releasing: - [ ] All tests pass (unit, integration, e2e) - [ ] Documentation updated (README, DEVELOPMENT, API docs) - [ ] CHANGELOG updated with changes - [ ] Version bumped appropriately (semver) - [ ] Dependencies reviewed and updated - [ ] Security scan passed - [ ] Performance benchmarks run - [ ] Docker image built and tested ## Additional Resources ### Useful Commands ```bash # Format all Go files make fmt # Run linter make golint # Run all tests make test # Build binary make build ``` ### External Documentation - [Beego Documentation](https://beego.wiki/) - [Jupyter Kernel Protocol](https://jupyter-client.readthedocs.io/en/stable/messaging.html) - [Go Best Practices](https://golang.org/doc/effective_go) - [Server-Sent Events Spec](https://html.spec.whatwg.org/multipage/server-sent-events.html) ### Getting Help - **Issues**: Report bugs or request features on GitHub Issues - **Discussions**: Ask questions in GitHub Discussions - **Chat**: Join the OpenSandbox community chat - **Documentation**: Check the wiki for detailed guides --- **Happy hacking!** Feel free to augment this guide with tips you discover along the way. For questions or suggestions, open an issue or discussion on GitHub. ================================================ FILE: components/execd/Dockerfile ================================================ # Copyright 2025 Alibaba Group Holding Ltd. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. FROM golang:1.24.0 AS builder WORKDIR /build ARG VERSION=dev ARG GIT_COMMIT=unknown ARG BUILD_TIME=unknown # Prepare local modules to satisfy replace directives. COPY components/internal/go.mod components/internal/go.sum ./components/internal/ COPY components/execd/go.mod components/execd/go.sum ./components/execd/ # Download deps with only mod files for better caching. RUN cd components/internal && go mod download RUN cd components/execd && go mod download # Copy sources. COPY components/internal ./components/internal COPY components/execd ./components/execd WORKDIR /build/components/execd RUN CGO_ENABLED=0 go build \ -ldflags "-X 'github.com/alibaba/opensandbox/internal/version.Version=${VERSION}' \ -X 'github.com/alibaba/opensandbox/internal/version.BuildTime=${BUILD_TIME}' \ -X 'github.com/alibaba/opensandbox/internal/version.GitCommit=${GIT_COMMIT}'" \ -o /build/execd ./main.go FROM alpine:latest COPY --from=builder /build/execd . COPY components/execd/bootstrap.sh ./bootstrap.sh ENTRYPOINT ["./execd"] ================================================ FILE: components/execd/Makefile ================================================ .PHONY: fmt fmt: ## Run go fmt against code. go fmt ./... .PHONY: vet vet: ## Run go vet against code. go mod tidy && go mod vendor go vet ./... .PHONY: test test: vet ## Run tests go test -v -coverpkg=./... ./pkg/... ##@ Linter .PHONY: install-golint install-golint: @if ! command -v golangci-lint &> /dev/null; then \ echo "installing golangci-lint..."; \ go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest; \ else \ echo "golangci-lint already installed"; \ fi .PHONY: golint golint: fmt install-golint golangci-lint run -v --fix ./... VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") GIT_COMMIT ?= $(shell git rev-parse HEAD 2>/dev/null || echo "unknown") BUILD_TIME ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") LDFLAGS := -X 'github.com/alibaba/opensandbox/internal/version.Version=$(VERSION)' \ -X 'github.com/alibaba/opensandbox/internal/version.BuildTime=$(BUILD_TIME)' \ -X 'github.com/alibaba/opensandbox/internal/version.GitCommit=$(GIT_COMMIT)' .PHONY: build build: vet ## Build the binary. @mkdir -p bin go build -ldflags "$(LDFLAGS)" -o bin/execd main.go .PHONY: multi-build multi-build: vet ## Cross-compile for linux/windows/darwin amd64/arm64. @mkdir -p bin @for os in linux windows darwin; do \ for arch in amd64 arm64; do \ out=bin/execd-$${os}-$${arch}; \ [ "$${os}" = "windows" ] && out="$${out}.exe"; \ echo ">> building $${os}/$${arch} -> $${out}"; \ GOOS=$${os} GOARCH=$${arch} CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o "$${out}" main.go || exit $$?; \ done; \ done ================================================ FILE: components/execd/README.md ================================================ # execd - OpenSandbox Execution Daemon English | [中文](README_zh.md) `execd` is the execution daemon for OpenSandbox. Built on Beego, it exposes a comprehensive HTTP API that turns external requests into runtime actions: managing Jupyter sessions, streaming code output via Server-Sent Events (SSE), executing shell commands, operating on the sandbox filesystem, and collecting host-side metrics. ## Table of Contents - [Overview](#overview) - [Core Features](#core-features) - [Architecture](#architecture) - [Getting Started](#getting-started) - [Configuration](#configuration) - [API Reference](#api-reference) - [Supported Languages](#supported-languages) - [Development](#development) - [Testing](#testing) - [Observability](#observability) - [Performance Benchmarks](#performance-benchmarks) - [Contributing](#contributing) - [License](#license) - [Support](#support) ## Overview `execd` provides a unified interface for: - **Code execution**: Python, Java, JavaScript, TypeScript, Go, and Bash - **Session management**: Long-lived Jupyter kernel sessions with state - **Command execution**: Synchronous and background shell commands - **File operations**: Full filesystem CRUD with chunked upload/download - **Monitoring**: Real-time host metrics (CPU, memory, uptime) ## Core Features ### Unified runtime management - Translate REST calls into runtime requests handled by `pkg/runtime` - Multiple execution backends: Jupyter, shell, etc. - Automatic language detection and routing - Pluggable Jupyter server configuration ### Jupyter integration - Maintain kernel sessions via `pkg/jupyter` - WebSocket-based real-time communication - Stream execution events through SSE ### Command executor - Foreground and background shell commands - Proper signal forwarding with process groups - Real-time stdout/stderr streaming - Context-aware interruption ### Filesystem - CRUD helpers around the sandbox filesystem - Glob-based file search - Chunked upload/download with resume support - Permission management ### Observability - Lightweight metrics endpoint (CPU, memory, uptime) - Structured streaming logs - SSE-based real-time monitoring ## Architecture ### Directory structure | Path | Purpose | |------------------------|------------------------------------------------------| | `main.go` | Entry point; initializes Beego, CLI flags, routers | | `pkg/flag/` | CLI and environment configuration | | `pkg/web/` | HTTP layer (controllers, models, router, SSE helpers) | | `pkg/web/controller/` | Handlers for files, code, commands, metrics | | `pkg/web/model/` | Request/response models and SSE event types | | `pkg/runtime/` | Dispatcher to Jupyter and shell executors | | `pkg/jupyter/` | Minimal Jupyter client (kernels/sessions/WebSocket) | | `pkg/jupyter/execute/` | Execution result types and stream parsers | | `pkg/jupyter/session/` | Session management and lifecycle | | `pkg/util/` | Utilities (safe goroutine helpers, glob helpers) | | `tests/` | Test scripts and tools | ## Getting Started ### Prerequisites - **Go 1.24+** (as defined in `go.mod`) - **Jupyter Server** (required for code execution) - **Docker** (optional, for containerized builds) - **Make** (optional, for convenience targets) ### Quick Start #### 1. Clone and build ```bash git clone git@github.com:alibaba/OpenSandbox.git cd OpenSandbox/components/execd go mod download make build ``` #### 2. Start Jupyter Server ```bash # Option 1: use the provided script ./tests/jupyter.sh # Option 2: start manually jupyter notebook --port=54321 --no-browser --ip=0.0.0.0 \ --NotebookApp.token='your-jupyter-token' ``` #### 3. Run execd ```bash ./bin/execd \ --jupyter-host=http://127.0.0.1:54321 \ --jupyter-token=your-jupyter-token \ --port=44772 ``` #### 4. Verify ```bash curl -v http://localhost:44772/ping # Expect HTTP 200 ``` ### Image build ```bash docker build -t opensandbox/execd:dev . # Run container docker run -d \ -p 44772:44772 \ -e JUPYTER_HOST=http://jupyter-server \ -e JUPYTER_TOKEN=your-token \ --name execd \ opensandbox/execd:dev ``` ## Configuration ### Command-line flags | Flag | Type | Default | Description | |-------------------------------|----------|---------|-----------------------------------------------| | `--jupyter-host` | string | `""` | Jupyter server URL (reachable by execd) | | `--jupyter-token` | string | `""` | Jupyter HTTP/WebSocket token | | `--port` | int | `44772` | HTTP listen port | | `--log-level` | int | `6` | Beego log level (0=Emergency, 7=Debug) | | `--access-token` | string | `""` | Shared API secret (optional) | | `--graceful-shutdown-timeout` | duration | `3s` | Wait time before cutting off SSE on shutdown | ### Environment variables All flags can be set via environment variables: ```bash export JUPYTER_HOST=http://127.0.0.1:8888 export JUPYTER_TOKEN=your-token ``` Environment variables override defaults but are superseded by explicit CLI flags. ## API Reference [API Spec](../../specs/execd-api.yaml). ## Supported Languages ### Jupyter-based | Language | Kernel | Highlights | |------------|-------------|-----------------------------| | Python | IPython | Full Jupyter protocol | | Java | IJava | JShell-based execution | | JavaScript | IJavaScript | Node.js runtime | | TypeScript | ITypeScript | TS compilation + Node exec | | Go | gophernotes | Go interpreter | | Bash | Bash kernel | Shell scripts | ### Native executors | Mode/Language | Backend | Highlights | |----------------------|---------|------------------------------| | `command` | OS exec | Synchronous shell commands | | `background-command` | OS exec | Detached background process | ## Development See [DEVELOPMENT.md](./DEVELOPMENT.md) for detailed guidelines. ## Testing ### Unit tests ```bash make test ``` ### Integration tests Integration tests requiring a real Jupyter Server are skipped by default: ```bash export JUPYTER_URL=http://localhost:8888 export JUPYTER_TOKEN=your-token go test -v ./pkg/jupyter/... ``` ### Manual testing workflow 1. Start Jupyter: `./tests/jupyter.sh` 2. Start execd: `./bin/execd --jupyter-host=http://localhost:54321 --jupyter-token=opensandboxexecdlocaltest` 3. Execute code: ```bash curl -X POST -H "Content-Type: application/json" \ -d '{"language":"python","code":"print(\"test\")"}' \ http://localhost:44772/code ``` ## Configuration ### API graceful shutdown window - Env: `EXECD_API_GRACE_SHUTDOWN` (e.g. `500ms`, `2s`, `1m`) - Flag: `--graceful-shutdown-timeout` - Default: `1s` This controls how long execd keeps SSE responses (code/command runs) alive after sending the final chunk, so clients can drain tail output before the connection closes. Set to `0s` to disable the grace period. ## Observability ### Logging Beego leveled logger: ```go logs.Info("message") // info logs.Warning("message") // warning logs.Error("message") // error logs.Debug("message") // debug ``` - Env: `EXECD_LOG_FILE` writes execd logs to the given file path; when unset, logs are sent to stdout. Log levels (0-7): - 0: Emergency - 1: Alert - 2: Critical - 3: Error - 4: Warning - 5: Notice - 6: Info (default) - 7: Debug ### Metrics `/metrics` exposes: - CPU usage percent - Memory total/used (GB) - Memory usage percent - Process uptime - Current timestamp For real-time monitoring, use `/metrics/watch` (SSE, 1s cadence). ## Performance Benchmarks ### Typical latency (localhost) | Operation | Latency | |---------------------|----------| | `/ping` | < 1ms | | `/files/info` | < 5ms | | Code execution (Py) | 50-200ms | | File upload (1MB) | 10-50ms | | Metrics snapshot | < 10ms | ### Resource usage (idle) - Memory: ~50MB - CPU: < 1% - Goroutines: ~15 ### Scalability - 100+ concurrent SSE connections - File operations scale linearly with file size - Jupyter sessions are stateful and need dedicated resources ## Contributing 1. Fork the repository 2. Create a feature branch 3. Follow coding conventions (see DEVELOPMENT.md) 4. Add tests for new functionality 5. Run `make fmt` and `make test` 6. Submit a pull request ## License `execd` is part of the OpenSandbox project. See [LICENSE](../../LICENSE) in the repository root. ## Support - Issues: [GitHub Issues](https://github.com/alibaba/OpenSandbox/issues) - Documentation: [OpenSandbox Docs](https://github.com/alibaba/OpenSandbox/wiki) - Community: [Discussions](https://github.com/alibaba/OpenSandbox/discussions) ================================================ FILE: components/execd/README_zh.md ================================================ # execd - OpenSandbox 执行守护进程 中文 | [English](README.md) `execd` 是 OpenSandbox 的执行守护进程,基于 Beego 框架提供全面的 HTTP API。它将外部请求转化为实际的运行时动作:管理 Jupyter 会话、以 SSE(Server-Sent Events)流式返回代码输出、执行 shell 命令、操作沙箱文件系统,并采集主机侧指标。 ## 目录 - [概述](#概述) - [核心特性](#核心特性) - [架构设计](#架构设计) - [快速开始](#快速开始) - [配置说明](#配置说明) - [API 参考](#api-参考) - [支持的语言](#支持的语言) - [开发指南](#开发指南) - [测试](#测试) - [可观测性](#可观测性) - [许可证](#许可证) ## 概述 `execd` 作为 OpenSandbox 的运行时守护进程,提供统一的接口用于: - **代码执行**:Python、Java、JavaScript、TypeScript、Go 和 Bash - **会话管理**:带状态保持的长连接 Jupyter kernel 会话 - **命令执行**:同步执行和异步执行 shell 命令 - **文件操作**:完整的文件系统 CRUD,支持分块上传/下载 - **监控**:实时系统指标(CPU、内存、运行时间) ## 核心特性 ### 统一运行时管理 - 将 REST 调用转化为由 `pkg/runtime` 控制器处理的运行时请求 - 支持多种执行后端:Jupyter、Shell、等等 - 自动语言检测和路由 - 可插拔 Jupyter server 配置 ### Jupyter 集成 - 通过 `pkg/jupyter` 维护 kernel 会话 - 基于 WebSocket 的实时通信 - 通过 Server-Sent Events (SSE) 流式推送执行事件 ### 命令执行器 - 前台、后台 shell 命令 - 通过进程组管理正确转发信号 - 实时 stdout/stderr 流式输出 - 支持上下文感知的中断 ### 文件系统 - 围绕沙箱文件系统的 CRUD 辅助工具 - Glob 模式匹配文件搜索 - 支持断点续传的分块上传/下载 - 权限管理 ### 可观测性 - 轻量级指标端点(CPU、内存、运行时间) - 结构化流式日志 - 基于 SSE 的实时监控 ## 架构设计 ### 目录结构 | 路径 | 说明 | |------------------------|--------------------------------------------| | `main.go` | 程序入口,初始化 Beego、CLI 标志和路由 | | `pkg/flag/` | 命令行与环境变量配置 | | `pkg/web/` | HTTP 层(控制器、模型、路由、SSE 辅助) | | `pkg/web/controller/` | 文件、代码、命令、指标的请求处理器 | | `pkg/web/model/` | 请求/响应模型与 SSE 事件类型 | | `pkg/runtime/` | 运行时控制器,调度到 Jupyter、Shell执行器 | | `pkg/jupyter/` | 精简 Jupyter 客户端(kernels/sessions/WebSocket) | | `pkg/jupyter/execute/` | 执行结果类型与流解析器 | | `pkg/jupyter/session/` | 会话管理与生命周期 | | `pkg/util/` | 通用工具(安全 goroutine、glob 辅助) | | `tests/` | 测试脚本和工具 | ## 快速开始 ### 环境要求 - **Go 1.24+**(在 `go.mod` 中定义) - **Jupyter Server**(代码执行上下文所需) - **Docker**(可选,用于容器化构建) - **Make**(可选,用于便捷命令) ### 快速启动 #### 1. 克隆并构建 ```bash git clone git@github.com:alibaba/OpenSandbox.git cd OpenSandbox/components/execd go mod download make build ``` #### 2. 启动 Jupyter Server ```bash # 方式 1:使用提供的脚本 ./tests/jupyter.sh # 方式 2:手动启动 jupyter notebook --port=54321 --no-browser --ip=0.0.0.0 \ --NotebookApp.token='your-jupyter-token' ``` #### 3. 运行 execd ```bash ./bin/execd \ --jupyter-host=http://127.0.0.1:54321 \ --jupyter-token=your-jupyter-token \ --port=44772 ``` #### 4. 验证安装 ```bash curl -v http://localhost:44772/ping # 期望200状态码 ``` ### 镜像构建 ```bash docker build -t opensandbox/execd:dev . # 运行容器 docker run -d \ -p 44772:44772 \ -e JUPYTER_HOST=http://jupyter-server \ -e JUPYTER_TOKEN=your-token \ --name execd \ opensandbox/execd:dev ``` ## 配置说明 ### 命令行标志 | 标志 | 类型 | 默认值 | 说明 | |-------------------------------|----------|---------|-------------------------------------| | `--jupyter-host` | string | `""` | 后端 Jupyter server 地址,要求execd进程可访问即可 | | `--jupyter-token` | string | `""` | Jupyter HTTP/WebSocket 令牌 | | `--port` | int | `44772` | HTTP 监听端口 | | `--log-level` | int | `6` | Beego 日志级别(0=紧急,7=调试) | | `--access-token` | string | `""` | API 共享密钥(可选) | | `--graceful-shutdown-timeout` | duration | `3s` | 关闭前等待 SSE 的时间 | ### 环境变量 所有标志都可以通过环境变量设置: ```bash export JUPYTER_HOST=http://127.0.0.1:8888 export JUPYTER_TOKEN=your-token ``` 环境变量优先于默认值,但会被显式的 CLI 标志覆盖。 ## API 参考 [API Spec](../../specs/execd-api.yaml)。 ## 支持的语言 ### 基于 Jupyter 的语言 | 语言 | Kernel | 特性 | |------------|-------------|-----------------| | Python | IPython | 完整 Jupyter 协议支持 | | Java | IJava | 基于 JShell 的执行 | | JavaScript | IJavaScript | Node.js 运行时 | | TypeScript | ITypeScript | TS 编译 + Node 执行 | | Go | gophernotes | Go 解释器 | | Bash | Bash kernel | Shell 脚本执行 | ### 原生执行器 | 模式/语言 | 后端 | 特性 | |----------------------|---------|-------------| | `command` | OS exec | 同步 shell 命令 | | `background-command` | OS exec | 分离的后台进程 | ## 开发指南 开发指南请参见 [DEVELOPMENT.md](./DEVELOPMENT.md)。 ## 测试 ### 单元测试 ```bash make test ``` ### 集成测试 需要真实 Jupyter Server 的集成测试默认跳过: ```bash export JUPYTER_URL=http://localhost:8888 export JUPYTER_TOKEN=your-token go test -v ./pkg/jupyter/... ``` ### 手动测试工作流 1. 启动 Jupyter:`./tests/jupyter.sh` 2. 启动 execd:`./bin/execd --jupyter-host=http://localhost:54321 --jupyter-token=opensandboxexecdlocaltest` 3. 执行代码: ```bash curl -X POST -H "Content-Type: application/json" \ -d '{"language":"python","code":"print(\"test\")"}' \ http://localhost:44772/code ``` ## 配置 ### SSE API 优雅结束时间窗口 - 环境变量:`EXECD_API_GRACE_SHUTDOWN`(如 `500ms`、`2s`、`1m`) - 命令行参数:`--graceful-shutdown-timeout` - 默认值:`1s` 作用:控制 SSE 响应(代码/命令执行)在发送最后一块数据后,保持连接的宽限时间,方便客户端完全读到尾部输出再关闭。如果设置为 `0s` 则关闭这一等待。 ## 可观测性 ### 日志记录 全程使用 Beego 的分级日志器: ```go logs.Info("message") // 常规信息 logs.Warning("message") // 警告条件 logs.Error("message") // 错误条件 logs.Debug("message") // 调试级别消息 ``` - 环境变量:`EXECD_LOG_FILE` 指定日志输出文件;未设置时日志输出到标准输出(stdout)。 日志级别(0-7): - 0:紧急 - 1:警报 - 2:严重 - 3:错误 - 4:警告 - 5:注意 - 6:信息(默认) - 7:调试 ### 指标采集 `/metrics` 端点提供: - CPU 使用百分比 - 内存总量/已用(GB) - 内存使用百分比 - 进程运行时间 - 当前时间戳 对于实时监控,使用 `/metrics/watch`,每秒通过 SSE 流式推送更新。 ## 性能基准 ### 典型延迟(localhost) | 操作 | 延迟 | |---------------|----------| | `/ping` | < 1ms | | `/files/info` | < 5ms | | 代码执行(Python) | 50-200ms | | 文件上传(1MB) | 10-50ms | | 指标快照 | < 10ms | ### 资源使用(空闲) - 内存:~50MB - CPU:< 1% - Goroutines:~15 ### 可扩展性 - 支持 100+ 并发 SSE 连接 - 文件操作随文件大小线性扩展 - Jupyter 会话是有状态的,需要专用资源 ## 贡献 1. Fork 仓库 2. 创建特性分支 3. 遵循编码规范(见 DEVELOPMENT.md) 4. 为新功能添加测试 5. 运行 `make fmt` 和 `make test` 6. 提交 pull request ## 许可证 `execd` 是 OpenSandbox 项目的一部分。详见仓库根目录的 [LICENSE](../../LICENSE)。 ## 支持 - 问题:[GitHub Issues](https://github.com/alibaba/OpenSandbox/issues) - 文档:[OpenSandbox Docs](https://github.com/alibaba/OpenSandbox/wiki) - 社区:[Discussions](https://github.com/alibaba/OpenSandbox/discussions) ================================================ FILE: components/execd/bootstrap.sh ================================================ #!/bin/sh # Copyright 2025 Alibaba Group Holding Ltd. # # 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. set -e EXECD="${EXECD:=/opt/opensandbox/execd}" if [ -z "${EXECD_ENVS:-}" ]; then EXECD_ENVS="/opt/opensandbox/.env" fi # Best-effort ensure file exists. if ! mkdir -p "$(dirname "$EXECD_ENVS")" 2>/dev/null; then echo "warning: failed to create dir for EXECD_ENVS=$EXECD_ENVS" >&2 fi if ! touch "$EXECD_ENVS" 2>/dev/null; then echo "warning: failed to touch EXECD_ENVS=$EXECD_ENVS" >&2 fi export EXECD_ENVS echo "starting OpenSandbox Execd daemon at $EXECD." $EXECD & # Allow chained shell commands (e.g., /test1.sh && /test2.sh) # Usage: # bootstrap.sh -c "/test1.sh && /test2.sh" # Or set BOOTSTRAP_CMD="/test1.sh && /test2.sh" CMD="" if [ "${BOOTSTRAP_CMD:-}" != "" ]; then CMD="$BOOTSTRAP_CMD" elif [ $# -ge 1 ] && [ "$1" = "-c" ]; then shift CMD="$*" fi SHELL_BIN="${BOOTSTRAP_SHELL:-}" if [ -z "$SHELL_BIN" ]; then if command -v bash >/dev/null 2>&1; then SHELL_BIN="$(command -v bash)" elif command -v sh >/dev/null 2>&1; then SHELL_BIN="$(command -v sh)" else echo "error: neither bash nor sh found in PATH" >&2 exit 1 fi fi set -x if [ "$CMD" != "" ]; then exec "$SHELL_BIN" -c "$CMD" fi if [ $# -eq 0 ]; then exec "$SHELL_BIN" fi exec "$@" ================================================ FILE: components/execd/build.sh ================================================ #!/bin/bash # Copyright 2025 Alibaba Group Holding Ltd. # # 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. set -ex TAG=${TAG:-latest} VERSION=${VERSION:-$(git describe --tags --always --dirty 2>/dev/null || echo "dev")} GIT_COMMIT=${GIT_COMMIT:-$(git rev-parse HEAD 2>/dev/null || echo "unknown")} BUILD_TIME=${BUILD_TIME:-$(date -u +"%Y-%m-%dT%H:%M:%SZ")} REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || realpath "$(dirname "$0")/../..") cd "${REPO_ROOT}" docker buildx rm execd-builder || true docker buildx create --use --name execd-builder docker buildx inspect --bootstrap docker buildx ls LATEST_TAGS=() if [[ "${TAG}" == v* ]]; then LATEST_TAGS+=(-t opensandbox/execd:latest -t sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:latest) fi docker buildx build \ -t opensandbox/execd:${TAG} \ -t sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:${TAG} \ "${LATEST_TAGS[@]}" \ -f components/execd/Dockerfile \ --build-arg VERSION="${VERSION}" \ --build-arg GIT_COMMIT="${GIT_COMMIT}" \ --build-arg BUILD_TIME="${BUILD_TIME}" \ --platform linux/amd64,linux/arm64 \ --push \ . ================================================ FILE: components/execd/go.mod ================================================ module github.com/alibaba/opensandbox/execd go 1.24.0 require ( github.com/alibaba/opensandbox/internal v0.0.0 github.com/bmatcuk/doublestar/v4 v4.9.1 github.com/gin-gonic/gin v1.10.0 github.com/go-playground/validator/v10 v10.28.0 github.com/go-sql-driver/mysql v1.8.1 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 github.com/shirou/gopsutil v3.21.11+incompatible github.com/stretchr/testify v1.10.0 go.uber.org/automaxprocs v1.6.0 k8s.io/apimachinery v0.34.2 k8s.io/client-go v0.34.2 ) require ( filippo.io/edwards25519 v1.1.1 // indirect github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.10 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.uber.org/multierr v1.10.0 // indirect go.uber.org/zap v1.27.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/arch v0.8.0 // indirect golang.org/x/crypto v0.45.0 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.31.0 // indirect google.golang.org/protobuf v1.36.5 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect ) replace github.com/alibaba/opensandbox/internal => ../internal ================================================ FILE: components/execd/go.sum ================================================ filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw= filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688= github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= k8s.io/apimachinery v0.34.2 h1:zQ12Uk3eMHPxrsbUJgNF8bTauTVR2WgqJsTmwTE/NW4= k8s.io/apimachinery v0.34.2/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= k8s.io/client-go v0.34.2 h1:Co6XiknN+uUZqiddlfAjT68184/37PS4QAzYvQvDR8M= k8s.io/client-go v0.34.2/go.mod h1:2VYDl1XXJsdcAxw7BenFslRQX28Dxz91U9MWKjX97fE= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= ================================================ FILE: components/execd/main.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package main import ( "fmt" "os" "github.com/alibaba/opensandbox/internal/version" _ "go.uber.org/automaxprocs/maxprocs" "github.com/alibaba/opensandbox/execd/pkg/flag" "github.com/alibaba/opensandbox/execd/pkg/log" _ "github.com/alibaba/opensandbox/execd/pkg/util/safego" "github.com/alibaba/opensandbox/execd/pkg/web" "github.com/alibaba/opensandbox/execd/pkg/web/controller" ) // main initializes and starts the execd server. func main() { version.EchoVersion("OpenSandbox Execd") flag.InitFlags() log.Init(flag.ServerLogLevel) controller.InitCodeRunner() engine := web.NewRouter(flag.ServerAccessToken) addr := fmt.Sprintf(":%d", flag.ServerPort) log.Info("execd listening on %s", addr) if err := engine.Run(addr); err != nil { log.Error("failed to start execd server: %v", err) os.Exit(1) } } ================================================ FILE: components/execd/pkg/flag/flags.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package flag import "time" var ( // JupyterServerHost points to the target Jupyter instance. JupyterServerHost string // JupyterServerToken authenticates requests to the Jupyter server. JupyterServerToken string // ServerPort controls the HTTP listener port. ServerPort int // ServerLogLevel controls the server log verbosity. ServerLogLevel int // ServerAccessToken guards API entrypoints when set. ServerAccessToken string // ApiGracefulShutdownTimeout waits before tearing down SSE streams. ApiGracefulShutdownTimeout time.Duration ) ================================================ FILE: components/execd/pkg/flag/parser.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package flag import ( "flag" stdlog "log" "os" "strings" "time" "github.com/alibaba/opensandbox/execd/pkg/log" ) const ( jupyterHostEnv = "JUPYTER_HOST" jupyterTokenEnv = "JUPYTER_TOKEN" gracefulShutdownTimeoutEnv = "EXECD_API_GRACE_SHUTDOWN" ) // InitFlags registers CLI flags and env overrides. func InitFlags() { // Set default values ServerPort = 44772 ServerLogLevel = 6 ServerAccessToken = "" ApiGracefulShutdownTimeout = time.Second * 1 // First, set default values from environment variables if jupyterFromEnv := os.Getenv(jupyterHostEnv); jupyterFromEnv != "" { if !strings.HasPrefix(jupyterFromEnv, "http://") && !strings.HasPrefix(jupyterFromEnv, "https://") { stdlog.Panic("Invalid JUPYTER_HOST format: must start with http:// or https://") } JupyterServerHost = jupyterFromEnv } if jupyterTokenFromEnv := os.Getenv(jupyterTokenEnv); jupyterTokenFromEnv != "" { JupyterServerToken = jupyterTokenFromEnv } // Then define flags with current values as defaults flag.StringVar(&JupyterServerHost, "jupyter-host", JupyterServerHost, "Jupyter server host address (e.g., http://localhost, http://192.168.1.100)") flag.StringVar(&JupyterServerToken, "jupyter-token", JupyterServerToken, "Jupyter server authentication token") flag.IntVar(&ServerPort, "port", ServerPort, "Server listening port (default: 44772)") flag.IntVar(&ServerLogLevel, "log-level", ServerLogLevel, "Server log level (0=LevelEmergency, 1=LevelAlert, 2=LevelCritical, 3=LevelError, 4=LevelWarning, 5=LevelNotice, 6=LevelInformational, 7=LevelDebug, default: 6)") flag.StringVar(&ServerAccessToken, "access-token", ServerAccessToken, "Server access token for API authentication") if graceShutdownTimeout := os.Getenv(gracefulShutdownTimeoutEnv); graceShutdownTimeout != "" { duration, err := time.ParseDuration(graceShutdownTimeout) if err != nil { stdlog.Panicf("Failed to parse graceful shutdown timeout from env: %v", err) } ApiGracefulShutdownTimeout = duration } flag.DurationVar(&ApiGracefulShutdownTimeout, "graceful-shutdown-timeout", ApiGracefulShutdownTimeout, "API graceful shutdown timeout duration (default: 3s)") // Parse flags - these will override environment variables if provided flag.Parse() // Log final values log.Info("Jupyter server host is: %s", JupyterServerHost) log.Info("Jupyter server token is: %s", JupyterServerToken) } ================================================ FILE: components/execd/pkg/jupyter/auth/auth.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package auth import ( "fmt" "net/url" ) // Auth represents authentication configuration. type Auth struct { Token string Username string Password string } // NewTokenAuth builds a token-based config. func NewTokenAuth(token string) *Auth { return &Auth{ Token: token, } } // NewBasicAuth builds a basic-auth config. func NewBasicAuth(username, password string) *Auth { return &Auth{ Username: username, Password: password, } } // Validate reports which auth mode is configured. func (a *Auth) Validate() string { if a.Token != "" { return "token" } if a.Username != "" { return "basic" } return "none" } // AddAuthToURL appends token query parameters to the URL. func (a *Auth) AddAuthToURL(baseURL string) (string, error) { parsedURL, err := url.Parse(baseURL) if err != nil { return "", fmt.Errorf("failed to parse URL: %w", err) } query := parsedURL.Query() if a.Token != "" { query.Set("token", a.Token) } parsedURL.RawQuery = query.Encode() return parsedURL.String(), nil } ================================================ FILE: components/execd/pkg/jupyter/auth/auth_test.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package auth import ( "net/http" "net/http/httptest" "testing" ) func TestTokenAuthentication(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { token := r.Header.Get("Authorization") expectedToken := "token test-token" if token != expectedToken { w.WriteHeader(http.StatusUnauthorized) return } w.WriteHeader(http.StatusOK) })) defer server.Close() auth := NewAuth() auth.Token = "test-token" client := NewClient(&http.Client{}, auth) req, err := http.NewRequest("GET", server.URL, nil) if err != nil { t.Fatalf("Failed to create request: %v", err) } resp, err := client.Do(req) if err != nil { t.Fatalf("Failed to send request: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Errorf("Expected status code %d, got %d", http.StatusOK, resp.StatusCode) } } func TestBasicAuthentication(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { username, password, ok := r.BasicAuth() if !ok || username != "testuser" || password != "testpass" { w.WriteHeader(http.StatusUnauthorized) return } w.WriteHeader(http.StatusOK) })) defer server.Close() auth := NewAuth() auth.Username = "testuser" auth.Password = "testpass" client := NewClient(&http.Client{}, auth) req, err := http.NewRequest("GET", server.URL, nil) if err != nil { t.Fatalf("Failed to create request: %v", err) } resp, err := client.Do(req) if err != nil { t.Fatalf("Failed to send request: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Errorf("Expected status code %d, got %d", http.StatusOK, resp.StatusCode) } } func TestAuthValidation(t *testing.T) { emptyAuth := NewAuth() if emptyAuth.IsValid() { t.Error("Empty Auth should be invalid, but was determined to be valid") } tokenAuth := NewAuth() tokenAuth.Token = "test-token" if !tokenAuth.IsValid() { t.Error("Auth with token should be valid, but was determined to be invalid") } basicAuth := NewAuth() basicAuth.Username = "testuser" basicAuth.Password = "testpass" if !basicAuth.IsValid() { t.Error("Auth with Basic Auth should be valid, but was determined to be invalid") } invalidBasicAuth := NewAuth() invalidBasicAuth.Username = "testuser" if invalidBasicAuth.IsValid() { t.Error("Auth with only username and no password should be invalid, but was determined to be valid") } mixedAuth := NewAuth() mixedAuth.Token = "test-token" mixedAuth.Username = "testuser" mixedAuth.Password = "testpass" if !mixedAuth.IsValid() { t.Error("Auth with both token and Basic Auth should be valid, but was determined to be invalid") } } ================================================ FILE: components/execd/pkg/jupyter/auth/client.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package auth import ( "fmt" "io" "net/http" ) // Client wraps http.Client and injects auth headers. type Client struct { httpClient *http.Client auth *Auth } // NewClient creates a new authenticated HTTP client. func NewClient(httpClient *http.Client, auth *Auth) *Client { return &Client{ httpClient: httpClient, auth: auth, } } // Do sends an HTTP request and automatically adds authentication data. func (c *Client) Do(req *http.Request) (*http.Response, error) { if c.auth == nil { return c.httpClient.Do(req) } if c.auth.Token != "" { req.Header.Set("Authorization", fmt.Sprintf("token %s", c.auth.Token)) } else if c.auth.Username != "" { req.SetBasicAuth(c.auth.Username, c.auth.Password) } return c.httpClient.Do(req) } // Get sends a GET request. func (c *Client) Get(url string) (*http.Response, error) { req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { return nil, err } return c.Do(req) } // Post sends a POST request. func (c *Client) Post(url, contentType string, body io.Reader) (*http.Response, error) { req, err := http.NewRequest(http.MethodPost, url, body) if err != nil { return nil, err } req.Header.Set("Content-Type", contentType) return c.Do(req) } // Put sends a PUT request. func (c *Client) Put(url, contentType string, body io.Reader) (*http.Response, error) { req, err := http.NewRequest(http.MethodPut, url, body) if err != nil { return nil, err } req.Header.Set("Content-Type", contentType) return c.Do(req) } // Delete sends a DELETE request. func (c *Client) Delete(url string) (*http.Response, error) { req, err := http.NewRequest(http.MethodDelete, url, nil) if err != nil { return nil, err } return c.Do(req) } ================================================ FILE: components/execd/pkg/jupyter/auth/types.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package auth const ( AuthTypeNone = "none" AuthTypeToken = "token" AuthTypeBasic = "basic" AuthHeaderKey = "Authorization" AuthHeaderValuePrefix = "token " AuthURLParamKey = "token" ) // NewAuth creates an empty authentication configuration. func NewAuth() *Auth { return &Auth{} } // IsValid reports whether token or username/password are present. func (a *Auth) IsValid() bool { return a.Token != "" || (a.Username != "" && a.Password != "") } // GetAuthType returns token/basic/none. func (a *Auth) GetAuthType() string { return a.Validate() } ================================================ FILE: components/execd/pkg/jupyter/client.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package jupyter import ( "errors" "fmt" "net/http" "net/url" "github.com/alibaba/opensandbox/execd/pkg/jupyter/auth" "github.com/alibaba/opensandbox/execd/pkg/jupyter/execute" "github.com/alibaba/opensandbox/execd/pkg/jupyter/kernel" "github.com/alibaba/opensandbox/execd/pkg/jupyter/session" ) // Client interacts with the Jupyter server. type Client struct { BaseURL string httpClient *http.Client Auth *auth.Auth kernelClient *kernel.Client sessionClient *session.Client executeClient *execute.Client authClient *auth.Client } type ClientOption func(*Client) // WithHTTPClient sets a custom HTTP client. func WithHTTPClient(client *http.Client) ClientOption { return func(c *Client) { c.httpClient = client } } // WithToken configures the client with an authentication token. func WithToken(token string) ClientOption { return func(c *Client) { c.Auth.Token = token } } // WithBasicAuth configures the client with basic authentication. func WithBasicAuth(username, password string) ClientOption { return func(c *Client) { c.Auth.Username = username c.Auth.Password = password } } // NewClient creates a new Jupyter client instance. func NewClient(baseURL string, options ...ClientOption) *Client { client := &Client{ BaseURL: baseURL, httpClient: http.DefaultClient, Auth: auth.NewAuth(), } for _, option := range options { option(client) } client.authClient = auth.NewClient(client.httpClient, client.Auth) client.kernelClient = kernel.NewClient(baseURL, client.httpClient) client.sessionClient = session.NewClient(baseURL, client.httpClient) client.executeClient = execute.NewClient(baseURL, client.authClient) return client } // SetToken configures token authentication. func (c *Client) SetToken(token string) { c.Auth.Token = token } // SetBasicAuth configures username/password authentication. func (c *Client) SetBasicAuth(username, password string) { c.Auth.Username = username c.Auth.Password = password } // ValidateAuth quickly checks that some auth data is present. func (c *Client) ValidateAuth() (string, error) { authType := c.Auth.Validate() if authType == "none" { return "error", errors.New("no valid authentication information provided") } return "ok", nil } // GetKernelSpecs retrieves available kernel specifications. func (c *Client) GetKernelSpecs() (*kernel.KernelSpecs, error) { return c.kernelClient.GetKernelSpecs() } // ListKernels retrieves all running kernels. func (c *Client) ListKernels() ([]*kernel.Kernel, error) { return c.kernelClient.ListKernels() } // GetKernel retrieves information about a specific kernel. func (c *Client) GetKernel(kernelId string) (*kernel.Kernel, error) { return c.kernelClient.GetKernel(kernelId) } // StartKernel starts a new kernel. func (c *Client) StartKernel(name string) (*kernel.Kernel, error) { return c.kernelClient.StartKernel(name) } // RestartKernel restarts the specified kernel. func (c *Client) RestartKernel(kernelId string) (bool, error) { return c.kernelClient.RestartKernel(kernelId) } // InterruptKernel interrupts the specified kernel. func (c *Client) InterruptKernel(kernelId string) error { return c.kernelClient.InterruptKernel(kernelId) } // ShutdownKernel shuts down (and optionally restarts) the specified kernel. func (c *Client) ShutdownKernel(kernelId string, restart bool) error { return c.kernelClient.ShutdownKernel(kernelId, restart) } // ListSessions retrieves active sessions. func (c *Client) ListSessions() ([]*session.Session, error) { return c.sessionClient.ListSessions() } // GetSession retrieves information about a specific session. func (c *Client) GetSession(sessionId string) (*session.Session, error) { return c.sessionClient.GetSession(sessionId) } // CreateSession creates a new session. func (c *Client) CreateSession(name, ipynb, kernel string) (*session.Session, error) { return c.sessionClient.CreateSession(name, ipynb, kernel) } // ModifySession updates an existing session. func (c *Client) ModifySession(sessionId, name, path, kernel string) (*session.Session, error) { return c.sessionClient.ModifySession(sessionId, name, path, kernel) } // DeleteSession deletes the specified session. func (c *Client) DeleteSession(sessionId string) error { return c.sessionClient.DeleteSession(sessionId) } // ConnectToKernel establishes a websocket connection to the kernel. func (c *Client) ConnectToKernel(kernelId string) error { parsedURL, err := url.Parse(c.BaseURL) if err != nil { return fmt.Errorf("invalid base URL: %w", err) } scheme := "ws" if parsedURL.Scheme == "https" { scheme = "wss" } wsURL := fmt.Sprintf("%s://%s/api/kernels/%s/channels", scheme, parsedURL.Host, kernelId) if c.Auth.Token != "" { wsURL = fmt.Sprintf("%s?token=%s", wsURL, c.Auth.Token) } return c.executeClient.Connect(wsURL) } // DisconnectFromKernel closes the websocket connection. func (c *Client) DisconnectFromKernel(kernelId string) { c.executeClient.Disconnect() } // ExecuteCodeStream streams execution results into resultChan. func (c *Client) ExecuteCodeStream(kernelId, code string, resultChan chan *execute.ExecutionResult) error { return c.executeClient.ExecuteCodeStream(code, resultChan) } // ExecuteCodeWithCallback processes execution events via callbacks. func (c *Client) ExecuteCodeWithCallback(code string, handler execute.CallbackHandler) error { return c.executeClient.ExecuteCodeWithCallback(code, handler) } ================================================ FILE: components/execd/pkg/jupyter/debug_integration_test.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package jupyter import ( "fmt" "net/http" "net/http/httputil" "testing" ) // TestDebugServerIntegration logs real server interactions for debugging. func TestDebugServerIntegration(t *testing.T) { jupyterURL := getEnv("JUPYTER_URL", "") jupyterToken := getEnv("JUPYTER_TOKEN", "") if jupyterURL == "" || jupyterToken == "" { t.Skip("JUPYTER_URL and JUPYTER_TOKEN environment variables must be set to run this test") } t.Logf("Connecting to Jupyter server: %s", jupyterURL) httpClient := &http.Client{ Transport: &debugTransport{t: t}, } client := NewClient(jupyterURL, WithToken(jupyterToken), WithHTTPClient(httpClient)) t.Run("Validate Authentication", func(t *testing.T) { t.Logf("Calling ValidateAuth...") status, err := client.ValidateAuth() if err != nil { t.Fatalf("Authentication validation failed: %v", err) } t.Logf("Authentication validation successful! Status: %s", status) }) t.Run("Get API Information", func(t *testing.T) { req, err := http.NewRequest("GET", fmt.Sprintf("%s/api", jupyterURL), nil) if err != nil { t.Fatalf("Failed to create request: %v", err) } req.Header.Set("Authorization", fmt.Sprintf("Token %s", jupyterToken)) t.Logf("Sending request to /api endpoint...") resp, err := httpClient.Do(req) if err != nil { t.Fatalf("Failed to send request: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Logf("API request returned non-200 status code: %d %s", resp.StatusCode, resp.Status) } else { t.Logf("API request successful, status code: %d %s", resp.StatusCode, resp.Status) respDump, err := httputil.DumpResponse(resp, true) if err != nil { t.Logf("Unable to dump response: %v", err) } else { t.Logf("Response details:\n%s", string(respDump)) } } }) t.Run("Test Different Header Combinations", func(t *testing.T) { headerSets := []map[string]string{ { "Authorization": fmt.Sprintf("Token %s", jupyterToken), }, { "Authorization": fmt.Sprintf("Token %s", jupyterToken), "X-XSRFToken": jupyterToken[:16], // Use first 16 characters of token as XSRF token attempt }, { "Authorization": fmt.Sprintf("token %s", jupyterToken), // lowercase token }, { "Cookie": fmt.Sprintf("_xsrf=%s; jupyter_token=%s", jupyterToken[:16], jupyterToken), }, } for i, headers := range headerSets { t.Logf("Testing header combination #%d:", i+1) for k, v := range headers { t.Logf(" %s: %s", k, v) } req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/kernelspecs", jupyterURL), nil) if err != nil { t.Fatalf("Failed to create request: %v", err) } for k, v := range headers { req.Header.Set(k, v) } t.Logf("Sending request to /api/kernelspecs endpoint...") resp, err := httpClient.Do(req) if err != nil { t.Fatalf("Failed to send request: %v", err) } defer resp.Body.Close() t.Logf("Response status code: %d %s", resp.StatusCode, resp.Status) if resp.StatusCode == http.StatusOK { t.Logf("Successfully found valid header combination!") respDump, err := httputil.DumpResponse(resp, true) if err != nil { t.Logf("Unable to dump response: %v", err) } else { maxLen := 500 respStr := string(respDump) if len(respStr) > maxLen { t.Logf("Response (truncated):\n%s...", respStr[:maxLen]) } else { t.Logf("Response:\n%s", respStr) } } } } }) } // debugTransport logs request and response dumps. type debugTransport struct { t *testing.T } func (d *debugTransport) RoundTrip(req *http.Request) (*http.Response, error) { reqDump, err := httputil.DumpRequestOut(req, true) if err != nil { d.t.Logf("Unable to dump request: %v", err) } else { maxLen := 500 reqStr := string(reqDump) if len(reqStr) > maxLen { d.t.Logf("Request (truncated):\n%s...", reqStr[:maxLen]) } else { d.t.Logf("Request:\n%s", reqStr) } } resp, err := http.DefaultTransport.RoundTrip(req) if err != nil { return nil, err } d.t.Logf("Response status: %d %s", resp.StatusCode, resp.Status) return resp, nil } ================================================ FILE: components/execd/pkg/jupyter/execute/events.json ================================================ [ { "header": { "msg_id": "e5e24851-db96ed91126b13f9b603136f_123284_6", "username": "username", "session": "e5e24851-db96ed91126b13f9b603136f", "date": "2025-06-06T09:20:51.206377Z", "msg_type": "status", "version": "5.3" }, "parent_header": { "msg_id": "e1df6eb2-f395e4906c9cecd23d97b548_7_2", "username": "username", "session": "e1df6eb2-f395e4906c9cecd23d97b548", "date": "2025-06-06T09:20:51.204953Z", "msg_type": "kernel_info_request", "version": "5.3" }, "metadata": {}, "content": { "execution_state": "busy" }, "buffers": [], "channel": "iopub" }, { "header": { "msg_id": "e5e24851-db96ed91126b13f9b603136f_123284_8", "username": "username", "session": "e5e24851-db96ed91126b13f9b603136f", "date": "2025-06-06T09:20:51.207083Z", "msg_type": "status", "version": "5.3" }, "parent_header": { "msg_id": "e1df6eb2-f395e4906c9cecd23d97b548_7_1", "username": "username", "session": "e1df6eb2-f395e4906c9cecd23d97b548", "date": "2025-06-06T09:20:51.204866Z", "msg_type": "kernel_info_request", "version": "5.3" }, "metadata": {}, "content": { "execution_state": "idle" }, "buffers": [], "channel": "iopub" }, { "header": { "msg_id": "e5e24851-db96ed91126b13f9b603136f_123284_9", "username": "username", "session": "e5e24851-db96ed91126b13f9b603136f", "date": "2025-06-06T09:20:51.207169Z", "msg_type": "status", "version": "5.3" }, "parent_header": { "msg_id": "e1df6eb2-f395e4906c9cecd23d97b548_7_2", "username": "username", "session": "e1df6eb2-f395e4906c9cecd23d97b548", "date": "2025-06-06T09:20:51.204953Z", "msg_type": "kernel_info_request", "version": "5.3" }, "metadata": {}, "content": { "execution_state": "idle" }, "buffers": [], "channel": "iopub" }, { "header": { "msg_id": "e5e24851-db96ed91126b13f9b603136f_123284_10", "username": "username", "session": "e5e24851-db96ed91126b13f9b603136f", "date": "2025-06-06T09:20:51.248234Z", "msg_type": "status", "version": "5.3" }, "parent_header": { "msg_id": "e8e7f0af-fdd9-4ea9-8d78-eab629b5c0f0-1", "username": "go-client", "session": "e8e7f0af-fdd9-4ea9-8d78-eab629b5c0f0", "date": "2025-06-06T17:20:51+08:00", "msg_type": "execute_request", "version": "5.3" }, "metadata": {}, "content": { "execution_state": "busy" }, "buffers": [], "channel": "iopub" }, { "header": { "msg_id": "e5e24851-db96ed91126b13f9b603136f_123284_11", "username": "username", "session": "e5e24851-db96ed91126b13f9b603136f", "date": "2025-06-06T09:20:51.248481Z", "msg_type": "execute_input", "version": "5.3" }, "parent_header": { "msg_id": "e8e7f0af-fdd9-4ea9-8d78-eab629b5c0f0-1", "username": "go-client", "session": "e8e7f0af-fdd9-4ea9-8d78-eab629b5c0f0", "date": "2025-06-06T17:20:51+08:00", "msg_type": "execute_request", "version": "5.3" }, "metadata": {}, "content": { "code": "print('Hello, Jupyter!')\nresult = 2 + 2\nresult", "execution_count": 1 }, "buffers": [], "channel": "iopub" }, { "header": { "msg_id": "e5e24851-db96ed91126b13f9b603136f_123284_13", "username": "username", "session": "e5e24851-db96ed91126b13f9b603136f", "date": "2025-06-06T09:20:51.253641Z", "msg_type": "stream", "version": "5.3" }, "parent_header": { "msg_id": "e8e7f0af-fdd9-4ea9-8d78-eab629b5c0f0-1", "username": "go-client", "session": "e8e7f0af-fdd9-4ea9-8d78-eab629b5c0f0", "date": "2025-06-06T17:20:51+08:00", "msg_type": "execute_request", "version": "5.3" }, "metadata": {}, "content": { "name": "stdout", "text": "Hello, Jupyter!\n" }, "buffers": [], "channel": "iopub" }, { "header": { "msg_id": "e5e24851-db96ed91126b13f9b603136f_123284_12", "username": "username", "session": "e5e24851-db96ed91126b13f9b603136f", "date": "2025-06-06T09:20:51.251743Z", "msg_type": "execute_result", "version": "5.3" }, "parent_header": { "msg_id": "e8e7f0af-fdd9-4ea9-8d78-eab629b5c0f0-1", "username": "go-client", "session": "e8e7f0af-fdd9-4ea9-8d78-eab629b5c0f0", "date": "2025-06-06T17:20:51+08:00", "msg_type": "execute_request", "version": "5.3" }, "metadata": {}, "content": { "data": { "text/plain": "4" }, "metadata": {}, "execution_count": 1 }, "buffers": [], "channel": "iopub" }, { "header": { "msg_id": "e5e24851-db96ed91126b13f9b603136f_123284_14", "username": "username", "session": "e5e24851-db96ed91126b13f9b603136f", "date": "2025-06-06T09:20:51.255042Z", "msg_type": "execute_reply", "version": "5.3" }, "parent_header": { "msg_id": "e8e7f0af-fdd9-4ea9-8d78-eab629b5c0f0-1", "username": "go-client", "session": "e8e7f0af-fdd9-4ea9-8d78-eab629b5c0f0", "date": "2025-06-06T17:20:51+08:00", "msg_type": "execute_request", "version": "5.3" }, "metadata": { "dependencies_met": true, "engine": "d82231bb-94b0-4296-8372-2913351ee2a1", "started": "2025-06-06T09:20:51.248468Z", "status": "ok" }, "content": { "status": "ok", "execution_count": 1, "user_expressions": {}, "payload": [] }, "buffers": [], "channel": "shell" }, { "header": { "msg_id": "e5e24851-db96ed91126b13f9b603136f_123284_15", "username": "username", "session": "e5e24851-db96ed91126b13f9b603136f", "date": "2025-06-06T09:20:51.255385Z", "msg_type": "status", "version": "5.3" }, "parent_header": { "msg_id": "e8e7f0af-fdd9-4ea9-8d78-eab629b5c0f0-1", "username": "go-client", "session": "e8e7f0af-fdd9-4ea9-8d78-eab629b5c0f0", "date": "2025-06-06T17:20:51+08:00", "msg_type": "execute_request", "version": "5.3" }, "metadata": {}, "content": { "execution_state": "idle" }, "buffers": [], "channel": "iopub" } ] ================================================ FILE: components/execd/pkg/jupyter/execute/execute.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. // Package execute provides functionality for executing Jupyter kernel code via WebSocket package execute import ( "encoding/json" "errors" "fmt" "net/http" "sync" "time" "github.com/google/uuid" "github.com/gorilla/websocket" ) // HTTPClient defines the HTTP client interface type HTTPClient interface { Do(req *http.Request) (*http.Response, error) } // Client is the client for code execution type Client struct { // Internal HTTP client for sending HTTP requests httpClient HTTPClient // WebSocket connection conn *websocket.Conn // Message handler mappings handlers map[MessageType]func(*Message) // Session ID session string // Message ID counter msgCounter int // Mutex for protecting concurrent access mu sync.Mutex // WebSocket URL for kernel connection wsURL string } // NewClient creates a new code execution client func NewClient(baseURL string, httpClient HTTPClient) *Client { return &Client{ httpClient: httpClient, handlers: make(map[MessageType]func(*Message)), session: uuid.New().String(), msgCounter: 0, } } // Connect connects to the WebSocket of the specified kernel func (c *Client) Connect(wsURL string) error { c.mu.Lock() defer c.mu.Unlock() // Save WebSocket URL c.wsURL = wsURL // Connect to WebSocket conn, resp, err := websocket.DefaultDialer.Dial(wsURL, nil) if resp != nil && err != nil { resp.Body.Close() } if err != nil { return fmt.Errorf("failed to connect to kernel: %w", err) } c.conn = conn // Register default message handlers c.registerDefaultHandlers() // Start message receiving goroutine go c.receiveMessages() return nil } // Disconnect disconnects the WebSocket connection to the kernel func (c *Client) Disconnect() { c.mu.Lock() defer c.mu.Unlock() if c.conn != nil { c.conn.Close() c.conn = nil } } // IsConnected checks if connected to the kernel func (c *Client) IsConnected() bool { c.mu.Lock() defer c.mu.Unlock() return c.conn != nil } // ExecuteCodeStream executes code in streaming mode, sending results to the provided channel func (c *Client) ExecuteCodeStream(code string, resultChan chan *ExecutionResult) error { if !c.IsConnected() { return errors.New("not connected to kernel, please call Connect method") } // record start time startTime := time.Now() // prepare execution request msgID := c.nextMessageID() request := &ExecuteRequest{ Code: code, Silent: false, StoreHistory: true, UserExpressions: make(map[string]string), AllowStdin: false, StopOnError: true, } // serialize request content content, err := json.Marshal(request) if err != nil { return fmt.Errorf("failed to serialize request: %w", err) } // create message msg := &Message{ Header: Header{ MessageID: msgID, Username: "go-client", Session: c.session, Date: time.Now().Format(time.RFC3339), MessageType: string(MsgExecuteRequest), Version: "5.3", }, ParentHeader: Header{}, Metadata: make(map[string]interface{}), Content: content, Channel: "shell", } // Create result object result := &ExecutionResult{ Status: "ok", Stream: make([]*StreamOutput, 0), ExecutionTime: 0, } // Register temporary handler to receive execution result var executeDone bool var executeMutex sync.Mutex var executeResult *ExecuteResult // Create mutex to protect result object var resultMutex sync.Mutex // Clear temporary handlers c.clearTemporaryHandlers() c.registerHandler(MsgExecuteReply, func(msg *Message) { var execReply ExecuteReply if err := json.Unmarshal(msg.Content, &execReply); err != nil { return } resultMutex.Lock() result.ExecutionCount = execReply.ExecutionCount if execReply.EName != "" { result.Error = &execReply.ErrorOutput } resultMutex.Unlock() }) // register execution result handler c.registerHandler(MsgExecuteResult, func(msg *Message) { var execResult ExecuteResult if err := json.Unmarshal(msg.Content, &execResult); err != nil { return } executeMutex.Lock() executeResult = &execResult executeMutex.Unlock() resultMutex.Lock() result.ExecutionCount = execResult.ExecutionCount notify := &ExecutionResult{} notify.ExecutionCount = executeResult.ExecutionCount notify.ExecutionData = executeResult.Data resultChan <- notify resultMutex.Unlock() }) // Register stream output handler c.registerHandler(MsgStream, func(msg *Message) { var stream StreamOutput if err := json.Unmarshal(msg.Content, &stream); err != nil { return } resultMutex.Lock() result.Stream = append(result.Stream, &stream) notify := &ExecutionResult{} notify.Stream = []*StreamOutput{&stream} resultChan <- notify resultMutex.Unlock() }) // register error handler c.registerHandler(MsgError, func(msg *Message) { var errOutput ErrorOutput if err := json.Unmarshal(msg.Content, &errOutput); err != nil { return } resultMutex.Lock() result.Status = "error" result.Error = &errOutput notify := &ExecutionResult{} notify.Error = &errOutput notify.Status = "error" resultChan <- notify resultMutex.Unlock() }) // register status handler c.registerHandler(MsgStatus, func(msg *Message) { var status StatusUpdate if err := json.Unmarshal(msg.Content, &status); err != nil { return } if status.ExecutionState == StateIdle { executeMutex.Lock() // Check whether execution can be completed if !executeDone { executeDone = true go func() { // calculate execution time resultMutex.Lock() result.ExecutionTime = time.Since(startTime) // Send final result notify := &ExecutionResult{} notify.ExecutionTime = result.ExecutionTime resultChan <- notify resultMutex.Unlock() for result.ExecutionCount <= 0 && result.Error == nil { time.Sleep(300 * time.Millisecond) } // Close result channel close(resultChan) }() } executeMutex.Unlock() } }) // send execution request c.mu.Lock() err = c.conn.WriteJSON(msg) c.mu.Unlock() if err != nil { return fmt.Errorf("failed to send execution request: %w", err) } return nil } // ExecuteCodeWithCallback executes code using callback functions func (c *Client) ExecuteCodeWithCallback(code string, handler CallbackHandler) error { if !c.IsConnected() { return errors.New("not connected to kernel, please call Connect method") } // prepare execution request msgID := c.nextMessageID() request := &ExecuteRequest{ Code: code, Silent: false, StoreHistory: true, UserExpressions: make(map[string]string), AllowStdin: false, StopOnError: true, } // serialize request content content, err := json.Marshal(request) if err != nil { return fmt.Errorf("failed to serialize request: %w", err) } // create message msg := &Message{ Header: Header{ MessageID: msgID, Username: "go-client", Session: c.session, Date: time.Now().Format(time.RFC3339), MessageType: string(MsgExecuteRequest), Version: "5.3", }, ParentHeader: Header{}, Metadata: make(map[string]interface{}), Content: content, Channel: "shell", } // register execution result handler if handler.OnExecuteResult != nil { c.registerHandler(MsgExecuteResult, func(msg *Message) { var execResult ExecuteResult if err := json.Unmarshal(msg.Content, &execResult); err != nil { return } // calls callback functions handler.OnExecuteResult(&execResult) }) } // Register stream output handler if handler.OnStream != nil { c.registerHandler(MsgStream, func(msg *Message) { var stream StreamOutput if err := json.Unmarshal(msg.Content, &stream); err != nil { return } // calls callback functions handler.OnStream(&stream) }) } // Register display data handler if handler.OnDisplayData != nil { c.registerHandler(MsgDisplayData, func(msg *Message) { var display DisplayData if err := json.Unmarshal(msg.Content, &display); err != nil { return } // calls callback functions handler.OnDisplayData(&display) }) } // register error handler if handler.OnError != nil { c.registerHandler(MsgError, func(msg *Message) { var errOutput ErrorOutput if err := json.Unmarshal(msg.Content, &errOutput); err != nil { return } // calls callback functions handler.OnError(&errOutput) }) } // register status handler if handler.OnStatus != nil { c.registerHandler(MsgStatus, func(msg *Message) { var status StatusUpdate if err := json.Unmarshal(msg.Content, &status); err != nil { return } // calls callback functions handler.OnStatus(&status) }) } // send execution request c.mu.Lock() err = c.conn.WriteJSON(msg) c.mu.Unlock() if err != nil { return fmt.Errorf("failed to send execution request: %w", err) } return nil } // Register default message handlers func (c *Client) registerDefaultHandlers() { // default message handlers can be registered here } // Register temporary message handler func (c *Client) registerHandler(msgType MessageType, handler func(*Message)) { c.mu.Lock() defer c.mu.Unlock() c.handlers[msgType] = handler } // Clear temporary message handlers func (c *Client) clearTemporaryHandlers() { c.mu.Lock() defer c.mu.Unlock() c.handlers = make(map[MessageType]func(*Message)) c.registerDefaultHandlers() } // Receive WebSocket messages func (c *Client) receiveMessages() { for { c.mu.Lock() conn := c.conn c.mu.Unlock() if conn == nil { break } // Receive message var msg Message err := conn.ReadJSON(&msg) if err != nil { // connection may already be closed break } // Process message c.handleMessage(&msg) } } // Handle received messages func (c *Client) handleMessage(msg *Message) { // Extract message type msgType := MessageType(msg.Header.MessageType) // call the corresponding handler c.mu.Lock() handler, ok := c.handlers[msgType] c.mu.Unlock() if ok && handler != nil { handler(msg) } } // generate next messageID func (c *Client) nextMessageID() string { c.mu.Lock() defer c.mu.Unlock() c.msgCounter++ return fmt.Sprintf("%s-%d", c.session, c.msgCounter) } ================================================ FILE: components/execd/pkg/jupyter/execute/execute_test.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package execute import ( "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/gorilla/websocket" ) // Create WebSocket test server func createTestServer(t *testing.T, handleFunc func(conn *websocket.Conn)) *httptest.Server { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Validate request path if !strings.HasPrefix(r.URL.Path, "/api/kernels/") { t.Errorf("expected path to start with '/api/kernels/', got '%s'", r.URL.Path) } if !strings.HasSuffix(r.URL.Path, "/channels") { t.Errorf("expected path to end with '/channels', got '%s'", r.URL.Path) } // Upgrade HTTP connection to WebSocket upgrader := websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true }, } conn, err := upgrader.Upgrade(w, r, nil) if err != nil { t.Fatalf("failed to upgrade to WebSocket: %v", err) } defer conn.Close() // Handle WebSocket connection handleFunc(conn) })) return server } // Test streaming code execution func TestExecuteCodeStream(t *testing.T) { // Spin up mock WebSocket server server := createTestServer(t, func(conn *websocket.Conn) { // Read execution request var executeRequest Message err := conn.ReadJSON(&executeRequest) if err != nil { t.Fatalf("failed to read execution request: %v", err) } // Send multiple stream messages for i := 0; i < 3; i++ { streamContent, _ := json.Marshal(StreamOutput{ Name: StreamStdout, Text: "Line " + string(rune('0'+i)) + "\n", }) streamMsg := Message{ Header: Header{ MessageID: "stream-msg-id-" + string(rune('0'+i)), Session: executeRequest.Header.Session, MessageType: string(MsgStream), }, ParentHeader: executeRequest.Header, Content: json.RawMessage(streamContent), } conn.WriteJSON(streamMsg) time.Sleep(100 * time.Millisecond) } // Send execution result resultContent, _ := json.Marshal(ExecuteResult{ ExecutionCount: 1, Data: map[string]interface{}{ "text/plain": "Completed", }, Metadata: map[string]interface{}{}, }) executeResultMsg := Message{ Header: Header{ MessageID: "result-msg-id", Session: executeRequest.Header.Session, MessageType: string(MsgExecuteResult), }, ParentHeader: executeRequest.Header, Content: json.RawMessage(resultContent), } conn.WriteJSON(executeResultMsg) // Send status message statusContent, _ := json.Marshal(StatusUpdate{ ExecutionState: StateIdle, }) statusMsg := Message{ Header: Header{ MessageID: "status-msg-id", Session: executeRequest.Header.Session, MessageType: string(MsgStatus), }, ParentHeader: executeRequest.Header, Content: json.RawMessage(statusContent), } conn.WriteJSON(statusMsg) }) defer server.Close() // Convert HTTP URL to WebSocket URL wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/api/kernels/test-kernel-id/channels" // Create executor client executor := NewExecutor(wsURL, nil) // Connect to WebSocket err := executor.Connect() if err != nil { t.Fatalf("failed to connect to WebSocket: %v", err) } defer executor.Disconnect() // Execute code in streaming mode resultChan := make(chan *ExecutionResult, 10) err = executor.ExecuteCodeStream("for i in range(3):\n print(f'Line {i}')", resultChan) if err != nil { t.Fatalf("failed to start streaming execution: %v", err) } // Receive and verify stream results resultCount := 0 for result := range resultChan { if result == nil { break } resultCount++ } // Should receive at least 4 results (3 stream outputs + 1 final result) if resultCount < 4 { t.Errorf("expected at least 4 results, got %d", resultCount) } } ================================================ FILE: components/execd/pkg/jupyter/execute/executor.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package execute // Executor is the interface for code execution type Executor struct { // Internal client client *Client // WebSocket URL wsURL string } // NewExecutor creates a new code executor func NewExecutor(wsURL string, httpClient HTTPClient) *Executor { client := NewClient("", httpClient) return &Executor{ client: client, wsURL: wsURL, } } // Connect connects to the kernel func (e *Executor) Connect() error { return e.client.Connect(e.wsURL) } // Disconnect disconnects from the kernel func (e *Executor) Disconnect() { e.client.Disconnect() } // ExecuteCodeStream executes code in streaming mode, sending results to the provided channel func (e *Executor) ExecuteCodeStream(code string, resultChan chan *ExecutionResult) error { return e.client.ExecuteCodeStream(code, resultChan) } // ExecuteCodeWithCallback executes code using callback functions func (e *Executor) ExecuteCodeWithCallback(code string, handler CallbackHandler) error { return e.client.ExecuteCodeWithCallback(code, handler) } ================================================ FILE: components/execd/pkg/jupyter/execute/types.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. // Package execute provides functionality for executing Jupyter kernel code via WebSocket package execute import ( "encoding/json" "fmt" "strings" "time" ) // MessageType represents Jupyter message types type MessageType string const ( // MsgExecuteRequest requests code execution MsgExecuteRequest MessageType = "execute_request" // MsgExecuteInput represents the input code MsgExecuteInput MessageType = "execute_input" // MsgExecuteResult represents execution results MsgExecuteResult MessageType = "execute_result" // MsgDisplayData represents data to be displayed MsgDisplayData MessageType = "display_data" // MsgStream represents stream output (stdout/stderr) MsgStream MessageType = "stream" // MsgError represents errors during execution MsgError MessageType = "error" // MsgStatus represents kernel status updates MsgStatus MessageType = "status" // MsgClearOutput represents clearing output MsgClearOutput MessageType = "clear_output" // MsgComm represents communication messages MsgComm MessageType = "comm" // MsgCommOpen represents opening communication MsgCommOpen MessageType = "comm_open" // MsgCommClose represents closing communication MsgCommClose MessageType = "comm_close" // MsgCommMsg representscommunication message content MsgCommMsg MessageType = "comm_msg" // MsgKernelInfo represents kernel information request MsgKernelInfo MessageType = "kernel_info_request" // MsgKernelInfoReply represents kernel information response MsgKernelInfoReply MessageType = "kernel_info_reply" MsgExecuteReply MessageType = "execute_reply" ) // StreamType representsoutput stream type type StreamType string const ( // StreamStdout represents standard output stream StreamStdout StreamType = "stdout" // StreamStderr representsstandard error stream StreamStderr StreamType = "stderr" ) // ExecutionState represents kernel execution state type ExecutionState string const ( // StateIdle representskernel is idle StateIdle ExecutionState = "idle" // StateBusy representskernel is busy StateBusy ExecutionState = "busy" // StateStarting representskernel is starting StateStarting ExecutionState = "starting" ) // Header defines Jupyter message header type Header struct { // MessageID is the unique identifier of the message MessageID string `json:"msg_id"` // Username is the username sending the message Username string `json:"username"` // Session is the session identifier Session string `json:"session"` // Date is the timestamp when the message was sent Date string `json:"date"` // MessageType is the type of the message MessageType string `json:"msg_type"` // Version is the version of the message protocol Version string `json:"version"` } // Message defines the basic structure of Jupyter messages type Message struct { // Header is the message header Header Header `json:"header"` // ParentHeader is the parent message header, used to track requests and responses ParentHeader Header `json:"parent_header"` // Metadata is the metadata related to the message Metadata map[string]interface{} `json:"metadata"` // Content is the actual content of the message Content json.RawMessage `json:"content"` // Buffers is the binary buffer Buffers [][]byte `json:"buffers"` // Channel is the channel of the message Channel string `json:"channel"` } // ExecuteRequest defines the request content for code execution type ExecuteRequest struct { // Code is the code to execute Code string `json:"code"` // Silent represents whether to execute in silent mode Silent bool `json:"silent"` // StoreHistory represents whether to store execution history StoreHistory bool `json:"store_history"` // UserExpressions contains expressions to evaluate in the execution context UserExpressions map[string]string `json:"user_expressions"` // AllowStdin represents whether to allow reading from standard input AllowStdin bool `json:"allow_stdin"` // StopOnError represents whether to stop execution when an error is encountered StopOnError bool `json:"stop_on_error"` } // StreamOutput represents stream output content type StreamOutput struct { // Name is the stream name (stdout or stderr) Name StreamType `json:"name"` // Text is the text content of the stream Text string `json:"text"` } // ExecuteResult represents the result of code execution type ExecuteResult struct { // ExecutionCount is the execution counter value ExecutionCount int `json:"execution_count"` // Data contains result data in different formats Data map[string]interface{} `json:"data"` // Metadata is the metadata related to the result Metadata map[string]interface{} `json:"metadata"` } type ExecuteReply struct { // ExecutionCount is the execution counter value ExecutionCount int `json:"execution_count"` Status string `json:"status"` ErrorOutput `json:",inline"` } // DisplayData representsdata to display type DisplayData struct { // Data contains display data in different formats Data map[string]interface{} `json:"data"` // Metadata is the metadata related to display data Metadata map[string]interface{} `json:"metadata"` } // ErrorOutput representserrors during execution type ErrorOutput struct { // EName is the name of the error EName string `json:"ename"` // EValue is the value of the error EValue string `json:"evalue"` // Traceback is the traceback of the error Traceback []string `json:"traceback"` } func (e *ErrorOutput) String() string { return fmt.Sprintf(` Error: %s Value: %s Traceback: %s `, e.EName, e.EValue, strings.Join(e.Traceback, "\n")) } // StatusUpdate represents kernel status update type StatusUpdate struct { // ExecutionState is the execution state of the kernel ExecutionState ExecutionState `json:"execution_state"` } // ExecutionResult represents the complete result of code execution type ExecutionResult struct { // Status represents the status of execution Status string `json:"status"` // ExecutionCount is the execution counter value ExecutionCount int `json:"execution_count"` // Stream contains all stream output Stream []*StreamOutput `json:"stream"` // Error contains errors during execution (if any) Error *ErrorOutput `json:"error"` // ExecutionTime is the total time of code execution ExecutionTime time.Duration `json:"execution_time"` // ExecutionData ExecutionData map[string]interface{} `json:"execution_data"` } // CallbackHandler defines callback functions for handling different types of messages type CallbackHandler struct { // OnExecuteResult handles execution result messages OnExecuteResult func(*ExecuteResult) // OnStream handles stream output messages OnStream func(...*StreamOutput) // OnDisplayData handles display data messages OnDisplayData func(*DisplayData) // OnError handles error messages OnError func(*ErrorOutput) // OnStatus handles status update messages OnStatus func(*StatusUpdate) } ================================================ FILE: components/execd/pkg/jupyter/execute/zz_generated.deepcopy.go ================================================ //go:build !ignore_autogenerated /* Copyright 2022. 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. */ // Code generated by controller-gen. DO NOT EDIT. package execute // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ErrorOutput) DeepCopyInto(out *ErrorOutput) { *out = *in if in.Traceback != nil { in, out := &in.Traceback, &out.Traceback *out = make([]string, len(*in)) copy(*out, *in) } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ErrorOutput. func (in *ErrorOutput) DeepCopy() *ErrorOutput { if in == nil { return nil } out := new(ErrorOutput) in.DeepCopyInto(out) return out } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ExecutionResult) DeepCopyInto(out *ExecutionResult) { *out = *in if in.Stream != nil { in, out := &in.Stream, &out.Stream *out = make([]*StreamOutput, len(*in)) for i := range *in { if (*in)[i] != nil { in, out := &(*in)[i], &(*out)[i] *out = new(StreamOutput) **out = **in } } } if in.Error != nil { in, out := &in.Error, &out.Error *out = new(ErrorOutput) (*in).DeepCopyInto(*out) } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExecutionResult. func (in *ExecutionResult) DeepCopy() *ExecutionResult { if in == nil { return nil } out := new(ExecutionResult) in.DeepCopyInto(out) return out } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *StreamOutput) DeepCopyInto(out *StreamOutput) { *out = *in } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StreamOutput. func (in *StreamOutput) DeepCopy() *StreamOutput { if in == nil { return nil } out := new(StreamOutput) in.DeepCopyInto(out) return out } ================================================ FILE: components/execd/pkg/jupyter/integration_test.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package jupyter import ( "encoding/json" "github.com/alibaba/opensandbox/execd/pkg/jupyter/execute" "net/http" "net/http/httptest" "strings" "testing" "github.com/gorilla/websocket" ) // Test integration flow: authentication -> get kernel specs -> create session -> execute code -> close session func TestIntegrationFlow(t *testing.T) { // Create mock HTTP server httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Handle authentication validation request if r.URL.Path == "/api/status" { // Check authentication token auth := r.Header.Get("Authorization") if auth != "token test-token" { w.WriteHeader(http.StatusUnauthorized) return } // Return status information w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"status": "ok"}`)) return } // Handle kernel specs request if r.URL.Path == "/api/kernelspecs" { // Return kernel specs w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{ "default": "python3", "kernelspecs": { "python3": { "name": "python3", "display_name": "Python 3", "language": "python" } } }`)) return } // Handle session-related requests if r.URL.Path == "/api/sessions" { if r.Method == http.MethodGet { // List sessions w.Header().Set("Content-Type", "application/json") w.Write([]byte(`[{ "id": "test-session-id", "path": "/path/to/notebook.ipynb", "name": "Test Session", "type": "notebook", "kernel": { "id": "test-kernel-id", "name": "python3" } }]`)) return } else if r.Method == http.MethodPost { // Create session w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) w.Write([]byte(`{ "id": "test-session-id", "path": "/path/to/notebook.ipynb", "name": "Test Session", "type": "notebook", "kernel": { "id": "test-kernel-id", "name": "python3" } }`)) return } } // Handle specific session requests if strings.HasPrefix(r.URL.Path, "/api/sessions/test-session-id") { if r.Method == http.MethodDelete { // Delete session w.WriteHeader(http.StatusNoContent) return } else if r.Method == http.MethodPatch { // Modify session w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{ "id": "test-session-id", "path": "/path/to/updated-notebook.ipynb", "name": "Updated Test Session", "type": "notebook", "kernel": { "id": "test-kernel-id", "name": "python3" } }`)) return } else if r.Method == http.MethodGet { // Get session w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{ "id": "test-session-id", "path": "/path/to/notebook.ipynb", "name": "Test Session", "type": "notebook", "kernel": { "id": "test-kernel-id", "name": "python3" } }`)) return } } // Handle kernel requests if r.URL.Path == "/api/kernels" { if r.Method == http.MethodGet { // List kernels w.Header().Set("Content-Type", "application/json") w.Write([]byte(`[{ "id": "test-kernel-id", "name": "python3", "execution_state": "idle" }]`)) return } } // Handle specific kernel requests if strings.HasPrefix(r.URL.Path, "/api/kernels/test-kernel-id") { if r.Method == http.MethodGet { // Get kernel w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{ "id": "test-kernel-id", "name": "python3", "execution_state": "idle" }`)) return } else if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/restart") { // Restart kernel w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{ "id": "test-kernel-id", "name": "python3", "restarted": true }`)) return } } // If it's a WebSocket connection request, upgrade to WebSocket if strings.HasSuffix(r.URL.Path, "/channels") { // Return 404, as WebSocket connections will be handled by a dedicated WebSocket server w.WriteHeader(http.StatusNotFound) return } // For other requests, return 404 w.WriteHeader(http.StatusNotFound) })) defer httpServer.Close() // Create mock WebSocket server for code execution wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !strings.HasSuffix(r.URL.Path, "/channels") { w.WriteHeader(http.StatusNotFound) return } // Upgrade HTTP connection to WebSocket upgrader := websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true }, } conn, err := upgrader.Upgrade(w, r, nil) if err != nil { t.Fatalf("Failed to upgrade connection to WebSocket: %v", err) } defer conn.Close() // Continuously handle WebSocket messages for { // Read request message var msg execute.Message err := conn.ReadJSON(&msg) if err != nil { break } // If it's an execute request, send mock response if msg.Header.MessageType == string(execute.MsgExecuteRequest) { // Send stream output streamContent, _ := json.Marshal(execute.StreamOutput{ Name: execute.StreamStdout, Text: "Hello from test WebSocket!\n", }) streamMsg := execute.Message{ Header: execute.Header{ MessageID: "stream-msg-id", Session: msg.Header.Session, MessageType: string(execute.MsgStream), }, ParentHeader: msg.Header, Content: json.RawMessage(streamContent), } conn.WriteJSON(streamMsg) // Send execution result resultContent, _ := json.Marshal(execute.ExecuteResult{ ExecutionCount: 1, Data: map[string]interface{}{ "text/plain": "Integration test result", }, Metadata: map[string]interface{}{}, }) executeResultMsg := execute.Message{ Header: execute.Header{ MessageID: "result-msg-id", Session: msg.Header.Session, MessageType: string(execute.MsgExecuteResult), }, ParentHeader: msg.Header, Content: json.RawMessage(resultContent), } conn.WriteJSON(executeResultMsg) // Send status message statusContent, _ := json.Marshal(execute.StatusUpdate{ ExecutionState: execute.StateIdle, }) statusMsg := execute.Message{ Header: execute.Header{ MessageID: "status-msg-id", Session: msg.Header.Session, MessageType: string(execute.MsgStatus), }, ParentHeader: msg.Header, Content: json.RawMessage(statusContent), } conn.WriteJSON(statusMsg) } } })) defer wsServer.Close() // Create Jupyter client client := NewClient(httpServer.URL) client.SetToken("test-token") // Test 1: Validate authentication status, err := client.ValidateAuth() if err != nil { t.Fatalf("Authentication validation failed: %v", err) } if status != "ok" { t.Errorf("Authentication status incorrect, expected 'ok', got '%s'", status) } // Test 2: Get kernel specs specs, err := client.GetKernelSpecs() if err != nil { t.Fatalf("Failed to get kernel specs: %v", err) } if specs.Default != "python3" { t.Errorf("Default kernel incorrect, expected 'python3', got '%s'", specs.Default) } if len(specs.Kernelspecs) != 1 { t.Errorf("Kernel count incorrect, expected 1, got %d", len(specs.Kernelspecs)) } // Test 3: Create session session, err := client.CreateSession("Test Session", "/path/to/notebook.ipynb", "python3") if err != nil { t.Fatalf("Failed to create session: %v", err) } if session.ID != "test-session-id" { t.Errorf("Session ID incorrect, expected 'test-session-id', got '%s'", session.ID) } if session.Kernel.ID != "test-kernel-id" { t.Errorf("Kernel ID incorrect, expected 'test-kernel-id', got '%s'", session.Kernel.ID) } // Modify WebSocket URL to point to WebSocket test server wsURL := "ws" + strings.TrimPrefix(wsServer.URL, "http") + "/api/kernels/test-kernel-id/channels" // Test 4: Connect to kernel and execute code executor := execute.NewExecutor(wsURL, nil) err = executor.Connect() if err != nil { t.Fatalf("Failed to connect to kernel: %v", err) } defer executor.Disconnect() // Execute code err = executor.ExecuteCodeWithCallback("print('Hello from integration test!')", execute.CallbackHandler{}) if err != nil { t.Fatalf("Failed to execute code: %v", err) } // Test 5: Delete session err = client.DeleteSession(session.ID) if err != nil { t.Fatalf("Failed to delete session: %v", err) } } ================================================ FILE: components/execd/pkg/jupyter/kernel/kernel.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. // Package kernel provides functionality for managing Jupyter kernels package kernel import ( "bytes" "encoding/json" "fmt" "io" "net/http" ) // Client is the client for kernel management type Client struct { // baseURL is the base URL of the Jupyter server baseURL string // httpClient is the client for sending HTTP requests, with authentication support httpClient *http.Client } // NewClient creates a new kernel management client func NewClient(baseURL string, httpClient *http.Client) *Client { return &Client{ baseURL: baseURL, httpClient: httpClient, } } // GetKernelSpecs retrieves the list of available kernel specifications func (c *Client) GetKernelSpecs() (*KernelSpecs, error) { // Build request URL url := fmt.Sprintf("%s/api/kernelspecs", c.baseURL) // Send GET request resp, err := c.httpClient.Get(url) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } defer resp.Body.Close() // Check response status if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("server returned error status code: %d", resp.StatusCode) } // Read response body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response: %w", err) } // Parse JSON response var specs KernelSpecs if err := json.Unmarshal(body, &specs); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) } return &specs, nil } // ListKernels retrieves the list of all running kernels func (c *Client) ListKernels() ([]*Kernel, error) { // Build request URL url := fmt.Sprintf("%s/api/kernels", c.baseURL) // Send GET request resp, err := c.httpClient.Get(url) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } defer resp.Body.Close() // Check response status if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("server returned error status code: %d", resp.StatusCode) } // Read response body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response: %w", err) } // Parse JSON response var kernels []*Kernel if err := json.Unmarshal(body, &kernels); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) } return kernels, nil } // GetKernel retrieves information about a specific kernel func (c *Client) GetKernel(kernelId string) (*Kernel, error) { // Build request URL url := fmt.Sprintf("%s/api/kernels/%s", c.baseURL, kernelId) // Send GET request resp, err := c.httpClient.Get(url) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } defer resp.Body.Close() // Check response status if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("server returned error status code: %d", resp.StatusCode) } // Read response body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response: %w", err) } // Parse JSON response var kernel Kernel if err := json.Unmarshal(body, &kernel); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) } return &kernel, nil } // StartKernel starts a new kernel func (c *Client) StartKernel(name string) (*Kernel, error) { // Build request URL url := fmt.Sprintf("%s/api/kernels", c.baseURL) // Build request body reqBody := &KernelStartRequest{ Name: name, } // Serialize request body to JSON jsonData, err := json.Marshal(reqBody) if err != nil { return nil, fmt.Errorf("failed to serialize request: %w", err) } // Create POST request req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonData)) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") // Send request resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } defer resp.Body.Close() // Check response status if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("server returned error status code: %d", resp.StatusCode) } // Read response body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response: %w", err) } // Parse JSON response var kernel Kernel if err := json.Unmarshal(body, &kernel); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) } return &kernel, nil } // RestartKernel restarts the specified kernel func (c *Client) RestartKernel(kernelId string) (bool, error) { // Build request URL url := fmt.Sprintf("%s/api/kernels/%s/restart", c.baseURL, kernelId) // Create POST request req, err := http.NewRequest(http.MethodPost, url, nil) if err != nil { return false, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") // Send request resp, err := c.httpClient.Do(req) if err != nil { return false, fmt.Errorf("failed to send request: %w", err) } defer resp.Body.Close() // Check response status if resp.StatusCode != http.StatusOK { return false, fmt.Errorf("server returned error status code: %d", resp.StatusCode) } // Read response body, err := io.ReadAll(resp.Body) if err != nil { return false, fmt.Errorf("failed to read response: %w", err) } // Parse JSON response var response KernelRestartResponse if err := json.Unmarshal(body, &response); err != nil { return false, fmt.Errorf("failed to parse response: %w", err) } return response.Restarted, nil } // InterruptKernel interrupts the specified kernel func (c *Client) InterruptKernel(kernelId string) error { // Build request URL url := fmt.Sprintf("%s/api/kernels/%s/interrupt", c.baseURL, kernelId) // Create POST request req, err := http.NewRequest(http.MethodPost, url, nil) if err != nil { return fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") // Send request resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } defer resp.Body.Close() // Check response status if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { return fmt.Errorf("server returned error status code: %d", resp.StatusCode) } return nil } // ShutdownKernel shuts down the specified kernel func (c *Client) ShutdownKernel(kernelId string, restart bool) error { // Build request URL url := fmt.Sprintf("%s/api/kernels/%s", c.baseURL, kernelId) // Build request body reqBody := &KernelShutdownRequest{ Restart: restart, } // Serialize request body to JSON jsonData, err := json.Marshal(reqBody) if err != nil { return fmt.Errorf("failed to serialize request: %w", err) } // Create DELETE request req, err := http.NewRequest(http.MethodDelete, url, bytes.NewBuffer(jsonData)) if err != nil { return fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") // Send request resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } defer resp.Body.Close() // Check response status if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { return fmt.Errorf("server returned error status code: %d", resp.StatusCode) } return nil } ================================================ FILE: components/execd/pkg/jupyter/kernel/kernelspecs.json ================================================ { "default" : "python3", "kernelspecs" : { "python3" : { "name" : "python3", "spec" : { "argv" : [ "/opt/conda/bin/python", "-m", "ipykernel_launcher", "-f", "{connection_file}" ], "env" : { }, "display_name" : "Python 3 (ipykernel)", "language" : "python", "interrupt_mode" : "signal", "metadata" : { "debugger" : true } }, "resources" : { "logo-svg" : "/kernelspecs/python3/logo-svg.svg", "logo-64x64" : "/kernelspecs/python3/logo-64x64.png", "logo-32x32" : "/kernelspecs/python3/logo-32x32.png" } } } } ================================================ FILE: components/execd/pkg/jupyter/kernel/types.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. // Package kernel provides functionality for managing Jupyter kernels package kernel import ( "time" ) // KernelSpecs contains available kernel specification information type KernelSpecs struct { // Default is the name of the default kernel Default string `json:"default"` // Kernelspecs is a mapping from kernel names to kernel specifications Kernelspecs map[string]*KernelSpecInfo `json:"kernelspecs"` } // KernelSpecInfo contains detailed kernel specification information type KernelSpecInfo struct { // Name is the name of the kernel Name string `json:"name"` Spec KernelSpecDetail `json:"spec"` // Resources contains resource paths related to the kernel Resources map[string]string `json:"resources,omitempty"` } type KernelSpecDetail struct { Argv []string `json:"argv,omitempty"` // DisplayName is the display name of the kernel DisplayName string `json:"display_name"` // Language is the programming language used by the kernel Language string `json:"language,omitempty"` // InterruptMode is the interrupt mode of the kernel InterruptMode string `json:"interrupt_mode,omitempty"` } // Kernel represents a running kernel instance type Kernel struct { // ID is the unique identifier of the kernel ID string `json:"id"` // Name is the name of the kernel Name string `json:"name"` // LastActivity is the timestamp of the kernel's last activity LastActivity time.Time `json:"last_activity,omitempty"` // Connections is the number of clients currently connected to the kernel Connections int `json:"connections,omitempty"` // ExecutionState is the execution state of the kernel (e.g., idle, busy) ExecutionState string `json:"execution_state,omitempty"` } // KernelStartRequest is the request for starting a new kernel type KernelStartRequest struct { // Name is the name of the kernel to start Name string `json:"name"` // Path is the optional path for the kernel Path string `json:"path,omitempty"` } // KernelRestartResponse representsresponse of kernel restart type KernelRestartResponse struct { // ID is the ID of the restarted kernel ID string `json:"id"` // Name is the restarted kernel name Name string `json:"name"` // Restarted represents whether the kernel was successfully restarted Restarted bool `json:"restarted"` // LastActivity is the timestamp of the kernel's last activity LastActivity time.Time `json:"last_activity,omitempty"` } // KernelInterruptRequest request to interrupt a kernel type KernelInterruptRequest struct { // Restart represents whether to restart the kernel after interruption Restart bool `json:"restart,omitempty"` } // KernelShutdownRequest request to close a kernel type KernelShutdownRequest struct { // Restart representswhether torestart kernel after shutdown Restart bool `json:"restart"` } // KernelStatus represents the status of the kernel type KernelStatus string const ( // KernelStatusIdle representskernel is idle KernelStatusIdle KernelStatus = "idle" // KernelStatusBusy representskernel is busy KernelStatusBusy KernelStatus = "busy" // KernelStatusStarting representskernel is starting KernelStatusStarting KernelStatus = "starting" // KernelStatusRestarting represents the kernel is restarting KernelStatusRestarting KernelStatus = "restarting" // KernelStatusDead represents the kernel is dead KernelStatusDead KernelStatus = "dead" ) ================================================ FILE: components/execd/pkg/jupyter/live_integration_test.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package jupyter import ( "fmt" "net/http" "os" "testing" "time" "github.com/alibaba/opensandbox/execd/pkg/jupyter/execute" ) // authTransport is a custom transport layer for adding authentication headers type authTransport struct { token string base http.RoundTripper } // RoundTrip implements the http.RoundTripper interface, adding authentication headers to each request func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) { // Clone the request to avoid modifying the original request reqClone := req.Clone(req.Context()) // Add authentication header reqClone.Header.Set("Authorization", "Token "+t.token) // Send the request using the base transport layer return t.base.RoundTrip(reqClone) } // TestLiveServerIntegration tests SDK integration with a real Jupyter server func TestLiveServerIntegration(t *testing.T) { t.Skip() // Get configuration from environment variables, use default values if not set jupyterURL := getEnv("JUPYTER_URL", "") jupyterToken := getEnv("JUPYTER_TOKEN", "") if jupyterURL == "" || jupyterToken == "" { t.Skip("JUPYTER_URL and JUPYTER_TOKEN environment variables must be set to run this test") } // Output test information t.Logf("Connecting to Jupyter server: %s", jupyterURL) // Create HTTP client with authentication capability httpClient := &http.Client{ Transport: &authTransport{ token: jupyterToken, base: http.DefaultTransport, }, } // Create client and set authentication client := NewClient(jupyterURL, WithToken(jupyterToken), // Keep Token setting to support ValidateAuth and WebSocket connections WithHTTPClient(httpClient)) // Test 1: Validate authentication t.Run("Validate Authentication", func(t *testing.T) { status, err := client.ValidateAuth() if err != nil { t.Fatalf("Authentication validation failed: %v", err) } if status != "ok" { t.Errorf("Authentication status incorrect, expected 'ok', got '%s'", status) } t.Logf("Authentication validation successful! Status: %s", status) }) // Test 2: Get kernel specs var kernelName string t.Run("Get Kernel Specs", func(t *testing.T) { specs, err := client.GetKernelSpecs() if err != nil { t.Fatalf("Failed to get kernel specs: %v", err) } if specs.Default == "" { t.Errorf("No default kernel") } if len(specs.Kernelspecs) == 0 { t.Errorf("No available kernels") } // Use default kernel or Python kernel (if available) kernelName = specs.Default for name, spec := range specs.Kernelspecs { if spec.Spec.Language == "python" { kernelName = name break } } t.Logf("Get kernel specs successful! Default kernel: %s, Selected kernel: %s", specs.Default, kernelName) t.Logf("Available kernels: %v", specs.Kernelspecs) }) // Test 3: List sessions t.Run("List Sessions", func(t *testing.T) { sessions, err := client.ListSessions() if err != nil { t.Fatalf("Failed to list sessions: %v", err) } t.Logf("List sessions successful! Number of existing sessions: %d", len(sessions)) for i, s := range sessions { t.Logf("Session %d: ID=%s, Path=%s, Kernel=%s", i+1, s.ID, s.Path, s.Kernel.Name) } }) // Test 4: Create new session var sessionID string t.Run("Create Session", func(t *testing.T) { // Generate unique name for test session sessionName := fmt.Sprintf("test-session-%d", time.Now().Unix()) sessionPath := "/test-notebook.ipynb" session, err := client.CreateSession(sessionName, sessionPath, kernelName) if err != nil { t.Fatalf("Failed to create session: %v", err) } if session.ID == "" { t.Errorf("Created session has no ID") } if session.Kernel.ID == "" { t.Errorf("Created session has no kernel ID") } // Save session ID for subsequent tests sessionID = session.ID t.Logf("Create session successful! Session ID: %s, Kernel ID: %s", session.ID, session.Kernel.ID) }) // Test 5: Get created session var kernelID string t.Run("Get Session", func(t *testing.T) { if sessionID == "" { t.Skip("No session ID, skipping test") } session, err := client.GetSession(sessionID) if err != nil { t.Fatalf("Failed to get session: %v", err) } if session.ID != sessionID { t.Errorf("Session ID mismatch, expected '%s', got '%s'", sessionID, session.ID) } // Save kernel ID for subsequent tests kernelID = session.Kernel.ID t.Logf("Get session successful! Session name: %s, Kernel name: %s", session.Name, session.Kernel.Name) }) // Test 6: List all kernels t.Run("List Kernels", func(t *testing.T) { kernels, err := client.ListKernels() if err != nil { t.Fatalf("Failed to list kernels: %v", err) } t.Logf("List kernels successful! Number of kernels: %d", len(kernels)) for i, k := range kernels { t.Logf("Kernel %d: ID=%s, Name=%s, State=%s", i+1, k.ID, k.Name, k.ExecutionState) } // Verify that the created kernel is in the list if kernelID != "" { found := false for _, k := range kernels { if k.ID == kernelID { found = true break } } if !found { t.Errorf("Cannot find created kernel in kernel list ID=%s", kernelID) } } }) // Test 7: Connect to kernel and execute code t.Run("Execute Code", func(t *testing.T) { if kernelID == "" { t.Skip("No kernel ID, skipping test") } // Connect to kernel err := client.ConnectToKernel(kernelID) if err != nil { t.Fatalf("Failed to connect to kernel: %v", err) } defer client.DisconnectFromKernel(kernelID) // Execute simple code code := "print('Hello, Jupyter!')\nresult = 2 + 2\nresult" t.Logf("Executing code:\n%s", code) err = client.ExecuteCodeWithCallback(code, execute.CallbackHandler{}) if err != nil { t.Fatalf("Failed to execute code: %v", err) } }) // Test 7: Connect to kernel and execute code t.Run("Execute Code", func(t *testing.T) { if kernelID == "" { t.Skip("No kernel ID, skipping test") } // Connect to kernel err := client.ConnectToKernel(kernelID) if err != nil { t.Fatalf("Failed to connect to kernel: %v", err) } defer client.DisconnectFromKernel(kernelID) // Execute simple code code := "print(f'2 + 2 = {result}')\nresult" t.Logf("Executing code:\n%s", code) err = client.ExecuteCodeWithCallback(code, execute.CallbackHandler{}) if err != nil { t.Fatalf("Failed to execute code: %v", err) } }) // Test 8: Execute complex code with different types of output t.Run("Execute Complex Code", func(t *testing.T) { if kernelID == "" { t.Skip("No kernel ID, skipping test") } // Connect to kernel err := client.ConnectToKernel(kernelID) if err != nil { t.Fatalf("Failed to connect to kernel: %v", err) } defer client.DisconnectFromKernel(kernelID) // Execute code that generates multiple output types code := ` # Display table data import pandas as pd import numpy as np try: df = pd.DataFrame({ 'A': np.random.rand(5), 'B': np.random.rand(5) }) display(df) print("DataFrame created successfully") except Exception as e: print(f"Error creating DataFrame: {e}") # Generate error try: print(undefined_variable) except Exception as e: print(f"Expected error: {e}") # Return dictionary {'hello': 'world', 'number': 42} ` t.Logf("Executing complex code...") err = client.ExecuteCodeWithCallback(code, execute.CallbackHandler{}) if err != nil { t.Fatalf("Failed to execute complex code: %v", err) } }) // Test 9: Restart kernel t.Run("Restart Kernel", func(t *testing.T) { if kernelID == "" { t.Skip("No kernel ID, skipping test") } // Restart kernel restarted, err := client.RestartKernel(kernelID) if err != nil { t.Fatalf("Failed to restart kernel: %v", err) } // Wait for kernel restart to complete time.Sleep(2 * time.Second) // Verify kernel state kernel, err := client.GetKernel(kernelID) if err != nil { t.Fatalf("Failed to get kernel: %v", err) } t.Logf("Restart kernel successful! Restart status: %v, Kernel state: %s", restarted, kernel.ExecutionState) }) // Test 10: Close session t.Run("Close Session", func(t *testing.T) { if sessionID == "" { t.Skip("No session ID, skipping test") } // Delete session err := client.DeleteSession(sessionID) if err != nil { t.Fatalf("Failed to delete session: %v", err) } // Verify session is deleted sessions, err := client.ListSessions() if err != nil { t.Fatalf("Failed to list sessions: %v", err) } for _, s := range sessions { if s.ID == sessionID { t.Errorf("Session still exists, not properly deleted ID=%s", sessionID) break } } t.Logf("Close session successful!") }) } // Helper function: Get environment variable, use default value if not exists func getEnv(key, defaultValue string) string { value := os.Getenv(key) if value == "" { return defaultValue } return value } // Helper function: Truncate string func truncateString(s string, maxLen int) string { if len(s) <= maxLen { return s } return s[:maxLen] + "..." } // Helper function: Get all keys from map func getKeys(m map[string]interface{}) []string { keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } return keys } ================================================ FILE: components/execd/pkg/jupyter/session/session.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. // Package session provides functionality for managing Jupyter sessions package session import ( "bytes" "encoding/json" "fmt" "io" "net/http" ) // Client is the client for session management type Client struct { // baseURL is the base URL of the Jupyter server baseURL string // httpClient is the client for sending HTTP requests, with authentication support httpClient *http.Client } // NewClient creates a new session management client func NewClient(baseURL string, httpClient *http.Client) *Client { return &Client{ baseURL: baseURL, httpClient: httpClient, } } // ListSessions retrieves the list of all active sessions func (c *Client) ListSessions() ([]*Session, error) { // Build request URL url := fmt.Sprintf("%s/api/sessions", c.baseURL) // Send GET request resp, err := c.httpClient.Get(url) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } defer resp.Body.Close() // Check response status if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("server returned error status code: %d", resp.StatusCode) } // Read response body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response: %w", err) } // Parse JSON response var sessions []*Session if err := json.Unmarshal(body, &sessions); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) } return sessions, nil } // GetSession retrieves information about a specific session func (c *Client) GetSession(sessionId string) (*Session, error) { // Build request URL url := fmt.Sprintf("%s/api/sessions/%s", c.baseURL, sessionId) // Send GET request resp, err := c.httpClient.Get(url) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } defer resp.Body.Close() // Check response status if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("server returned error status code: %d", resp.StatusCode) } // Read response body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response: %w", err) } // Parse JSON response var session Session if err := json.Unmarshal(body, &session); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) } return &session, nil } // CreateSession creates a new session func (c *Client) CreateSession(name, ipynb, kernel string) (*Session, error) { // Build request URL url := fmt.Sprintf("%s/api/sessions", c.baseURL) // Build request body reqBody := &SessionCreateRequest{ Path: ipynb, Name: name, Type: DefaultSessionType, Kernel: &KernelSpec{ Name: kernel, }, } // Serialize request body to JSON jsonData, err := json.Marshal(reqBody) if err != nil { return nil, fmt.Errorf("failed to serialize request: %w", err) } // Create POST request req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonData)) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") // Send request resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } defer resp.Body.Close() // Check response status if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("server returned error status code: %d", resp.StatusCode) } // Read response body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response: %w", err) } // Parse JSON response var session Session if err := json.Unmarshal(body, &session); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) } return &session, nil } // ModifySession modifies properties of an existing session func (c *Client) ModifySession(sessionId, name, path, kernel string) (*Session, error) { // Build request URL url := fmt.Sprintf("%s/api/sessions/%s", c.baseURL, sessionId) // Build request body reqBody := &SessionUpdateRequest{} if name != "" { reqBody.Name = name } if path != "" { reqBody.Path = path } if kernel != "" { reqBody.Kernel = &KernelSpec{ Name: kernel, } } // Serialize request body to JSON jsonData, err := json.Marshal(reqBody) if err != nil { return nil, fmt.Errorf("failed to serialize request: %w", err) } // Create PATCH request req, err := http.NewRequest(http.MethodPatch, url, bytes.NewBuffer(jsonData)) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") // Send request resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } defer resp.Body.Close() // Check response status if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("server returned error status code: %d", resp.StatusCode) } // Read response body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response: %w", err) } // Parse JSON response var session Session if err := json.Unmarshal(body, &session); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) } return &session, nil } // DeleteSession deletes the specified session func (c *Client) DeleteSession(sessionId string) error { // Build request URL url := fmt.Sprintf("%s/api/sessions/%s", c.baseURL, sessionId) // Create DELETE request req, err := http.NewRequest(http.MethodDelete, url, nil) if err != nil { return fmt.Errorf("failed to create request: %w", err) } // Send request resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } defer resp.Body.Close() // Check response status if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { return fmt.Errorf("server returned error status code: %d", resp.StatusCode) } return nil } // CreateSessionWithOptions usingoption to create a new session func (c *Client) CreateSessionWithOptions(options *SessionOptions) (*Session, error) { // Build request URL url := fmt.Sprintf("%s/api/sessions", c.baseURL) // Build request body reqBody := &SessionCreateRequest{ Path: options.Path, Name: options.Name, } // set session type if options.Type != "" { reqBody.Type = options.Type } else { reqBody.Type = DefaultSessionType } // set kernel information if options.KernelID != "" { // If kernel ID is provided, use existing kernel reqBody.Kernel = &KernelSpec{ ID: options.KernelID, } } else if options.KernelName != "" { // If kernel name is provided, start new kernel reqBody.Kernel = &KernelSpec{ Name: options.KernelName, } } // Serialize request body to JSON jsonData, err := json.Marshal(reqBody) if err != nil { return nil, fmt.Errorf("failed to serialize request: %w", err) } // Create POST request req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonData)) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") // Send request resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } defer resp.Body.Close() // Check response status if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("server returned error status code: %d", resp.StatusCode) } // Read response body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response: %w", err) } // Parse JSON response var session Session if err := json.Unmarshal(body, &session); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) } return &session, nil } ================================================ FILE: components/execd/pkg/jupyter/session/session_test.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package session import ( "encoding/json" "net/http" "net/http/httptest" "testing" ) // Test listing sessions func TestListSessions(t *testing.T) { // Create mock server server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Verify request method and path if r.Method != http.MethodGet { t.Errorf("expected request method GET, got %s", r.Method) } if r.URL.Path != "/api/sessions" { t.Errorf("expected request path /api/sessions, got %s", r.URL.Path) } // Return mocked session list response := `[ { "id": "session-1", "path": "/path/to/notebook1.ipynb", "name": "Session 1", "type": "notebook", "kernel": { "id": "kernel-1", "name": "python3", "last_activity": "2023-01-01T00:00:00Z", "execution_state": "idle", "connections": 1 } }, { "id": "session-2", "path": "/path/to/notebook2.ipynb", "name": "Session 2", "type": "notebook", "kernel": { "id": "kernel-2", "name": "python3", "last_activity": "2023-01-01T00:00:00Z", "execution_state": "idle", "connections": 1 } } ]` w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) w.Write([]byte(response)) })) defer server.Close() // Create client client := NewClient(server.URL, &http.Client{}) // Fetch session list sessions, err := client.ListSessions() if err != nil { t.Fatalf("failed to list sessions: %v", err) } // Validate session count if len(sessions) != 2 { t.Errorf("expected 2 sessions, got %d", len(sessions)) } // Validate first session fields if sessions[0].ID != "session-1" { t.Errorf("expected session ID 'session-1', got '%s'", sessions[0].ID) } if sessions[0].Name != "Session 1" { t.Errorf("expected session name 'Session 1', got '%s'", sessions[0].Name) } if sessions[0].Path != "/path/to/notebook1.ipynb" { t.Errorf("expected session path '/path/to/notebook1.ipynb', got '%s'", sessions[0].Path) } if sessions[0].Type != "notebook" { t.Errorf("expected session type 'notebook', got '%s'", sessions[0].Type) } // Validate first session kernel fields if sessions[0].Kernel.ID != "kernel-1" { t.Errorf("expected kernel ID 'kernel-1', got '%s'", sessions[0].Kernel.ID) } if sessions[0].Kernel.Name != "python3" { t.Errorf("expected kernel name 'python3', got '%s'", sessions[0].Kernel.Name) } } // Test creating session func TestCreateSession(t *testing.T) { // Create mock server server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Verify request method and path if r.Method != http.MethodPost { t.Errorf("expected request method POST, got %s", r.Method) } if r.URL.Path != "/api/sessions" { t.Errorf("expected request path /api/sessions, got %s", r.URL.Path) } // Parse request body var requestBody SessionCreateRequest decoder := json.NewDecoder(r.Body) if err := decoder.Decode(&requestBody); err != nil { t.Fatalf("failed to decode request body: %v", err) } // Validate request params if requestBody.Name != "Test Session" { t.Errorf("expected session name 'Test Session', got '%s'", requestBody.Name) } if requestBody.Path != "/path/to/notebook.ipynb" { t.Errorf("expected session path '/path/to/notebook.ipynb', got '%s'", requestBody.Path) } if requestBody.Type != "notebook" { t.Errorf("expected session type 'notebook', got '%s'", requestBody.Type) } if requestBody.Kernel.Name != "python3" { t.Errorf("expected kernel name 'python3', got '%s'", requestBody.Kernel.Name) } // Return mocked create response response := `{ "id": "new-session-id", "path": "/path/to/notebook.ipynb", "name": "Test Session", "type": "notebook", "kernel": { "id": "new-kernel-id", "name": "python3", "last_activity": "2023-01-01T00:00:00Z", "execution_state": "idle", "connections": 0 } }` w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) w.Write([]byte(response)) })) defer server.Close() // Create client client := NewClient(server.URL, &http.Client{}) // Create session newSession, err := client.CreateSession("Test Session", "/path/to/notebook.ipynb", "python3") if err != nil { t.Fatalf("failed to create session: %v", err) } // Validate created session if newSession.ID != "new-session-id" { t.Errorf("expected session ID 'new-session-id', got '%s'", newSession.ID) } if newSession.Name != "Test Session" { t.Errorf("expected session name 'Test Session', got '%s'", newSession.Name) } if newSession.Path != "/path/to/notebook.ipynb" { t.Errorf("expected session path '/path/to/notebook.ipynb', got '%s'", newSession.Path) } if newSession.Kernel.ID != "new-kernel-id" { t.Errorf("expected kernel ID 'new-kernel-id', got '%s'", newSession.Kernel.ID) } } // Test fetching a specific session func TestGetSession(t *testing.T) { sessionID := "test-session-id" // Create mock server server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Verify request method and path if r.Method != http.MethodGet { t.Errorf("expected request method GET, got %s", r.Method) } expectedPath := "/api/sessions/" + sessionID if r.URL.Path != expectedPath { t.Errorf("expected request path '%s', got '%s'", expectedPath, r.URL.Path) } // Return mocked session response := `{ "id": "test-session-id", "path": "/path/to/notebook.ipynb", "name": "Test Session", "type": "notebook", "kernel": { "id": "test-kernel-id", "name": "python3", "last_activity": "2023-01-01T00:00:00Z", "execution_state": "idle", "connections": 1 } }` w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) w.Write([]byte(response)) })) defer server.Close() // Create client client := NewClient(server.URL, &http.Client{}) // Fetch session session, err := client.GetSession(sessionID) if err != nil { t.Fatalf("failed to get session: %v", err) } // Validate session if session.ID != sessionID { t.Errorf("expected session ID '%s', got '%s'", sessionID, session.ID) } if session.Name != "Test Session" { t.Errorf("expected session name 'Test Session', got '%s'", session.Name) } if session.Kernel.ID != "test-kernel-id" { t.Errorf("expected kernel ID 'test-kernel-id', got '%s'", session.Kernel.ID) } } ================================================ FILE: components/execd/pkg/jupyter/session/sessions.json ================================================ [ { "id" : "cb1baca9-a60e-4937-a1d0-18bc1fc45e60", "path" : "my_notebook.ipynb", "name" : "my_session", "type" : "notebook", "kernel" : { "id" : "d7052326-5c98-4575-bb18-a7902ef5f623", "name" : "python3", "last_activity" : "2025-06-05T09:09:54.420827Z", "execution_state" : "idle", "connections" : 0 }, "notebook" : { "path" : "my_notebook.ipynb", "name" : "my_session" } }, { "id" : "a3378ca1-ba62-4341-9db5-3bc612fb3517", "path" : "Untitled.ipynb", "name" : "Untitled.ipynb", "type" : "notebook", "kernel" : { "id" : "d7052326-5c98-4575-bb18-a7902ef5f623", "name" : "python3", "last_activity" : "2025-06-05T09:09:54.420827Z", "execution_state" : "idle", "connections" : 0 }, "notebook" : { "path" : "Untitled.ipynb", "name" : "Untitled.ipynb" } }, { "id" : "c4829f29-8430-4dce-b1f5-9d2ac6c4f570", "path" : "/tmp/example_notebook.ipynb", "name" : "example_session", "type" : "notebook", "kernel" : { "id" : "00349e07-3877-4eb0-a676-0df5b886d770", "name" : "python3", "last_activity" : "2025-06-05T11:51:22.194821Z", "execution_state" : "starting", "connections" : 0 }, "notebook" : { "path" : "/tmp/example_notebook.ipynb", "name" : "example_session" } }, { "id" : "9a8e1857-b737-41a6-8f81-6039f6ae0ac1", "path" : "e0ebd37c-578a-443c-8f58-236984aea7ff", "name" : "session_5c4e8183-9e8a-4879-93b2-5622518193d7", "type" : "notebook", "kernel" : { "id" : "e8792c3e-3190-4b11-92e8-b7ec9ef44da9", "name" : "python3", "last_activity" : "2025-06-05T12:26:01.610210Z", "execution_state" : "starting", "connections" : 0 }, "notebook" : { "path" : "e0ebd37c-578a-443c-8f58-236984aea7ff", "name" : "session_5c4e8183-9e8a-4879-93b2-5622518193d7" } }, { "id" : "cc06c06d-4f6b-45a5-a546-11a5b5f246f8", "path" : "notebook.ipynb", "name" : null, "type" : "notebook", "kernel" : { "id" : "62e7fd9e-ea50-4045-861b-3a5a7073ee22", "name" : "python3", "last_activity" : "2025-06-05T12:26:51.714871Z", "execution_state" : "starting", "connections" : 0 }, "notebook" : { "path" : "notebook.ipynb", "name" : null } }, { "id" : "db123df4-ec13-4fe0-b3c3-ef85464b8a42", "path" : "/tmp/test.ipynb", "name" : "", "type" : "notebook", "kernel" : { "id" : "7d3091af-8b0a-474a-be04-f64191a43d0f", "name" : "python3", "last_activity" : "2025-06-06T01:29:16.712732Z", "execution_state" : "starting", "connections" : 0 }, "notebook" : { "path" : "/tmp/test.ipynb", "name" : "" } } ] ================================================ FILE: components/execd/pkg/jupyter/session/types.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. // Package session provides functionality for managing Jupyter sessions package session import ( "time" ) // Session represents a Jupyter session type Session struct { // ID is the unique identifier of the session ID string `json:"id"` // Path is the path associated with the session (typically the notebook file path) Path string `json:"path"` // Name is the name of the session Name string `json:"name"` // Type is the type of the session (e.g., notebook, console) Type string `json:"type"` // Kernel contains information about the kernel associated with the session Kernel *KernelInfo `json:"kernel"` // CreatedAt is the timestamp when the session was created CreatedAt time.Time `json:"created,omitempty"` // LastModified is the timestamp when the session was last modified LastModified time.Time `json:"last_modified,omitempty"` } // KernelInfo contains basic kernel information type KernelInfo struct { // ID is the unique identifier of the kernel ID string `json:"id"` // Name is the name of the kernel (e.g., python3, ir) Name string `json:"name"` // LastActivity is the timestamp of the kernel's last activity LastActivity time.Time `json:"last_activity,omitempty"` // Connections is the number of clients currently connected to the kernel Connections int `json:"connections,omitempty"` // ExecutionState is the execution state of the kernel (e.g., idle, busy) ExecutionState string `json:"execution_state,omitempty"` } // SessionCreateRequest is the request for creating a new session type SessionCreateRequest struct { // Path is the path associated with the session (typically the notebook file path) Path string `json:"path"` // Name is the name of the session Name string `json:"name,omitempty"` // Type is the type of the session (defaults to "notebook") Type string `json:"type,omitempty"` // Kernel contains information about the kernel to start Kernel *KernelSpec `json:"kernel,omitempty"` } // KernelSpec contains kernel specification information type KernelSpec struct { // Name is the name of the kernel (e.g., python3, ir) Name string `json:"name"` // ID is the unique identifier of the kernel (optional, used only when reusing existing kernel) ID string `json:"id,omitempty"` } // SessionUpdateRequest request to update an existing session type SessionUpdateRequest struct { // Path is the new session path Path string `json:"path,omitempty"` // Name is the new session name Name string `json:"name,omitempty"` // Type is the new session type Type string `json:"type,omitempty"` // Kernel contains the new kernel information Kernel *KernelSpec `json:"kernel,omitempty"` } // SessionListResponse represents the response for listing sessions type SessionListResponse []*Session // SessionOptions contains options for creating or updating sessions type SessionOptions struct { // Name is the name of the session Name string // Path is the path associated with the session Path string // Type is the type of the session (defaults to "notebook") Type string // KernelName is the kernel name to use (e.g., python3, ir, etc.) KernelName string // KernelID is the ID of the existing kernel to reuse (if provided, KernelName will be ignored) KernelID string } // DefaultSessionType is the default session type const DefaultSessionType = "notebook" ================================================ FILE: components/execd/pkg/jupyter/transport.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package jupyter import "net/http" type AuthTransport struct { Token string Base http.RoundTripper } func (t *AuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { reqClone := req.Clone(req.Context()) reqClone.Header.Set("Authorization", "Token "+t.Token) return t.Base.RoundTrip(reqClone) } ================================================ FILE: components/execd/pkg/log/log.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package log import ( "os" slogger "github.com/alibaba/opensandbox/internal/logger" ) const logFileEnvKey = "EXECD_LOG_FILE" var current slogger.Logger // Init constructs the singleton logger. Call once during startup. // Legacy levels: 0/1/2=fatal, 3=error, 4=warn, 5/6=info, 7+=debug. func Init(level int) { current = newLogger(mapLevel(level)) } func mapLevel(level int) string { switch { case level <= 2: return "fatal" case level == 3: return "error" case level == 4: return "warn" case level == 5 || level == 6: return "info" default: return "debug" } } func newLogger(level string) slogger.Logger { cfg := slogger.Config{ Level: level, } if logFile := os.Getenv(logFileEnvKey); logFile != "" { cfg.OutputPaths = []string{logFile} cfg.ErrorOutputPaths = cfg.OutputPaths } return slogger.MustNew(cfg) } func getLogger() slogger.Logger { if current != nil { return current } l := newLogger("info") current = l return l } func Debug(format string, args ...any) { getLogger().Debugf(format, args...) } func Info(format string, args ...any) { getLogger().Infof(format, args...) } func Warn(format string, args ...any) { getLogger().Warnf(format, args...) } // Warning is an alias to Warn for compatibility. func Warning(format string, args ...any) { Warn(format, args...) } func Error(format string, args ...any) { getLogger().Errorf(format, args...) } ================================================ FILE: components/execd/pkg/runtime/bash_session.go ================================================ // Copyright 2026 Alibaba Group Holding Ltd. // // 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. //go:build !windows // +build !windows package runtime import ( "bufio" "context" "errors" "fmt" "os" "os/exec" "sort" "strconv" "strings" "syscall" "time" "github.com/google/uuid" "github.com/alibaba/opensandbox/execd/pkg/jupyter/execute" "github.com/alibaba/opensandbox/execd/pkg/log" ) const ( envDumpStartMarker = "__ENV_DUMP_START__" envDumpEndMarker = "__ENV_DUMP_END__" exitMarkerPrefix = "__EXIT_CODE__:" pwdMarkerPrefix = "__PWD__:" ) func (c *Controller) createBashSession(req *CreateContextRequest) (string, error) { session := newBashSession(req.Cwd) if err := session.start(); err != nil { return "", fmt.Errorf("failed to start bash session: %w", err) } c.bashSessionClientMap.Store(session.config.Session, session) log.Info("created bash session %s", session.config.Session) return session.config.Session, nil } func (c *Controller) runBashSession(ctx context.Context, request *ExecuteCodeRequest) error { session := c.getBashSession(request.Context) if session == nil { return ErrContextNotFound } return session.run(ctx, request) } func (c *Controller) getBashSession(sessionId string) *bashSession { if v, ok := c.bashSessionClientMap.Load(sessionId); ok { if s, ok := v.(*bashSession); ok { return s } } return nil } func (c *Controller) closeBashSession(sessionId string) error { session := c.getBashSession(sessionId) if session == nil { return ErrContextNotFound } err := session.close() if err != nil { return err } c.bashSessionClientMap.Delete(sessionId) return nil } func (c *Controller) CreateBashSession(req *CreateContextRequest) (string, error) { return c.createBashSession(req) } func (c *Controller) RunInBashSession(ctx context.Context, req *ExecuteCodeRequest) error { return c.runBashSession(ctx, req) } func (c *Controller) DeleteBashSession(sessionID string) error { return c.closeBashSession(sessionID) } // Session implementation (pipe-based, no PTY) func newBashSession(cwd string) *bashSession { config := &bashSessionConfig{ Session: uuidString(), StartupTimeout: 5 * time.Second, } env := make(map[string]string) for _, kv := range os.Environ() { if k, v, ok := splitEnvPair(kv); ok { env[k] = v } } return &bashSession{ config: config, env: env, cwd: cwd, } } func (s *bashSession) start() error { s.mu.Lock() defer s.mu.Unlock() if s.started { return errors.New("session already started") } s.started = true return nil } func (s *bashSession) trackCurrentProcess(pid int) { s.mu.Lock() defer s.mu.Unlock() s.currentProcessPid = pid } func (s *bashSession) untrackCurrentProcess() { s.mu.Lock() defer s.mu.Unlock() s.currentProcessPid = 0 } //nolint:gocognit func (s *bashSession) run(ctx context.Context, request *ExecuteCodeRequest) error { s.mu.Lock() if !s.started { s.mu.Unlock() return errors.New("session not started") } envSnapshot := copyEnvMap(s.env) cwd := s.cwd // override original cwd if specified if request.Cwd != "" { cwd = request.Cwd } sessionID := s.config.Session s.mu.Unlock() startAt := time.Now() if request.Hooks.OnExecuteInit != nil { request.Hooks.OnExecuteInit(sessionID) } wait := request.Timeout if wait <= 0 { wait = 24 * 3600 * time.Second // max to 24 hours } ctx, cancel := context.WithTimeout(ctx, wait) defer cancel() script := buildWrappedScript(request.Code, envSnapshot, cwd) scriptFile, err := os.CreateTemp("", "execd_bash_*.sh") if err != nil { return fmt.Errorf("create script file: %w", err) } scriptPath := scriptFile.Name() if _, err := scriptFile.WriteString(script); err != nil { _ = scriptFile.Close() return fmt.Errorf("write script file: %w", err) } if err := scriptFile.Close(); err != nil { return fmt.Errorf("close script file: %w", err) } cmd := exec.CommandContext(ctx, "bash", "--noprofile", "--norc", scriptPath) cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} // Do not pass envSnapshot via cmd.Env to avoid "argument list too long" when session env is large. // Child inherits parent env (nil => default in Go). The script file already has "export K=V" for // all session vars at the top, so the session environment is applied when the script runs. stdout, err := cmd.StdoutPipe() if err != nil { return fmt.Errorf("stdout pipe: %w", err) } cmd.Stderr = cmd.Stdout if err := cmd.Start(); err != nil { log.Error("start bash session failed: %v (command: %q)", err, request.Code) return fmt.Errorf("start bash: %w", err) } defer s.untrackCurrentProcess() s.trackCurrentProcess(cmd.Process.Pid) scanner := bufio.NewScanner(stdout) scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) var ( envLines []string pwdLine string exitCode *int inEnv bool ) for scanner.Scan() { line := scanner.Text() switch { case line == envDumpStartMarker: inEnv = true case line == envDumpEndMarker: inEnv = false case strings.HasPrefix(line, exitMarkerPrefix): if code, err := strconv.Atoi(strings.TrimPrefix(line, exitMarkerPrefix)); err == nil { exitCode = &code //nolint:ineffassign } case strings.HasPrefix(line, pwdMarkerPrefix): pwdLine = strings.TrimPrefix(line, pwdMarkerPrefix) default: if inEnv { envLines = append(envLines, line) continue } if request.Hooks.OnExecuteStdout != nil { request.Hooks.OnExecuteStdout(line) } } } scanErr := scanner.Err() waitErr := cmd.Wait() if scanErr != nil { log.Error("read stdout failed: %v (command: %q)", scanErr, request.Code) return fmt.Errorf("read stdout: %w", scanErr) } if errors.Is(ctx.Err(), context.DeadlineExceeded) { log.Error("timeout after %s while running command: %q", wait, request.Code) return fmt.Errorf("timeout after %s while running command %q", wait, request.Code) } if exitCode == nil && cmd.ProcessState != nil { code := cmd.ProcessState.ExitCode() //nolint:staticcheck exitCode = &code //nolint:ineffassign } updatedEnv := parseExportDump(envLines) s.mu.Lock() if len(updatedEnv) > 0 { s.env = updatedEnv } if pwdLine != "" { s.cwd = pwdLine } s.mu.Unlock() var exitErr *exec.ExitError if waitErr != nil && !errors.As(waitErr, &exitErr) { log.Error("command wait failed: %v (command: %q)", waitErr, request.Code) return waitErr } userExitCode := 0 if exitCode != nil { userExitCode = *exitCode } if userExitCode != 0 { errMsg := fmt.Sprintf("command exited with code %d", userExitCode) if waitErr != nil { errMsg = waitErr.Error() } if request.Hooks.OnExecuteError != nil { request.Hooks.OnExecuteError(&execute.ErrorOutput{ EName: "CommandExecError", EValue: strconv.Itoa(userExitCode), Traceback: []string{errMsg}, }) } log.Error("CommandExecError: %s (command: %q)", errMsg, request.Code) return nil } if request.Hooks.OnExecuteComplete != nil { request.Hooks.OnExecuteComplete(time.Since(startAt)) } return nil } func buildWrappedScript(command string, env map[string]string, cwd string) string { var b strings.Builder keys := make([]string, 0, len(env)) for k := range env { v := env[k] if isValidEnvKey(k) && !envKeysNotPersisted[k] && len(v) <= maxPersistedEnvValueSize { keys = append(keys, k) } } sort.Strings(keys) for _, k := range keys { b.WriteString("export ") b.WriteString(k) b.WriteString("=") b.WriteString(shellEscape(env[k])) b.WriteString("\n") } if cwd != "" { b.WriteString("cd ") b.WriteString(shellEscape(cwd)) b.WriteString("\n") } b.WriteString(command) if !strings.HasSuffix(command, "\n") { b.WriteString("\n") } b.WriteString("__USER_EXIT_CODE__=$?\n") b.WriteString("printf \"\\n%s\\n\" \"" + envDumpStartMarker + "\"\n") b.WriteString("export -p\n") b.WriteString("printf \"%s\\n\" \"" + envDumpEndMarker + "\"\n") b.WriteString("printf \"" + pwdMarkerPrefix + "%s\\n\" \"$(pwd)\"\n") b.WriteString("printf \"" + exitMarkerPrefix + "%s\\n\" \"$__USER_EXIT_CODE__\"\n") b.WriteString("exit \"$__USER_EXIT_CODE__\"\n") return b.String() } // envKeysNotPersisted are not carried across runs (prompt/display vars). var envKeysNotPersisted = map[string]bool{ "PS1": true, "PS2": true, "PS3": true, "PS4": true, "PROMPT_COMMAND": true, } // maxPersistedEnvValueSize caps single env value length as a safeguard. const maxPersistedEnvValueSize = 8 * 1024 func parseExportDump(lines []string) map[string]string { if len(lines) == 0 { return nil } env := make(map[string]string, len(lines)) for _, line := range lines { k, v, ok := parseExportLine(line) if !ok || envKeysNotPersisted[k] || len(v) > maxPersistedEnvValueSize { continue } env[k] = v } return env } func parseExportLine(line string) (string, string, bool) { const prefix = "declare -x " if !strings.HasPrefix(line, prefix) { return "", "", false } rest := strings.TrimSpace(strings.TrimPrefix(line, prefix)) if rest == "" { return "", "", false } name, value := rest, "" if eq := strings.Index(rest, "="); eq >= 0 { name = rest[:eq] raw := rest[eq+1:] if unquoted, err := strconv.Unquote(raw); err == nil { value = unquoted } else { value = strings.Trim(raw, `"`) } } if !isValidEnvKey(name) { return "", "", false } return name, value, true } func shellEscape(value string) string { return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'" } func isValidEnvKey(key string) bool { if key == "" { return false } for i, r := range key { if i == 0 { if (r < 'A' || (r > 'Z' && r < 'a') || r > 'z') && r != '_' { return false } continue } if (r < 'A' || (r > 'Z' && r < 'a') || r > 'z') && (r < '0' || r > '9') && r != '_' { return false } } return true } func copyEnvMap(src map[string]string) map[string]string { if src == nil { return map[string]string{} } dst := make(map[string]string, len(src)) for k, v := range src { dst[k] = v } return dst } func splitEnvPair(kv string) (string, string, bool) { parts := strings.SplitN(kv, "=", 2) if len(parts) != 2 { return "", "", false } if !isValidEnvKey(parts[0]) { return "", "", false } return parts[0], parts[1], true } func (s *bashSession) close() error { s.mu.Lock() defer s.mu.Unlock() pid := s.currentProcessPid s.currentProcessPid = 0 s.started = false s.env = nil s.cwd = "" if pid != 0 { if err := syscall.Kill(-pid, syscall.SIGKILL); err != nil { log.Warning("kill session process group %d: %v (process may have already exited)", pid, err) } } return nil } func uuidString() string { return uuid.New().String() } ================================================ FILE: components/execd/pkg/runtime/bash_session_test.go ================================================ // Copyright 2026 Alibaba Group Holding Ltd. // // 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. //go:build !windows // +build !windows package runtime import ( "context" "fmt" "os/exec" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/alibaba/opensandbox/execd/pkg/jupyter/execute" ) func TestBashSession_NonZeroExitEmitsError(t *testing.T) { if _, err := exec.LookPath("bash"); err != nil { t.Skip("bash not found in PATH") } c := NewController("", "") ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() var ( sessionID string stdoutLine string errCh = make(chan *execute.ErrorOutput, 1) completeCh = make(chan struct{}, 1) ) req := &ExecuteCodeRequest{ Language: Bash, Code: `echo "before"; exit 7`, Cwd: t.TempDir(), Timeout: 5 * time.Second, Hooks: ExecuteResultHook{ OnExecuteInit: func(s string) { sessionID = s }, OnExecuteStdout: func(s string) { stdoutLine = s }, OnExecuteError: func(err *execute.ErrorOutput) { errCh <- err }, OnExecuteComplete: func(_ time.Duration) { completeCh <- struct{}{} }, }, } session, err := c.createBashSession(&CreateContextRequest{}) assert.NoError(t, err) req.Context = session require.NoError(t, c.runBashSession(ctx, req)) var gotErr *execute.ErrorOutput select { case gotErr = <-errCh: case <-time.After(2 * time.Second): require.Fail(t, "expected error hook to be called") } require.NotNil(t, gotErr, "expected non-nil error output") require.Equal(t, "CommandExecError", gotErr.EName) require.Equal(t, "7", gotErr.EValue) require.NotEmpty(t, sessionID, "expected session id to be set") require.Equal(t, "before", stdoutLine) select { case <-completeCh: require.Fail(t, "did not expect completion hook on non-zero exit") default: } } func TestBashSession_envAndExitCode(t *testing.T) { session := newBashSession("") t.Cleanup(func() { _ = session.close() }) require.NoError(t, session.start()) var ( initCalls int completeCalls int stdoutLines []string ) hooks := ExecuteResultHook{ OnExecuteInit: func(ctx string) { require.Equal(t, session.config.Session, ctx, "unexpected session in OnExecuteInit") initCalls++ }, OnExecuteStdout: func(text string) { t.Log(text) stdoutLines = append(stdoutLines, text) }, OnExecuteComplete: func(_ time.Duration) { completeCalls++ }, } // 1) export an env var request := &ExecuteCodeRequest{ Code: "export FOO=hello", Hooks: hooks, Timeout: 3 * time.Second, } require.NoError(t, session.run(context.Background(), request)) exportStdoutCount := len(stdoutLines) // 2) verify env is persisted request = &ExecuteCodeRequest{ Code: "echo $FOO", Hooks: hooks, Timeout: 3 * time.Second, } require.NoError(t, session.run(context.Background(), request)) echoLines := stdoutLines[exportStdoutCount:] foundHello := false for _, line := range echoLines { if strings.TrimSpace(line) == "hello" { foundHello = true break } } require.True(t, foundHello, "expected echo $FOO to output 'hello', got %v", echoLines) // 3) ensure exit code of previous command is reflected in shell state request = &ExecuteCodeRequest{ Code: "false; echo EXIT:$?", Hooks: hooks, Timeout: 3 * time.Second, } prevCount := len(stdoutLines) require.NoError(t, session.run(context.Background(), request)) exitLines := stdoutLines[prevCount:] foundExit := false for _, line := range exitLines { if strings.Contains(line, "EXIT:1") { foundExit = true break } } require.True(t, foundExit, "expected exit code output 'EXIT:1', got %v", exitLines) require.Equal(t, 3, initCalls, "OnExecuteInit expected 3 calls") require.Equal(t, 3, completeCalls, "OnExecuteComplete expected 3 calls") } func TestBashSession_envLargeOutputChained(t *testing.T) { session := newBashSession("") t.Cleanup(func() { _ = session.close() }) require.NoError(t, session.start()) var ( initCalls int completeCalls int stdoutLines []string ) hooks := ExecuteResultHook{ OnExecuteInit: func(ctx string) { require.Equal(t, session.config.Session, ctx, "unexpected session in OnExecuteInit") initCalls++ }, OnExecuteStdout: func(text string) { t.Log(text) stdoutLines = append(stdoutLines, text) }, OnExecuteComplete: func(_ time.Duration) { completeCalls++ }, } runAndCollect := func(cmd string) []string { start := len(stdoutLines) request := &ExecuteCodeRequest{ Code: cmd, Hooks: hooks, Timeout: 10 * time.Second, } require.NoError(t, session.run(context.Background(), request)) return append([]string(nil), stdoutLines[start:]...) } lines1 := runAndCollect("export FOO=hello1; for i in $(seq 1 60); do echo A${i}:$FOO; done") require.GreaterOrEqual(t, len(lines1), 60, "expected >=60 lines for cmd1") require.True(t, containsLine(lines1, "A1:hello1") && containsLine(lines1, "A60:hello1"), "env not reflected in cmd1 output, got %v", lines1[:3]) lines2 := runAndCollect("export FOO=${FOO}_next; export BAR=bar1; for i in $(seq 1 60); do echo B${i}:$FOO:$BAR; done") require.GreaterOrEqual(t, len(lines2), 60, "expected >=60 lines for cmd2") require.True(t, containsLine(lines2, "B1:hello1_next:bar1") && containsLine(lines2, "B60:hello1_next:bar1"), "env not propagated to cmd2 output, sample %v", lines2[:3]) lines3 := runAndCollect("export BAR=${BAR}_last; for i in $(seq 1 60); do echo C${i}:$FOO:$BAR; done; echo FINAL_FOO=$FOO; echo FINAL_BAR=$BAR") require.GreaterOrEqual(t, len(lines3), 62, "expected >=62 lines for cmd3") // 60 lines + 2 finals require.True(t, containsLine(lines3, "C1:hello1_next:bar1_last") && containsLine(lines3, "C60:hello1_next:bar1_last"), "env not propagated to cmd3 output, sample %v", lines3[:3]) require.True(t, containsLine(lines3, "FINAL_FOO=hello1_next") && containsLine(lines3, "FINAL_BAR=bar1_last"), "final env lines missing, got %v", lines3[len(lines3)-5:]) require.Equal(t, 3, initCalls, "OnExecuteInit expected 3 calls") require.Equal(t, 3, completeCalls, "OnExecuteComplete expected 3 calls") } func TestBashSession_cwdPersistsWithoutOverride(t *testing.T) { session := newBashSession("") t.Cleanup(func() { _ = session.close() }) require.NoError(t, session.start()) targetDir := t.TempDir() var stdoutLines []string hooks := ExecuteResultHook{ OnExecuteStdout: func(line string) { stdoutLines = append(stdoutLines, line) }, } runAndCollect := func(req *ExecuteCodeRequest) []string { start := len(stdoutLines) require.NoError(t, session.run(context.Background(), req)) return append([]string(nil), stdoutLines[start:]...) } firstRunLines := runAndCollect(&ExecuteCodeRequest{ Code: fmt.Sprintf("cd %s\npwd", targetDir), Hooks: hooks, Timeout: 3 * time.Second, }) require.True(t, containsLine(firstRunLines, targetDir), "expected cd to update cwd to %q, got %v", targetDir, firstRunLines) secondRunLines := runAndCollect(&ExecuteCodeRequest{ Code: "pwd", Hooks: hooks, Timeout: 3 * time.Second, }) require.True(t, containsLine(secondRunLines, targetDir), "expected subsequent run to inherit cwd %q, got %v", targetDir, secondRunLines) session.mu.Lock() finalCwd := session.cwd session.mu.Unlock() require.Equal(t, targetDir, finalCwd, "expected session cwd to stay at %q", targetDir) } func TestBashSession_requestCwdOverridesAfterCd(t *testing.T) { session := newBashSession("") t.Cleanup(func() { _ = session.close() }) require.NoError(t, session.start()) initialDir := t.TempDir() overrideDir := t.TempDir() var stdoutLines []string hooks := ExecuteResultHook{ OnExecuteStdout: func(line string) { stdoutLines = append(stdoutLines, line) }, } runAndCollect := func(req *ExecuteCodeRequest) []string { start := len(stdoutLines) require.NoError(t, session.run(context.Background(), req)) return append([]string(nil), stdoutLines[start:]...) } // First request: change session cwd via script. firstRunLines := runAndCollect(&ExecuteCodeRequest{ Code: fmt.Sprintf("cd %s\npwd", initialDir), Hooks: hooks, Timeout: 3 * time.Second, }) require.True(t, containsLine(firstRunLines, initialDir), "expected cd to update cwd to %q, got %v", initialDir, firstRunLines) // Second request: explicit Cwd overrides session cwd. secondRunLines := runAndCollect(&ExecuteCodeRequest{ Code: "pwd", Cwd: overrideDir, Hooks: hooks, Timeout: 3 * time.Second, }) require.True(t, containsLine(secondRunLines, overrideDir), "expected command to run in override cwd %q, got %v", overrideDir, secondRunLines) session.mu.Lock() finalCwd := session.cwd session.mu.Unlock() require.Equal(t, overrideDir, finalCwd, "expected session cwd updated to override dir %q", overrideDir) } func TestBashSession_envDumpNotLeakedWhenNoTrailingNewline(t *testing.T) { session := newBashSession("") t.Cleanup(func() { _ = session.close() }) require.NoError(t, session.start()) var stdoutLines []string hooks := ExecuteResultHook{ OnExecuteStdout: func(line string) { stdoutLines = append(stdoutLines, line) }, } request := &ExecuteCodeRequest{ Code: `set +x; printf '{"foo":1}'`, Hooks: hooks, Timeout: 3 * time.Second, } require.NoError(t, session.run(context.Background(), request)) require.Len(t, stdoutLines, 1, "expected exactly one stdout line") require.Equal(t, `{"foo":1}`, strings.TrimSpace(stdoutLines[0])) for _, line := range stdoutLines { require.NotContains(t, line, envDumpStartMarker, "env dump leaked into stdout: %v", stdoutLines) require.NotContains(t, line, "declare -x", "env dump leaked into stdout: %v", stdoutLines) } } func TestBashSession_envDumpNotLeakedWhenNoOutput(t *testing.T) { session := newBashSession("") t.Cleanup(func() { _ = session.close() }) require.NoError(t, session.start()) var stdoutLines []string hooks := ExecuteResultHook{ OnExecuteStdout: func(line string) { stdoutLines = append(stdoutLines, line) }, } request := &ExecuteCodeRequest{ Code: `set +x; true`, Hooks: hooks, Timeout: 3 * time.Second, } require.NoError(t, session.run(context.Background(), request)) require.LessOrEqual(t, len(stdoutLines), 1, "expected at most one stdout line, got %v", stdoutLines) if len(stdoutLines) == 1 { require.Empty(t, strings.TrimSpace(stdoutLines[0]), "expected empty stdout") } for _, line := range stdoutLines { require.NotContains(t, line, envDumpStartMarker, "env dump leaked into stdout: %v", stdoutLines) require.NotContains(t, line, "declare -x", "env dump leaked into stdout: %v", stdoutLines) } } func TestBashSession_heredoc(t *testing.T) { rewardDir := t.TempDir() controller := NewController("", "") sessionID, err := controller.CreateBashSession(&CreateContextRequest{}) require.NoError(t, err) t.Cleanup(func() { _ = controller.DeleteBashSession(sessionID) }) hooks := ExecuteResultHook{ OnExecuteStdout: func(line string) { fmt.Printf("[stdout] %s\n", line) }, OnExecuteComplete: func(d time.Duration) { fmt.Printf("[complete] %s\n", d) }, } // First run: heredoc + reward file write. script := fmt.Sprintf(` set -x reward_dir=%q mkdir -p "$reward_dir" cat > /tmp/repro_script.sh <<'SHEOF' #!/usr/bin/env sh echo "hello heredoc" SHEOF chmod +x /tmp/repro_script.sh /tmp/repro_script.sh echo "after heredoc" echo 1 > "$reward_dir/reward.txt" cat "$reward_dir/reward.txt" `, rewardDir) ctx := context.Background() require.NoError(t, controller.RunInBashSession(ctx, &ExecuteCodeRequest{ Context: sessionID, Language: Bash, Timeout: 10 * time.Second, Code: script, Hooks: hooks, })) // Second run: ensure the session keeps working. require.NoError(t, controller.RunInBashSession(ctx, &ExecuteCodeRequest{ Context: sessionID, Language: Bash, Timeout: 5 * time.Second, Code: "echo 'second command works'", Hooks: hooks, })) } func TestBashSession_execReplacesShell(t *testing.T) { session := newBashSession("") t.Cleanup(func() { _ = session.close() }) require.NoError(t, session.start()) var stdoutLines []string hooks := ExecuteResultHook{ OnExecuteStdout: func(line string) { stdoutLines = append(stdoutLines, line) }, } script := ` cat > /tmp/exec_child.sh <<'EOF' echo "child says hi" EOF chmod +x /tmp/exec_child.sh exec /tmp/exec_child.sh ` request := &ExecuteCodeRequest{ Code: script, Hooks: hooks, Timeout: 5 * time.Second, } require.NoError(t, session.run(context.Background(), request), "expected exec to complete without killing the session") require.True(t, containsLine(stdoutLines, "child says hi"), "expected child output, got %v", stdoutLines) // Subsequent run should still work because we restart bash per run. request = &ExecuteCodeRequest{ Code: "echo still-alive", Hooks: hooks, Timeout: 2 * time.Second, } stdoutLines = nil require.NoError(t, session.run(context.Background(), request), "expected run to succeed after exec replaced the shell") require.True(t, containsLine(stdoutLines, "still-alive"), "expected follow-up output, got %v", stdoutLines) } func TestBashSession_complexExec(t *testing.T) { session := newBashSession("") t.Cleanup(func() { _ = session.close() }) require.NoError(t, session.start()) var stdoutLines []string hooks := ExecuteResultHook{ OnExecuteStdout: func(line string) { stdoutLines = append(stdoutLines, line) }, } script := ` LOG_FILE=$(mktemp) export LOG_FILE exec 3>&1 4>&2 exec > >(tee "$LOG_FILE") 2>&1 set -x echo "from-complex-exec" exec 1>&3 2>&4 # step record echo "after-restore" ` request := &ExecuteCodeRequest{ Code: script, Hooks: hooks, Timeout: 5 * time.Second, } require.NoError(t, session.run(context.Background(), request), "expected complex exec to finish") require.True(t, containsLine(stdoutLines, "from-complex-exec") && containsLine(stdoutLines, "after-restore"), "expected exec outputs, got %v", stdoutLines) // Session should still be usable. request = &ExecuteCodeRequest{ Code: "echo still-alive", Hooks: hooks, Timeout: 2 * time.Second, } stdoutLines = nil require.NoError(t, session.run(context.Background(), request), "expected run to succeed after complex exec") require.True(t, containsLine(stdoutLines, "still-alive"), "expected follow-up output, got %v", stdoutLines) } func containsLine(lines []string, target string) bool { for _, l := range lines { if strings.TrimSpace(l) == target { return true } } return false } // TestBashSession_CloseKillsRunningProcess verifies that session.close() kills the active // process group so that a long-running command (e.g. sleep) does not keep running after close. func TestBashSession_CloseKillsRunningProcess(t *testing.T) { if _, err := exec.LookPath("bash"); err != nil { t.Skip("bash not found in PATH") } session := newBashSession("") require.NoError(t, session.start()) runDone := make(chan error, 1) req := &ExecuteCodeRequest{ Code: "sleep 30", Timeout: 60 * time.Second, Hooks: ExecuteResultHook{}, } go func() { runDone <- session.run(context.Background(), req) }() // Give the child process time to start. time.Sleep(200 * time.Millisecond) // Close should kill the process group; run() should return soon (it may return nil // because the code path treats non-zero exit as success after calling OnExecuteError). require.NoError(t, session.close()) select { case <-runDone: // run() returned; process was killed so we did not wait 30s case <-time.After(3 * time.Second): require.Fail(t, "run did not return within 3s after close (process was not killed)") } } // TestBashSession_DeleteBashSessionKillsRunningProcess verifies that DeleteBashSession // (close path) kills the active run and removes the session from the controller. func TestBashSession_DeleteBashSessionKillsRunningProcess(t *testing.T) { if _, err := exec.LookPath("bash"); err != nil { t.Skip("bash not found in PATH") } c := NewController("", "") sessionID, err := c.CreateBashSession(&CreateContextRequest{}) require.NoError(t, err) runDone := make(chan error, 1) req := &ExecuteCodeRequest{ Language: Bash, Context: sessionID, Code: "sleep 30", Timeout: 60 * time.Second, Hooks: ExecuteResultHook{}, } go func() { runDone <- c.RunInBashSession(context.Background(), req) }() time.Sleep(200 * time.Millisecond) require.NoError(t, c.DeleteBashSession(sessionID)) select { case <-runDone: // RunInBashSession returned; process was killed case <-time.After(3 * time.Second): require.Fail(t, "RunInBashSession did not return within 3s after DeleteBashSession") } // Session should be gone; deleting again should return ErrContextNotFound. err = c.DeleteBashSession(sessionID) require.Error(t, err) require.ErrorIs(t, err, ErrContextNotFound) } // TestBashSession_CloseWithNoActiveRun verifies that close() with no running command // completes without error and does not hang. func TestBashSession_CloseWithNoActiveRun(t *testing.T) { session := newBashSession("") require.NoError(t, session.start()) done := make(chan struct{}, 1) go func() { _ = session.close() done <- struct{}{} }() select { case <-done: // close() returned case <-time.After(2 * time.Second): require.Fail(t, "close() did not return within 2s when no run was active") } } ================================================ FILE: components/execd/pkg/runtime/bash_session_windows.go ================================================ // Copyright 2026 Alibaba Group Holding Ltd. // // 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. //go:build windows // +build windows package runtime import ( "context" "errors" ) var errBashSessionNotSupported = errors.New("bash session is not supported on windows") // CreateBashSession is not supported on Windows. func (c *Controller) CreateBashSession(_ *CreateContextRequest) (string, error) { //nolint:revive return "", errBashSessionNotSupported } // RunInBashSession is not supported on Windows. func (c *Controller) RunInBashSession(_ context.Context, _ *ExecuteCodeRequest) error { //nolint:revive return errBashSessionNotSupported } // DeleteBashSession is not supported on Windows. func (c *Controller) DeleteBashSession(_ string) error { //nolint:revive return errBashSessionNotSupported } ================================================ FILE: components/execd/pkg/runtime/command.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. //go:build !windows // +build !windows package runtime import ( "context" "errors" "fmt" "os" "os/exec" "os/signal" "os/user" "strconv" "sync" "syscall" "time" "github.com/alibaba/opensandbox/execd/pkg/jupyter/execute" "github.com/alibaba/opensandbox/execd/pkg/log" "github.com/alibaba/opensandbox/execd/pkg/util/safego" ) // getShell returns the preferred shell, falling back to sh if bash is not available. // This is needed for Alpine-based Docker images that only have sh by default. func getShell() string { if _, err := exec.LookPath("bash"); err == nil { return "bash" } return "sh" } func buildCredential(uid, gid *uint32) (*syscall.Credential, error) { if uid == nil && gid == nil { return nil, nil //nolint:nilnil } cred := &syscall.Credential{} if uid != nil { cred.Uid = *uid // Load user info to get primary GID and supplemental groups u, err := user.LookupId(strconv.FormatUint(uint64(*uid), 10)) if err == nil { // Set primary GID if not explicitly provided if gid == nil { primaryGid, err := strconv.ParseUint(u.Gid, 10, 32) if err == nil { cred.Gid = uint32(primaryGid) } } // Load supplemental groups gids, err := u.GroupIds() if err == nil { for _, g := range gids { id, err := strconv.ParseUint(g, 10, 32) if err == nil { cred.Groups = append(cred.Groups, uint32(id)) } } } } } // Override Gid if explicitly provided if gid != nil { cred.Gid = *gid } return cred, nil } // runCommand executes shell commands and streams their output. func (c *Controller) runCommand(ctx context.Context, request *ExecuteCodeRequest) error { session := c.newContextID() signals := make(chan os.Signal, 1) defer close(signals) signal.Notify(signals) defer signal.Reset() stdout, stderr, err := c.stdLogDescriptor(session) if err != nil { return fmt.Errorf("failed to get stdlog descriptor: %w", err) } defer stdout.Close() defer stderr.Close() stdoutPath := c.stdoutFileName(session) stderrPath := c.stderrFileName(session) startAt := time.Now() log.Info("received command: %v", request.Code) shell := getShell() cmd := exec.CommandContext(ctx, shell, "-c", request.Code) // Configure credentials and process group cred, err := buildCredential(request.Uid, request.Gid) if err != nil { return fmt.Errorf("failed to build credential: %w", err) } cmd.SysProcAttr = &syscall.SysProcAttr{ Setpgid: true, Credential: cred, } cmd.Stdout = stdout cmd.Stderr = stderr extraEnv := mergeExtraEnvs(loadExtraEnvFromFile(), request.Envs) cmd.Env = mergeEnvs(os.Environ(), extraEnv) cmd.Dir = request.Cwd done := make(chan struct{}, 1) var wg sync.WaitGroup wg.Add(2) safego.Go(func() { defer wg.Done() c.tailStdPipe(stdoutPath, request.Hooks.OnExecuteStdout, done) }) safego.Go(func() { defer wg.Done() c.tailStdPipe(stderrPath, request.Hooks.OnExecuteStderr, done) }) err = cmd.Start() if err != nil { close(done) wg.Wait() request.Hooks.OnExecuteInit(session) request.Hooks.OnExecuteError(&execute.ErrorOutput{EName: "CommandExecError", EValue: err.Error()}) log.Error("CommandExecError: error starting commands: %v", err) return nil } kernel := &commandKernel{ pid: cmd.Process.Pid, stdoutPath: stdoutPath, stderrPath: stderrPath, startedAt: startAt, running: true, content: request.Code, isBackground: false, } c.storeCommandKernel(session, kernel) request.Hooks.OnExecuteInit(session) go func() { for { select { case <-ctx.Done(): return case sig := <-signals: if sig == nil { continue } // DO NOT forward syscall.SIGURG to children processes. if sig != syscall.SIGCHLD && sig != syscall.SIGURG { _ = syscall.Kill(-cmd.Process.Pid, sig.(syscall.Signal)) } } } }() err = cmd.Wait() close(done) wg.Wait() if err != nil { var eName, eValue string var eCode int var traceback []string var exitError *exec.ExitError if errors.As(err, &exitError) { exitCode := exitError.ExitCode() eName = "CommandExecError" eValue = strconv.Itoa(exitCode) eCode = exitCode } else { eName = "CommandExecError" eValue = err.Error() eCode = 1 } traceback = []string{err.Error()} request.Hooks.OnExecuteError(&execute.ErrorOutput{ EName: eName, EValue: eValue, Traceback: traceback, }) log.Error("CommandExecError: error running commands: %v", err) c.markCommandFinished(session, eCode, err.Error()) return nil } c.markCommandFinished(session, 0, "") request.Hooks.OnExecuteComplete(time.Since(startAt)) return nil } // runBackgroundCommand executes shell commands in detached mode. func (c *Controller) runBackgroundCommand(ctx context.Context, cancel context.CancelFunc, request *ExecuteCodeRequest) error { session := c.newContextID() request.Hooks.OnExecuteInit(session) pipe, err := c.combinedOutputDescriptor(session) if err != nil { cancel() return fmt.Errorf("failed to get combined output descriptor: %w", err) } stdoutPath := c.combinedOutputFileName(session) stderrPath := c.combinedOutputFileName(session) signals := make(chan os.Signal, 1) defer close(signals) signal.Notify(signals) defer signal.Reset() startAt := time.Now() log.Info("received command: %v", request.Code) shell := getShell() cmd := exec.CommandContext(ctx, shell, "-c", request.Code) cmd.Dir = request.Cwd // Configure credentials and process group cred, err := buildCredential(request.Uid, request.Gid) if err != nil { log.Error("failed to build credentials: %v", err) } cmd.SysProcAttr = &syscall.SysProcAttr{ Setpgid: true, Credential: cred, } cmd.Stdout = pipe cmd.Stderr = pipe extraEnv := mergeExtraEnvs(loadExtraEnvFromFile(), request.Envs) cmd.Env = mergeEnvs(os.Environ(), extraEnv) // use DevNull as stdin so interactive programs exit immediately. devNull, err := os.Open(os.DevNull) if err == nil { cmd.Stdin = devNull defer devNull.Close() } err = cmd.Start() kernel := &commandKernel{ pid: -1, stdoutPath: stdoutPath, stderrPath: stderrPath, startedAt: startAt, running: true, content: request.Code, isBackground: true, } if err != nil { cancel() log.Error("CommandExecError: error starting commands: %v", err) kernel.running = false c.storeCommandKernel(session, kernel) c.markCommandFinished(session, 255, err.Error()) return fmt.Errorf("failed to start commands: %w", err) } safego.Go(func() { defer pipe.Close() kernel.running = true kernel.pid = cmd.Process.Pid c.storeCommandKernel(session, kernel) err = cmd.Wait() cancel() if err != nil { log.Error("CommandExecError: error running commands: %v", err) exitCode := 1 var exitError *exec.ExitError if errors.As(err, &exitError) { exitCode = exitError.ExitCode() } c.markCommandFinished(session, exitCode, err.Error()) return } c.markCommandFinished(session, 0, "") }) // ensure we kill the whole process group if the context is cancelled (e.g., timeout). safego.Go(func() { <-ctx.Done() if cmd.Process != nil { _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) // best-effort } }) request.Hooks.OnExecuteComplete(time.Since(startAt)) return nil } ================================================ FILE: components/execd/pkg/runtime/command_common.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package runtime import ( "bufio" "bytes" "fmt" "io" "os" "path/filepath" "sync" "time" ) // tailStdPipe streams appended log data until the process finishes. func (c *Controller) tailStdPipe(file string, onExecute func(text string), done <-chan struct{}) { lastPos := int64(0) ticker := time.NewTicker(100 * time.Millisecond) defer ticker.Stop() mutex := &sync.Mutex{} for { select { case <-done: c.readFromPos(mutex, file, lastPos, onExecute, true) return case <-ticker.C: newPos := c.readFromPos(mutex, file, lastPos, onExecute, false) lastPos = newPos } } } // getCommandKernel retrieves a command execution context. func (c *Controller) getCommandKernel(sessionID string) *commandKernel { if v, ok := c.commandClientMap.Load(sessionID); ok { if kernel, ok := v.(*commandKernel); ok { return kernel } } return nil } // storeCommandKernel registers a command execution context. func (c *Controller) storeCommandKernel(sessionID string, kernel *commandKernel) { c.commandClientMap.Store(sessionID, kernel) } // stdLogDescriptor creates temporary files for capturing command output. // It ensures the temp directory exists before opening files, so that commands // continue to work even after the /tmp directory has been removed and recreated. func (c *Controller) stdLogDescriptor(session string) (io.WriteCloser, io.WriteCloser, error) { logDir := os.TempDir() if err := os.MkdirAll(logDir, 0o755); err != nil { return nil, nil, fmt.Errorf("failed to create temp dir %s: %w", logDir, err) } stdout, err := os.OpenFile(c.stdoutFileName(session), os.O_RDWR|os.O_CREATE|os.O_TRUNC, os.ModePerm) if err != nil { return nil, nil, err } stderr, err := os.OpenFile(c.stderrFileName(session), os.O_RDWR|os.O_CREATE|os.O_TRUNC, os.ModePerm) if err != nil { stdout.Close() return nil, nil, err } return stdout, stderr, nil } func (c *Controller) combinedOutputDescriptor(session string) (io.WriteCloser, error) { logDir := os.TempDir() if err := os.MkdirAll(logDir, 0o755); err != nil { return nil, fmt.Errorf("failed to create temp dir %s: %w", logDir, err) } return os.OpenFile(c.combinedOutputFileName(session), os.O_RDWR|os.O_CREATE|os.O_TRUNC, os.ModePerm) } // stdoutFileName constructs the stdout log path. func (c *Controller) stdoutFileName(session string) string { return filepath.Join(os.TempDir(), session+".stdout") } // stderrFileName constructs the stderr log path. func (c *Controller) stderrFileName(session string) string { return filepath.Join(os.TempDir(), session+".stderr") } func (c *Controller) combinedOutputFileName(session string) string { return filepath.Join(os.TempDir(), session+".output") } // readFromPos streams new content from a file starting at startPos. func (c *Controller) readFromPos(mutex *sync.Mutex, filepath string, startPos int64, onExecute func(string), flushIncomplete bool) int64 { if !mutex.TryLock() { return -1 } defer mutex.Unlock() file, err := os.Open(filepath) if err != nil { return startPos } defer file.Close() _, _ = file.Seek(startPos, 0) //nolint:errcheck reader := bufio.NewReader(file) var buffer bytes.Buffer var currentPos int64 = startPos for { b, err := reader.ReadByte() if err != nil { if err == io.EOF { // If buffer has content but no newline, flush if needed, otherwise wait for next read if flushIncomplete && buffer.Len() > 0 { onExecute(buffer.String()) buffer.Reset() } } break } currentPos++ // Check if it's a line terminator (\n or \r) if b == '\n' || b == '\r' { // If buffer has content, output this line if buffer.Len() > 0 { onExecute(buffer.String()) buffer.Reset() } // Skip line terminator continue } buffer.WriteByte(b) } endPos, _ := file.Seek(0, 1) // If the last read position doesn't end with a newline, return buffer start position and wait for next flush if !flushIncomplete && buffer.Len() > 0 { return currentPos - int64(buffer.Len()) } return endPos } ================================================ FILE: components/execd/pkg/runtime/command_status.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package runtime import ( "fmt" "io" "os" "time" ) // CommandStatus describes the lifecycle state of a command. type CommandStatus struct { Session string `json:"session"` Running bool `json:"running"` ExitCode *int `json:"exit_code,omitempty"` Error string `json:"error,omitempty"` StartedAt time.Time `json:"started_at,omitempty"` FinishedAt *time.Time `json:"finished_at,omitempty"` Content string `json:"content,omitempty"` } // CommandOutput contains non-streamed stdout/stderr plus status. type CommandOutput struct { CommandStatus Stdout string `json:"stdout"` Stderr string `json:"stderr"` } func (c *Controller) commandSnapshot(session string) *commandKernel { var kernel *commandKernel if v, ok := c.commandClientMap.Load(session); ok { kernel, _ = v.(*commandKernel) } if kernel == nil { return nil } cp := *kernel return &cp } // GetCommandStatus returns the execution status for a command session. func (c *Controller) GetCommandStatus(session string) (*CommandStatus, error) { kernel := c.commandSnapshot(session) if kernel == nil { return nil, fmt.Errorf("command not found: %s", session) } status := &CommandStatus{ Session: session, Running: kernel.running, ExitCode: kernel.exitCode, Error: kernel.errMsg, StartedAt: kernel.startedAt, FinishedAt: kernel.finishedAt, Content: kernel.content, } return status, nil } // SeekBackgroundCommandOutput returns accumulated stdout/stderr and status for a session. func (c *Controller) SeekBackgroundCommandOutput(session string, cursor int64) ([]byte, int64, error) { kernel := c.commandSnapshot(session) if kernel == nil { return nil, -1, fmt.Errorf("command not found: %s", session) } if !kernel.isBackground { return nil, -1, fmt.Errorf("command %s is not running in background", session) } file, err := os.Open(kernel.stdoutPath) if err != nil { return nil, -1, fmt.Errorf("error open combined output file for command %s: %w", session, err) } defer file.Close() // Seek to the cursor position _, err = file.Seek(cursor, 0) if err != nil { return nil, -1, fmt.Errorf("error seek file: %w", err) } // Read all content from cursor to end data, err := io.ReadAll(file) if err != nil { return nil, -1, fmt.Errorf("error read file: %w", err) } // Get current file position (end of file) currentPos, err := file.Seek(0, 1) if err != nil { return nil, -1, fmt.Errorf("error get current position: %w", err) } return data, currentPos, nil } // markCommandFinished updates bookkeeping when a command exits. func (c *Controller) markCommandFinished(session string, exitCode int, errMsg string) { now := time.Now() c.mu.Lock() defer c.mu.Unlock() var kernel *commandKernel if v, ok := c.commandClientMap.Load(session); ok { kernel, _ = v.(*commandKernel) } if kernel == nil { return } kernel.exitCode = &exitCode kernel.errMsg = errMsg kernel.running = false kernel.finishedAt = &now } ================================================ FILE: components/execd/pkg/runtime/command_status_test.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package runtime import ( "context" "os" "path/filepath" "strings" "testing" "time" "github.com/stretchr/testify/require" ) func TestGetCommandStatus_NotFound(t *testing.T) { c := NewController("", "") _, err := c.GetCommandStatus("missing") require.Error(t, err, "expected error for missing session") } func TestGetCommandStatus_Running(t *testing.T) { c := NewController("", "") var session string req := &ExecuteCodeRequest{ Language: BackgroundCommand, Code: "sleep 2", Hooks: ExecuteResultHook{ OnExecuteInit: func(id string) { session = id }, OnExecuteComplete: func(time.Duration) {}, }, } ctx, cancel := context.WithCancel(context.Background()) require.NoError(t, c.runBackgroundCommand(ctx, cancel, req)) require.NotEmpty(t, session, "session should be set by OnExecuteInit") // Poll until status is registered (runBackgroundCommand stores kernel asynchronously). deadline := time.Now().Add(5 * time.Second) var ( status *CommandStatus err error ) for time.Now().Before(deadline) { status, err = c.GetCommandStatus(session) if err == nil { break } if strings.Contains(err.Error(), "not found") { time.Sleep(50 * time.Millisecond) continue } require.NoError(t, err, "GetCommandStatus unexpected error") } require.NoError(t, err, "GetCommandStatus error after retry") require.NotNil(t, status) require.True(t, status.Running, "expected running=true") require.Nil(t, status.ExitCode, "expected exitCode to be nil while running") require.Nil(t, status.FinishedAt, "expected finishedAt to be nil while running") require.False(t, status.StartedAt.IsZero(), "expected startedAt to be set") t.Log(status) } func TestSeekBackgroundCommandOutput_Completed(t *testing.T) { c := NewController("", "") tmpDir := t.TempDir() session := "sess-done" stdoutPath := filepath.Join(tmpDir, session+".stdout") stdoutContent := "hello stdout" require.NoError(t, os.WriteFile(stdoutPath, []byte(stdoutContent), 0o644)) started := time.Now().Add(-2 * time.Second) finished := time.Now() exitCode := 0 kernel := &commandKernel{ pid: 456, stdoutPath: stdoutPath, isBackground: true, startedAt: started, finishedAt: &finished, exitCode: &exitCode, errMsg: "", running: false, } c.storeCommandKernel(session, kernel) output, cursor, err := c.SeekBackgroundCommandOutput(session, 0) require.NoError(t, err, "GetCommandOutput error") require.Greater(t, cursor, int64(0), "expected cursor>=0") require.Equal(t, stdoutContent, string(output)) } func TestSeekBackgroundCommandOutput_WithRunBackgroundCommand(t *testing.T) { c := NewController("", "") expected := "line1\nline2\n" var session string req := &ExecuteCodeRequest{ Language: BackgroundCommand, Code: "printf 'line1\nline2\n'", Hooks: ExecuteResultHook{ OnExecuteInit: func(id string) { session = id }, OnExecuteComplete: func(executionTime time.Duration) {}, // other hooks unused in this test }, } ctx, cancel := context.WithCancel(context.Background()) require.NoError(t, c.runBackgroundCommand(ctx, cancel, req)) require.NotEmpty(t, session, "session should be set by OnExecuteInit") var ( output []byte cursor int64 err error ) deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { output, cursor, err = c.SeekBackgroundCommandOutput(session, 0) if err == nil && len(output) > 0 { break } time.Sleep(100 * time.Millisecond) } require.NoError(t, err, "SeekBackgroundCommandOutput error") require.Equal(t, expected, string(output)) require.GreaterOrEqual(t, cursor, int64(len(expected)), "cursor should advance to end of file") // incremental seek from current cursor should return empty data and same-or-higher cursor output2, cursor2, err := c.SeekBackgroundCommandOutput(session, cursor) require.NoError(t, err, "SeekBackgroundCommandOutput (second call) error") require.Empty(t, output2, "expected no new output") require.GreaterOrEqual(t, cursor2, cursor, "cursor should not move backwards") } ================================================ FILE: components/execd/pkg/runtime/command_test.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package runtime import ( "context" "os" "os/exec" "path/filepath" "strings" "sync" "testing" "time" goruntime "runtime" "github.com/alibaba/opensandbox/execd/pkg/jupyter/execute" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestReadFromPos_SplitsOnCRAndLF(t *testing.T) { tmp := t.TempDir() logFile := filepath.Join(tmp, "stdout.log") mutex := &sync.Mutex{} initial := "line1\nprog 10%\rprog 20%\rprog 30%\nlast\n" require.NoError(t, os.WriteFile(logFile, []byte(initial), 0o644)) var got []string c := &Controller{} nextPos := c.readFromPos(mutex, logFile, 0, func(s string) { got = append(got, s) }, false) want := []string{"line1", "prog 10%", "prog 20%", "prog 30%", "last"} require.Len(t, got, len(want)) for i := range want { require.Equal(t, want[i], got[i], "token[%d] mismatch", i) } // append more content and ensure incremental read only yields the new part appendPart := "tail1\r\ntail2\n" f, err := os.OpenFile(logFile, os.O_APPEND|os.O_WRONLY, 0o644) require.NoError(t, err) _, err = f.WriteString(appendPart) require.NoError(t, err, "append write") _ = f.Close() got = got[:0] c.readFromPos(mutex, logFile, nextPos, func(s string) { got = append(got, s) }, false) want = []string{"tail1", "tail2"} require.Len(t, got, len(want)) for i := range want { require.Equal(t, want[i], got[i], "incremental token[%d] mismatch", i) } } func TestReadFromPos_LongLine(t *testing.T) { tmp := t.TempDir() logFile := filepath.Join(tmp, "stdout.log") // construct a single line larger than the default 64KB, but under 5MB longLine := strings.Repeat("x", 256*1024) + "\n" // 256KB require.NoError(t, os.WriteFile(logFile, []byte(longLine), 0o644)) var got []string c := &Controller{} c.readFromPos(&sync.Mutex{}, logFile, 0, func(s string) { got = append(got, s) }, false) require.Len(t, got, 1, "expected one token") require.Equal(t, strings.TrimSuffix(longLine, "\n"), got[0], "long line mismatch") } func TestReadFromPos_FlushesTrailingLine(t *testing.T) { tmpDir := t.TempDir() file := filepath.Join(tmpDir, "stdout.log") content := []byte("line1\nlastline-without-newline") err := os.WriteFile(file, content, 0o644) assert.NoError(t, err) c := NewController("", "") mutex := &sync.Mutex{} var lines []string onExecute := func(text string) { lines = append(lines, text) } // First read: should only get complete lines with newlines pos := c.readFromPos(mutex, file, 0, onExecute, false) assert.GreaterOrEqual(t, pos, int64(0)) assert.Equal(t, []string{"line1"}, lines) // Flush at end: should output the last line (without newline) c.readFromPos(mutex, file, pos, onExecute, true) assert.Equal(t, []string{"line1", "lastline-without-newline"}, lines) } func TestRunCommand_Echo(t *testing.T) { if goruntime.GOOS == "windows" { t.Skip("bash not available on windows") } if _, err := exec.LookPath("bash"); err != nil { t.Skip("bash not found in PATH") } c := NewController("", "") ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() var ( sessionID string stdoutLines []string stderrLines []string completeCh = make(chan struct{}, 1) ) req := &ExecuteCodeRequest{ Code: `echo "hello"; echo "errline" 1>&2`, Cwd: t.TempDir(), Timeout: 5 * time.Second, Hooks: ExecuteResultHook{ OnExecuteInit: func(s string) { sessionID = s }, OnExecuteStdout: func(s string) { stdoutLines = append(stdoutLines, s) }, OnExecuteStderr: func(s string) { stderrLines = append(stderrLines, s) }, OnExecuteError: func(err *execute.ErrorOutput) { require.Failf(t, "unexpected error hook", "%+v", err) }, OnExecuteComplete: func(_ time.Duration) { completeCh <- struct{}{} }, }, } require.NoError(t, c.runCommand(ctx, req)) select { case <-completeCh: case <-time.After(2 * time.Second): require.Fail(t, "timeout waiting for completion hook") } require.NotEmpty(t, sessionID, "expected session id to be set") require.Equal(t, []string{"hello"}, stdoutLines) require.Equal(t, []string{"errline"}, stderrLines) } func TestRunCommand_Error(t *testing.T) { if goruntime.GOOS == "windows" { t.Skip("bash not available on windows") } if _, err := exec.LookPath("bash"); err != nil { t.Skip("bash not found in PATH") } c := NewController("", "") ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() var ( sessionID string gotErr *execute.ErrorOutput completeCh = make(chan struct{}, 2) stdoutLines []string stderrLines []string ) req := &ExecuteCodeRequest{ Code: `echo "before"; exit 3`, Cwd: t.TempDir(), Timeout: 5 * time.Second, Hooks: ExecuteResultHook{ OnExecuteInit: func(s string) { sessionID = s }, OnExecuteStdout: func(s string) { stdoutLines = append(stdoutLines, s) }, OnExecuteStderr: func(s string) { stderrLines = append(stderrLines, s) }, OnExecuteError: func(err *execute.ErrorOutput) { gotErr = err completeCh <- struct{}{} }, OnExecuteComplete: func(_ time.Duration) { completeCh <- struct{}{} }, }, } require.NoError(t, c.runCommand(ctx, req)) select { case <-completeCh: case <-time.After(2 * time.Second): require.Fail(t, "timeout waiting for completion hook") } require.NotEmpty(t, sessionID, "expected session id to be set") require.Equal(t, []string{"before"}, stdoutLines) require.Empty(t, stderrLines, "expected no stderr") require.NotNil(t, gotErr, "expected error hook to be called") require.Equal(t, "CommandExecError", gotErr.EName) require.Equal(t, "3", gotErr.EValue) } // TestStdLogDescriptor_AutoCreatesTempDir verifies that stdLogDescriptor // recreates the temp directory when it has been deleted, rather than failing. // Regression test for https://github.com/alibaba/OpenSandbox/issues/400. func TestStdLogDescriptor_AutoCreatesTempDir(t *testing.T) { if goruntime.GOOS == "windows" { t.Skip("TMPDIR env var has no effect on Windows") } // Point os.TempDir() at a path that does not yet exist. missingDir := filepath.Join(t.TempDir(), "deleted_tmp") t.Setenv("TMPDIR", missingDir) c := NewController("", "") stdout, stderr, err := c.stdLogDescriptor("test-session") require.NoError(t, err) stdout.Close() stderr.Close() // The directory must have been created. info, err := os.Stat(missingDir) require.NoError(t, err, "expected temp dir to be created, stat error") require.True(t, info.IsDir(), "expected %s to be a directory", missingDir) } // TestCombinedOutputDescriptor_AutoCreatesTempDir verifies that // combinedOutputDescriptor also recreates the temp directory when missing. // Regression test for https://github.com/alibaba/OpenSandbox/issues/400. func TestCombinedOutputDescriptor_AutoCreatesTempDir(t *testing.T) { if goruntime.GOOS == "windows" { t.Skip("TMPDIR env var has no effect on Windows") } missingDir := filepath.Join(t.TempDir(), "deleted_tmp") t.Setenv("TMPDIR", missingDir) c := NewController("", "") f, err := c.combinedOutputDescriptor("test-session") require.NoError(t, err) f.Close() info, err := os.Stat(missingDir) require.NoError(t, err, "expected temp dir to be created, stat error") require.True(t, info.IsDir(), "expected %s to be a directory", missingDir) } ================================================ FILE: components/execd/pkg/runtime/command_windows.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. //go:build windows // +build windows package runtime import ( "context" "errors" "fmt" "os" "os/exec" "strconv" "time" "github.com/alibaba/opensandbox/execd/pkg/jupyter/execute" "github.com/alibaba/opensandbox/execd/pkg/log" "github.com/alibaba/opensandbox/execd/pkg/util/safego" ) // runCommand executes shell commands and streams their output on Windows. func (c *Controller) runCommand(ctx context.Context, request *ExecuteCodeRequest) error { session := c.newContextID() request.Hooks.OnExecuteInit(session) stdout, stderr, err := c.stdLogDescriptor(session) if err != nil { return fmt.Errorf("failed to get stdlog descriptor: %w", err) } startAt := time.Now() log.Info("received command: %v", request.Code) cmd := exec.CommandContext(ctx, "cmd", "/C", request.Code) cmd.Stdout = stdout cmd.Stderr = stderr cmd.Dir = request.Cwd extraEnv := mergeExtraEnvs(loadExtraEnvFromFile(), request.Envs) cmd.Env = mergeEnvs(os.Environ(), extraEnv) done := make(chan struct{}, 1) safego.Go(func() { c.tailStdPipe(c.stdoutFileName(session), request.Hooks.OnExecuteStdout, done) }) safego.Go(func() { c.tailStdPipe(c.stderrFileName(session), request.Hooks.OnExecuteStderr, done) }) err = cmd.Start() if err != nil { request.Hooks.OnExecuteError(&execute.ErrorOutput{EName: "CommandExecError", EValue: err.Error()}) log.Error("CommandExecError: error starting commands: %v", err) return nil } kernel := &commandKernel{ pid: cmd.Process.Pid, content: request.Code, isBackground: false, } c.storeCommandKernel(session, kernel) err = cmd.Wait() close(done) if err != nil { var eName, eValue string var traceback []string var exitError *exec.ExitError if errors.As(err, &exitError) { exitCode := exitError.ExitCode() eName = "CommandExecError" eValue = strconv.Itoa(exitCode) } else { eName = "CommandExecError" eValue = err.Error() } traceback = []string{err.Error()} request.Hooks.OnExecuteError(&execute.ErrorOutput{ EName: eName, EValue: eValue, Traceback: traceback, }) log.Error("CommandExecError: error running commands: %v", err) return nil } request.Hooks.OnExecuteComplete(time.Since(startAt)) return nil } // runBackgroundCommand executes shell commands in detached mode on Windows. func (c *Controller) runBackgroundCommand(ctx context.Context, cancel context.CancelFunc, request *ExecuteCodeRequest) error { session := c.newContextID() request.Hooks.OnExecuteInit(session) pipe, err := c.combinedOutputDescriptor(session) if err != nil { return fmt.Errorf("failed to get combined output descriptor: %w", err) } stdoutPath := c.combinedOutputFileName(session) stderrPath := c.combinedOutputFileName(session) startAt := time.Now() log.Info("received command: %v", request.Code) cmd := exec.CommandContext(ctx, "cmd", "/C", request.Code) cmd.Dir = request.Cwd cmd.Stdout = pipe cmd.Stderr = pipe extraEnv := mergeExtraEnvs(loadExtraEnvFromFile(), request.Envs) cmd.Env = mergeEnvs(os.Environ(), extraEnv) devNull, _ := os.OpenFile(os.DevNull, os.O_RDWR, 0) // best-effort, ignore error cmd.Stdin = devNull safego.Go(func() { err := cmd.Start() if err != nil { log.Error("CommandExecError: error starting commands: %v", err) pipe.Close() // best-effort cancel() return } kernel := &commandKernel{ pid: cmd.Process.Pid, content: request.Code, stdoutPath: stdoutPath, stderrPath: stderrPath, startedAt: startAt, running: true, isBackground: true, } c.storeCommandKernel(session, kernel) safego.Go(func() { <-ctx.Done() if cmd.Process != nil { _ = cmd.Process.Kill() // best-effort } }) err = cmd.Wait() cancel() pipe.Close() // best-effort devNull.Close() // best-effort if err != nil { log.Error("CommandExecError: error running commands: %v", err) exitCode := 1 var exitError *exec.ExitError if errors.As(err, &exitError) { exitCode = exitError.ExitCode() } c.markCommandFinished(session, exitCode, err.Error()) return } c.markCommandFinished(session, 0, "") }) request.Hooks.OnExecuteComplete(time.Since(startAt)) return nil } ================================================ FILE: components/execd/pkg/runtime/context.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package runtime import ( "errors" "fmt" "net/http" "os" "path/filepath" "strings" "github.com/google/uuid" "k8s.io/client-go/util/retry" "github.com/alibaba/opensandbox/execd/pkg/jupyter" jupytersession "github.com/alibaba/opensandbox/execd/pkg/jupyter/session" "github.com/alibaba/opensandbox/execd/pkg/log" ) // CreateContext provisions a kernel-backed session and returns its ID. // Bash language uses Jupyter kernel like other languages; for pipe-based bash sessions use CreateBashSession (session API). func (c *Controller) CreateContext(req *CreateContextRequest) (string, error) { // Create a new Jupyter session. var ( client *jupyter.Client session *jupytersession.Session err error ) err = retry.OnError(kernelWaitingBackoff, func(err error) bool { log.Error("failed to create session, retrying: %v", err) return err != nil }, func() error { client, session, err = c.createJupyterContext(*req) return err }) if err != nil { return "", err } kernel := &jupyterKernel{ kernelID: session.Kernel.ID, client: client, language: req.Language, } c.storeJupyterKernel(session.ID, kernel) err = c.setWorkingDir(kernel, req) if err != nil { return "", fmt.Errorf("failed to setup working dir: %w", err) } return session.ID, nil } func (c *Controller) DeleteContext(session string) error { return c.deleteSessionAndCleanup(session) } func (c *Controller) GetContext(session string) (CodeContext, error) { kernel := c.getJupyterKernel(session) if kernel == nil { return CodeContext{}, ErrContextNotFound } return CodeContext{ ID: session, Language: kernel.language, }, nil } func (c *Controller) ListContext(language string) ([]CodeContext, error) { switch language { case Command.String(), BackgroundCommand.String(), SQL.String(): return nil, fmt.Errorf("unsupported language context operation: %s", language) case "": return c.listAllContexts() default: return c.listLanguageContexts(Language(language)) } } func (c *Controller) DeleteLanguageContext(language Language) error { contexts, err := c.listLanguageContexts(language) if err != nil { return err } seen := make(map[string]struct{}) for _, context := range contexts { if _, ok := seen[context.ID]; ok { continue } seen[context.ID] = struct{}{} if err := c.deleteSessionAndCleanup(context.ID); err != nil { return fmt.Errorf("error deleting context %s: %w", context.ID, err) } } return nil } func (c *Controller) deleteSessionAndCleanup(session string) error { if c.getJupyterKernel(session) == nil { return ErrContextNotFound } if err := c.jupyterClient().DeleteSession(session); err != nil { return err } c.jupyterClientMap.Delete(session) c.deleteDefaultSessionByID(session) return nil } func (c *Controller) newContextID() string { return strings.ReplaceAll(uuid.New().String(), "-", "") } func (c *Controller) newIpynbPath(sessionID, cwd string) (string, error) { if cwd != "" { err := os.MkdirAll(cwd, os.ModePerm) if err != nil { return "", err } } return filepath.Join(cwd, fmt.Sprintf("%s.ipynb", sessionID)), nil } // createDefaultLanguageJupyterContext prewarms a session for stateless execution. func (c *Controller) createDefaultLanguageJupyterContext(language Language) error { if c.getDefaultLanguageSession(language) != "" { return nil } var ( client *jupyter.Client session *jupytersession.Session err error ) err = retry.OnError(kernelWaitingBackoff, func(err error) bool { log.Error("failed to create context, retrying: %v", err) return err != nil }, func() error { client, session, err = c.createJupyterContext(CreateContextRequest{ Language: language, Cwd: "", }) return err }) if err != nil { return err } c.setDefaultLanguageSession(language, session.ID) c.jupyterClientMap.Store(session.ID, &jupyterKernel{ kernelID: session.Kernel.ID, client: client, language: language, }) return nil } // createJupyterContext performs the actual context creation workflow. func (c *Controller) createJupyterContext(request CreateContextRequest) (*jupyter.Client, *jupytersession.Session, error) { client := c.jupyterClient() kernel, err := c.searchKernel(client, request.Language) if err != nil { return nil, nil, err } sessionID := c.newContextID() ipynb, err := c.newIpynbPath(sessionID, request.Cwd) if err != nil { return nil, nil, err } jupyterSession, err := client.CreateSession(sessionID, ipynb, kernel) if err != nil { return nil, nil, err } kernels, err := client.ListKernels() if err != nil { return nil, nil, err } found := false for _, k := range kernels { if k.ID == jupyterSession.Kernel.ID { found = true break } } if !found { return nil, nil, errors.New("kernel not found") } return client, jupyterSession, nil } // storeJupyterKernel caches a session -> kernel mapping. func (c *Controller) storeJupyterKernel(sessionID string, kernel *jupyterKernel) { c.jupyterClientMap.Store(sessionID, kernel) } func (c *Controller) jupyterClient() *jupyter.Client { httpClient := &http.Client{ Transport: &jupyter.AuthTransport{ Token: c.token, Base: http.DefaultTransport, }, } return jupyter.NewClient(c.baseURL, jupyter.WithToken(c.token), jupyter.WithHTTPClient(httpClient)) } func (c *Controller) getDefaultLanguageSession(language Language) string { if v, ok := c.defaultLanguageSessions.Load(language); ok { if session, ok := v.(string); ok { return session } } return "" } func (c *Controller) setDefaultLanguageSession(language Language, sessionID string) { c.defaultLanguageSessions.Store(language, sessionID) } func (c *Controller) deleteDefaultSessionByID(sessionID string) { c.defaultLanguageSessions.Range(func(key, value any) bool { if s, ok := value.(string); ok && s == sessionID { c.defaultLanguageSessions.Delete(key) } return true }) } func (c *Controller) listAllContexts() ([]CodeContext, error) { contexts := make([]CodeContext, 0) c.jupyterClientMap.Range(func(key, value any) bool { session, _ := key.(string) if kernel, ok := value.(*jupyterKernel); ok && kernel != nil { contexts = append(contexts, CodeContext{ID: session, Language: kernel.language}) } return true }) c.defaultLanguageSessions.Range(func(key, value any) bool { lang, _ := key.(Language) session, _ := value.(string) if session == "" { return true } contexts = append(contexts, CodeContext{ID: session, Language: lang}) return true }) return contexts, nil } func (c *Controller) listLanguageContexts(language Language) ([]CodeContext, error) { contexts := make([]CodeContext, 0) c.jupyterClientMap.Range(func(key, value any) bool { session, _ := key.(string) if kernel, ok := value.(*jupyterKernel); ok && kernel != nil && kernel.language == language { contexts = append(contexts, CodeContext{ID: session, Language: language}) } return true }) if defaultContext := c.getDefaultLanguageSession(language); defaultContext != "" { contexts = append(contexts, CodeContext{ID: defaultContext, Language: language}) } return contexts, nil } ================================================ FILE: components/execd/pkg/runtime/context_test.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package runtime import ( "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "github.com/stretchr/testify/require" ) func TestListContextsAndNewIpynbPath(t *testing.T) { c := NewController("http://example", "token") c.jupyterClientMap.Store("session-python", &jupyterKernel{language: Python}) c.defaultLanguageSessions.Store(Go, "session-go-default") pyContexts, err := c.listLanguageContexts(Python) require.NoError(t, err) require.Len(t, pyContexts, 1) require.Equal(t, "session-python", pyContexts[0].ID) require.Equal(t, Python, pyContexts[0].Language) allContexts, err := c.listAllContexts() require.NoError(t, err) require.Len(t, allContexts, 2) tmpDir := filepath.Join(t.TempDir(), "nested") path, err := c.newIpynbPath("abc123", tmpDir) require.NoError(t, err) _, statErr := os.Stat(tmpDir) require.NoError(t, statErr, "expected directory to be created") expected := filepath.Join(tmpDir, "abc123.ipynb") require.Equal(t, expected, path) } func TestNewContextID_UniqueAndLength(t *testing.T) { c := NewController("", "") id1 := c.newContextID() id2 := c.newContextID() require.NotEmpty(t, id1) require.NotEmpty(t, id2) require.NotEqual(t, id1, id2, "expected unique ids") require.Len(t, id1, 32) require.Len(t, id2, 32) } func TestNewIpynbPath_ErrorWhenCwdIsFile(t *testing.T) { c := NewController("", "") tmpFile := filepath.Join(t.TempDir(), "file.txt") require.NoError(t, os.WriteFile(tmpFile, []byte("x"), 0o644)) _, err := c.newIpynbPath("abc", tmpFile) require.Error(t, err, "expected error when cwd is a file") } func TestListContextUnsupportedLanguage(t *testing.T) { c := NewController("", "") _, err := c.ListContext(Command.String()) require.Error(t, err, "expected error for command language") _, err = c.ListContext(BackgroundCommand.String()) require.Error(t, err, "expected error for background-command language") _, err = c.ListContext(SQL.String()) require.Error(t, err, "expected error for sql language") } func TestDeleteContext_NotFound(t *testing.T) { c := NewController("", "") err := c.DeleteContext("missing") require.Error(t, err, "expected ErrContextNotFound") require.ErrorIs(t, err, ErrContextNotFound) } func TestGetContext_NotFound(t *testing.T) { c := NewController("", "") _, err := c.GetContext("missing") require.Error(t, err, "expected ErrContextNotFound") require.ErrorIs(t, err, ErrContextNotFound) } func TestDeleteContext_RemovesCacheOnSuccess(t *testing.T) { sessionID := "sess-123" // mock jupyter server that accepts DELETE server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, http.MethodDelete, r.Method, "unexpected method") require.True(t, strings.HasSuffix(r.URL.Path, "/api/sessions/"+sessionID), "unexpected path: %s", r.URL.Path) w.WriteHeader(http.StatusNoContent) })) defer server.Close() c := NewController(server.URL, "token") c.jupyterClientMap.Store(sessionID, &jupyterKernel{language: Python}) c.defaultLanguageSessions.Store(Python, sessionID) require.NoError(t, c.DeleteContext(sessionID)) require.Nil(t, c.getJupyterKernel(sessionID), "expected cache to be cleared") _, ok := c.defaultLanguageSessions.Load(Python) require.False(t, ok, "expected default session entry to be removed") } func TestDeleteLanguageContext_RemovesCacheOnSuccess(t *testing.T) { lang := Python session1 := "sess-1" session2 := "sess-2" // mock jupyter server to accept two deletes deleteCalls := make(map[string]int) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, http.MethodDelete, r.Method, "unexpected method") if strings.Contains(r.URL.Path, session1) { deleteCalls[session1]++ } else if strings.Contains(r.URL.Path, session2) { deleteCalls[session2]++ } else { require.Failf(t, "unexpected path", "%s", r.URL.Path) } w.WriteHeader(http.StatusNoContent) })) defer server.Close() c := NewController(server.URL, "token") c.jupyterClientMap.Store(session1, &jupyterKernel{language: lang}) c.jupyterClientMap.Store(session2, &jupyterKernel{language: lang}) c.defaultLanguageSessions.Store(lang, session2) require.NoError(t, c.DeleteLanguageContext(lang)) _, ok := c.jupyterClientMap.Load(session1) require.False(t, ok, "expected session1 removed from cache") _, ok = c.jupyterClientMap.Load(session2) require.False(t, ok, "expected session2 removed from cache") _, ok = c.defaultLanguageSessions.Load(lang) require.False(t, ok, "expected default entry removed") require.Equal(t, 1, deleteCalls[session1]) require.Equal(t, 1, deleteCalls[session2]) } ================================================ FILE: components/execd/pkg/runtime/ctrl.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package runtime import ( "context" "database/sql" "fmt" "sync" "time" "k8s.io/apimachinery/pkg/util/wait" "github.com/alibaba/opensandbox/execd/pkg/jupyter" ) var kernelWaitingBackoff = wait.Backoff{ Steps: 60, Duration: 500 * time.Millisecond, Factor: 1.5, Jitter: 0.1, } // Controller manages code execution across runtimes. type Controller struct { baseURL string token string mu sync.RWMutex jupyterClientMap sync.Map // map[sessionID]*jupyterKernel defaultLanguageSessions sync.Map // map[Language]string commandClientMap sync.Map // map[sessionID]*commandKernel bashSessionClientMap sync.Map // map[sessionID]*bashSession db *sql.DB dbOnce sync.Once } type jupyterKernel struct { mu sync.Mutex kernelID string client *jupyter.Client language Language } type commandKernel struct { pid int stdoutPath string stderrPath string startedAt time.Time finishedAt *time.Time exitCode *int errMsg string running bool isBackground bool content string } // NewController creates a runtime controller. func NewController(baseURL, token string) *Controller { return &Controller{ baseURL: baseURL, token: token, } } // Execute dispatches a request to the correct backend. func (c *Controller) Execute(request *ExecuteCodeRequest) error { var cancel context.CancelFunc var ctx context.Context if request.Timeout > 0 { ctx, cancel = context.WithTimeout(context.Background(), request.Timeout) } else { ctx, cancel = context.WithCancel(context.Background()) } switch request.Language { case Command: defer cancel() return c.runCommand(ctx, request) case BackgroundCommand: return c.runBackgroundCommand(ctx, cancel, request) case Bash, Python, Java, JavaScript, TypeScript, Go: defer cancel() return c.runJupyter(ctx, request) case SQL: defer cancel() return c.runSQL(ctx, request) default: defer cancel() return fmt.Errorf("unknown language: %s", request.Language) } } ================================================ FILE: components/execd/pkg/runtime/env.go ================================================ // Copyright 2026 Alibaba Group Holding Ltd. // // 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. package runtime import ( "fmt" "os" "strings" "github.com/alibaba/opensandbox/execd/pkg/log" ) // loadExtraEnvFromFile reads key=value lines from EXECD_ENVS (if set). // Empty lines and lines starting with '#' are ignored. func loadExtraEnvFromFile() map[string]string { path := os.Getenv("EXECD_ENVS") if path == "" { return nil } data, err := os.ReadFile(path) if err != nil { log.Warn("EXECD_ENVS: failed to read file %s: %v", path, err) return nil } envs := make(map[string]string) lines := strings.Split(string(data), "\n") for _, line := range lines { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } kv := strings.SplitN(line, "=", 2) if len(kv) != 2 { log.Warn("EXECD_ENVS: skip malformed line: %s", line) continue } envs[kv[0]] = os.ExpandEnv(kv[1]) } return envs } // mergeEnvs overlays extra into base and returns a merged slice. func mergeEnvs(base []string, extra map[string]string) []string { if len(extra) == 0 { return base } merged := make(map[string]string, len(base)+len(extra)) for _, kv := range base { pair := strings.SplitN(kv, "=", 2) if len(pair) == 2 { merged[pair[0]] = pair[1] } } for k, v := range extra { merged[k] = v } out := make([]string, 0, len(merged)) for k, v := range merged { out = append(out, fmt.Sprintf("%s=%s", k, v)) } return out } // mergeExtraEnvs merges environment maps from file and request-level overrides. func mergeExtraEnvs(fromFile, fromRequest map[string]string) map[string]string { if len(fromRequest) == 0 { return fromFile } merged := make(map[string]string, len(fromFile)+len(fromRequest)) for k, v := range fromFile { merged[k] = v } for k, v := range fromRequest { merged[k] = v } return merged } ================================================ FILE: components/execd/pkg/runtime/env_test.go ================================================ // Copyright 2026 Alibaba Group Holding Ltd. // // 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. package runtime import ( "os" "path/filepath" "strings" "testing" "github.com/stretchr/testify/require" ) func TestLoadExtraEnvFromFileUnset(t *testing.T) { t.Setenv("EXECD_ENVS", "") require.Nil(t, loadExtraEnvFromFile(), "expected nil when EXECD_ENVS unset") } func TestLoadExtraEnvFromFileParsesAndExpands(t *testing.T) { dir := t.TempDir() envFile := filepath.Join(dir, "env") t.Setenv("EXECD_ENVS", envFile) t.Setenv("BASE_DIR", "/opt/base") content := strings.Join([]string{ "# comment", "FOO=bar", "PATH=$BASE_DIR/bin", "MALFORMED", "EMPTY=", "", }, "\n") require.NoError(t, os.WriteFile(envFile, []byte(content), 0o644)) got := loadExtraEnvFromFile() require.Len(t, got, 3) require.Equal(t, "bar", got["FOO"]) require.Equal(t, "/opt/base/bin", got["PATH"]) val, ok := got["EMPTY"] require.True(t, ok) require.Equal(t, "", val) } func TestLoadExtraEnvFromFileMissingFile(t *testing.T) { dir := t.TempDir() envFile := filepath.Join(dir, "does-not-exist") t.Setenv("EXECD_ENVS", envFile) require.Nil(t, loadExtraEnvFromFile(), "expected nil for missing file") } func TestMergeEnvsOverlaysExtra(t *testing.T) { base := []string{"A=1", "B=2"} extra := map[string]string{"B": "override", "C": "3"} merged := mergeEnvs(base, extra) got := make(map[string]string) for _, kv := range merged { parts := strings.SplitN(kv, "=", 2) if len(parts) == 2 { got[parts[0]] = parts[1] } } require.Len(t, got, 3) require.Equal(t, "1", got["A"]) require.Equal(t, "override", got["B"]) require.Equal(t, "3", got["C"]) } func TestMergeExtraEnvsMergesAndOverrides(t *testing.T) { fromFile := map[string]string{"A": "1", "B": "2"} fromRequest := map[string]string{"B": "override", "C": "3"} got := mergeExtraEnvs(fromFile, fromRequest) require.Len(t, got, 3) require.Equal(t, "1", got["A"]) require.Equal(t, "override", got["B"]) require.Equal(t, "3", got["C"]) } func TestMergeExtraEnvsHandlesNilFromFile(t *testing.T) { fromRequest := map[string]string{"ONLY": "request"} got := mergeExtraEnvs(nil, fromRequest) require.Len(t, got, 1) require.Equal(t, "request", got["ONLY"]) } ================================================ FILE: components/execd/pkg/runtime/errors.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package runtime import "errors" var ErrContextNotFound = errors.New("context not found") ================================================ FILE: components/execd/pkg/runtime/helpers_test.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package runtime import ( "context" "database/sql" "database/sql/driver" "errors" "fmt" "io" "sync/atomic" "testing" "time" "github.com/stretchr/testify/require" ) type stubDriver struct { columns []string rows [][]driver.Value execRowsAffected int64 queryErr error execErr error pingErr error execCalled int32 queryCalled int32 } type stubConn struct { d *stubDriver } func (c *stubConn) Prepare(string) (driver.Stmt, error) { return nil, errors.New("not implemented") } func (c *stubConn) Close() error { return nil } func (c *stubConn) Begin() (driver.Tx, error) { return nil, errors.New("not implemented") } func (c *stubConn) Ping(context.Context) error { return c.d.pingErr } func (c *stubConn) ExecContext(_ context.Context, _ string, _ []driver.NamedValue) (driver.Result, error) { atomic.AddInt32(&c.d.execCalled, 1) if c.d.execErr != nil { return nil, c.d.execErr } return driver.RowsAffected(c.d.execRowsAffected), nil } func (c *stubConn) QueryContext(_ context.Context, _ string, _ []driver.NamedValue) (driver.Rows, error) { atomic.AddInt32(&c.d.queryCalled, 1) if c.d.queryErr != nil { return nil, c.d.queryErr } return &stubRows{ columns: c.d.columns, rows: c.d.rows, }, nil } type stubRows struct { columns []string rows [][]driver.Value idx int } func (r *stubRows) Columns() []string { return r.columns } func (r *stubRows) Close() error { return nil } func (r *stubRows) Next(dest []driver.Value) error { if r.idx >= len(r.rows) { return io.EOF } row := r.rows[r.idx] r.idx++ for i, v := range row { dest[i] = v } return nil } type stubConnector struct { d *stubDriver } func (c *stubConnector) Connect(context.Context) (driver.Conn, error) { return &stubConn{d: c.d}, nil } func (c *stubConnector) Driver() driver.Driver { return c } func (c *stubConnector) Open(string) (driver.Conn, error) { return &stubConn{d: c.d}, nil } func newStubDB(t *testing.T, d *stubDriver) *sql.DB { t.Helper() driverName := fmt.Sprintf("stub-%d", time.Now().UnixNano()) sql.Register(driverName, &stubConnector{d: d}) db, err := sql.Open(driverName, "") require.NoError(t, err) return db } ================================================ FILE: components/execd/pkg/runtime/interrupt.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. //go:build !windows // +build !windows package runtime import ( "errors" "fmt" "os" "strings" "syscall" "time" "github.com/alibaba/opensandbox/execd/pkg/log" ) // Interrupt stops execution in the specified session. func (c *Controller) Interrupt(sessionID string) error { switch { case c.getJupyterKernel(sessionID) != nil: kernel := c.getJupyterKernel(sessionID) log.Warning("Interrupting Jupyter kernel %s", kernel.kernelID) return kernel.client.InterruptKernel(kernel.kernelID) case c.getCommandKernel(sessionID) != nil: kernel := c.getCommandKernel(sessionID) return c.killPid(kernel.pid) case c.getBashSession(sessionID) != nil: return c.closeBashSession(sessionID) default: return errors.New("no such session") } } // killPid sends SIGTERM followed by SIGKILL if needed. func (c *Controller) killPid(pid int) error { process, err := os.FindProcess(pid) if err != nil { return err } log.Warning("Attempting to terminate process %d", pid) if err := process.Signal(syscall.SIGTERM); err != nil { if strings.Contains(err.Error(), "already finished") { return nil } log.Warning("SIGTERM failed for pid %d: %v, trying SIGKILL", pid, err) } else { done := make(chan error, 1) go func() { _, err := process.Wait() done <- err }() select { case err := <-done: if err == nil { log.Info("Process %d terminated gracefully", pid) return nil } case <-time.After(3 * time.Second): log.Warning("Process %d did not terminate after SIGTERM, using SIGKILL", pid) } } if err := process.Signal(syscall.SIGKILL); err != nil { if strings.Contains(err.Error(), "already finished") { return nil } return fmt.Errorf("failed to kill process %d: %w", pid, err) } for range 3 { if err := process.Signal(syscall.Signal(0)); err != nil { if strings.Contains(err.Error(), "already finished") || strings.Contains(err.Error(), "no such process") { log.Info("Process %d confirmed terminated", pid) return nil } } time.Sleep(50 * time.Millisecond) } return fmt.Errorf("process %d might still be running", pid) } ================================================ FILE: components/execd/pkg/runtime/interrupt_windows.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. //go:build windows // +build windows package runtime import ( "errors" "fmt" "os" "time" "github.com/alibaba/opensandbox/execd/pkg/log" ) // Interrupt stops execution in the specified session. func (c *Controller) Interrupt(sessionID string) error { switch { case c.getJupyterKernel(sessionID) != nil: kernel := c.getJupyterKernel(sessionID) log.Warning("Interrupting Jupyter kernel %s", kernel.kernelID) return kernel.client.InterruptKernel(kernel.kernelID) case c.getCommandKernel(sessionID) != nil: kernel := c.getCommandKernel(sessionID) return c.killPid(kernel.pid) default: return errors.New("no such session") } } // killPid terminates a process on Windows. func (c *Controller) killPid(pid int) error { process, err := os.FindProcess(pid) if err != nil { return err } log.Warning("Attempting to terminate process %d", pid) if err := process.Kill(); err != nil { return fmt.Errorf("failed to kill process %d: %w", pid, err) } // Best-effort wait to reduce zombies; os.Process.Wait only works for child processes. done := make(chan error, 1) go func() { _, err := process.Wait() done <- err }() select { case <-done: case <-time.After(3 * time.Second): log.Warning("Process %d kill wait timed out", pid) } return nil } ================================================ FILE: components/execd/pkg/runtime/jupyter.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package runtime import ( "context" "errors" "github.com/alibaba/opensandbox/execd/pkg/jupyter" "github.com/alibaba/opensandbox/execd/pkg/jupyter/execute" "github.com/alibaba/opensandbox/execd/pkg/log" ) // runJupyter executes code through a Jupyter kernel. func (c *Controller) runJupyter(ctx context.Context, request *ExecuteCodeRequest) error { if c.baseURL == "" || c.token == "" { return errors.New("language runtime server not configured, please check your image runtime") } if request.Context == "" { if c.getDefaultLanguageSession(request.Language) == "" { if err := c.createDefaultLanguageJupyterContext(request.Language); err != nil { return err } } } var targetSessionID string if request.Context == "" { targetSessionID = c.getDefaultLanguageSession(request.Language) } else { targetSessionID = request.Context } kernel := c.getJupyterKernel(targetSessionID) if kernel == nil { return ErrContextNotFound } request.SetDefaultHooks() request.Hooks.OnExecuteInit(targetSessionID) return c.runJupyterCode(ctx, kernel, request) } // runJupyterCode streams execution results for a single kernel. // //nolint:gocognit // complex due to hook handling; refactor later func (c *Controller) runJupyterCode(ctx context.Context, kernel *jupyterKernel, request *ExecuteCodeRequest) error { if !kernel.mu.TryLock() { return errors.New("session is busy") } defer kernel.mu.Unlock() err := kernel.client.ConnectToKernel(kernel.kernelID) if err != nil { return err } defer kernel.client.DisconnectFromKernel(kernel.kernelID) results := make(chan *execute.ExecutionResult, 10) err = kernel.client.ExecuteCodeStream(kernel.kernelID, request.Code, results) if err != nil { return err } for { select { case result := <-results: if result == nil { return nil } if result.ExecutionCount > 0 || len(result.ExecutionData) > 0 { request.Hooks.OnExecuteResult(result.ExecutionData, result.ExecutionCount) } if result.Status != "" { request.Hooks.OnExecuteStatus(result.Status) } if result.ExecutionTime > 0 { request.Hooks.OnExecuteComplete(result.ExecutionTime) } if result.Error != nil { request.Hooks.OnExecuteError(result.Error) } if len(result.Stream) > 0 { for _, stream := range result.Stream { switch stream.Name { case execute.StreamStdout: request.Hooks.OnExecuteStdout(stream.Text) case execute.StreamStderr: request.Hooks.OnExecuteStderr(stream.Text) default: } } } case <-ctx.Done(): log.Warning("context cancelled, try to interrupt kernel") err = kernel.client.InterruptKernel(kernel.kernelID) if err != nil { log.Error("interrupt kernel failed: %v", err) } request.Hooks.OnExecuteError(&execute.ErrorOutput{ EName: "ContextCancelled", EValue: "Interrupt kernel", }) return errors.New("context cancelled, interrupt kernel") } } } // setWorkingDir configures the working directory for a kernel session. func (c *Controller) setWorkingDir(_ *jupyterKernel, _ *CreateContextRequest) error { return nil } // getJupyterKernel retrieves a kernel connection from the session map. func (c *Controller) getJupyterKernel(sessionID string) *jupyterKernel { if v, ok := c.jupyterClientMap.Load(sessionID); ok { if kernel, ok := v.(*jupyterKernel); ok { return kernel } } return nil } // searchKernel finds a kernel spec name for the given language. func (c *Controller) searchKernel(client *jupyter.Client, language Language) (string, error) { specs, err := client.GetKernelSpecs() if err != nil { return "", err } if len(specs.Kernelspecs) == 0 { return "", errors.New("no kernel specs found") } var kernelName string for name, spec := range specs.Kernelspecs { if name == "python3" { continue } if spec.Spec.Language == language.String() { kernelName = name } } if kernelName == "" { return "", errors.New("no kernel specs found") } return kernelName, nil } ================================================ FILE: components/execd/pkg/runtime/language.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package runtime // Language represents the programming language or execution mode type Language string const ( Command Language = "command" Bash Language = "bash" Python Language = "python" Java Language = "java" JavaScript Language = "javascript" TypeScript Language = "typescript" Go Language = "go" SQL Language = "sql" BackgroundCommand Language = "background-command" ) // String returns the string representation of the language func (l Language) String() string { return string(l) } ================================================ FILE: components/execd/pkg/runtime/sql.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package runtime import ( "context" "database/sql" "encoding/json" "errors" "fmt" "strings" "time" "github.com/google/uuid" _ "github.com/go-sql-driver/mysql" "github.com/alibaba/opensandbox/execd/pkg/jupyter/execute" "github.com/alibaba/opensandbox/execd/pkg/log" ) // QueryResult represents a SQL query response. type QueryResult struct { Columns []string `json:"columns,omitempty"` Rows [][]any `json:"rows,omitempty"` Error string `json:"error,omitempty"` } // runSQL executes SQL queries based on their type. func (c *Controller) runSQL(ctx context.Context, request *ExecuteCodeRequest) error { request.Hooks.OnExecuteInit(uuid.New().String()) err := c.initDB() if err != nil { request.Hooks.OnExecuteError(&execute.ErrorOutput{EName: "DBInitError", EValue: err.Error()}) log.Error("DBInitError: error initializing db server: %v", err) return err } err = c.db.PingContext(ctx) if err != nil { request.Hooks.OnExecuteError(&execute.ErrorOutput{EName: "DBPingError", EValue: err.Error()}) log.Error("DBPingError: error pinging db server: %v", err) return err } switch c.getQueryType(request.Code) { case "SELECT": return c.executeSelectSQLQuery(ctx, request) default: return c.executeUpdateSQLQuery(ctx, request) } } // executeSelectSQLQuery handles SELECT statements. func (c *Controller) executeSelectSQLQuery(ctx context.Context, request *ExecuteCodeRequest) error { startAt := time.Now() rows, err := c.db.QueryContext(ctx, request.Code) if err != nil { request.Hooks.OnExecuteError(&execute.ErrorOutput{EName: "DBQueryError", EValue: err.Error()}) return nil } defer rows.Close() columns, err := rows.Columns() if err != nil { request.Hooks.OnExecuteError(&execute.ErrorOutput{EName: "DBQueryError", EValue: err.Error()}) return nil } var result [][]any values := make([]any, len(columns)) scanArgs := make([]any, len(columns)) for i := range values { scanArgs[i] = &values[i] } for rows.Next() { err := rows.Scan(scanArgs...) if err != nil { request.Hooks.OnExecuteError(&execute.ErrorOutput{EName: "RowScanError", EValue: err.Error()}) return nil } row := make([]any, len(columns)) for i, v := range values { if v == nil { row[i] = nil } else { row[i] = fmt.Sprintf("%v", v) } } result = append(result, row) } if err := rows.Err(); err != nil { request.Hooks.OnExecuteError(&execute.ErrorOutput{EName: "RowIterationError", EValue: err.Error()}) return nil } queryResult := QueryResult{ Columns: columns, Rows: result, } bytes, err := json.Marshal(queryResult) if err != nil { request.Hooks.OnExecuteError(&execute.ErrorOutput{EName: "JSONMarshalError", EValue: err.Error()}) return nil } request.Hooks.OnExecuteResult( map[string]any{ "text/plain": string(bytes), }, 1, ) request.Hooks.OnExecuteComplete(time.Since(startAt)) return nil } // executeUpdateSQLQuery handles non-SELECT statements. func (c *Controller) executeUpdateSQLQuery(ctx context.Context, request *ExecuteCodeRequest) error { startAt := time.Now() result, err := c.db.ExecContext(ctx, request.Code) if err != nil { request.Hooks.OnExecuteError(&execute.ErrorOutput{EName: "DBExecError", EValue: err.Error()}) return err } affected, _ := result.RowsAffected() queryResult := QueryResult{ Rows: [][]any{{affected}}, Columns: []string{"affected_rows"}, } bytes, err := json.Marshal(queryResult) if err != nil { request.Hooks.OnExecuteError(&execute.ErrorOutput{EName: "JSONMarshalError", EValue: err.Error()}) return err } request.Hooks.OnExecuteResult( map[string]any{ "text/plain": string(bytes), }, 1, ) request.Hooks.OnExecuteComplete(time.Since(startAt)) return nil } // getQueryType extracts the first token to decide which executor to use. func (c *Controller) getQueryType(query string) string { fields := strings.Fields(query) if len(fields) == 0 { return "" } return strings.ToUpper(fields[0]) } // initDB lazily opens the local sandbox database. func (c *Controller) initDB() error { var initErr error c.dbOnce.Do(func() { dsn := "root:@tcp(127.0.0.1:3306)/" db, err := sql.Open("mysql", dsn) if err != nil { initErr = err return } err = db.Ping() if err != nil { initErr = err return } _, err = db.Exec("CREATE DATABASE IF NOT EXISTS sandbox") if err != nil { initErr = err return } _, err = db.Exec("USE sandbox") if err != nil { initErr = err return } c.db = db }) if initErr != nil { return initErr } if c.db == nil { return errors.New("db is not initialized") } return nil } ================================================ FILE: components/execd/pkg/runtime/sql_test.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package runtime import ( "context" "database/sql/driver" "encoding/json" "testing" "time" "github.com/alibaba/opensandbox/execd/pkg/jupyter/execute" "github.com/stretchr/testify/require" ) func TestExecuteSelectSQLQuery_Success(t *testing.T) { driver := &stubDriver{ columns: []string{"id", "name"}, rows: [][]driver.Value{ {int64(1), "alice"}, {int64(2), "bob"}, }, } db := newStubDB(t, driver) c := NewController("", "") c.db = db var ( gotResult map[string]any gotError *execute.ErrorOutput completed bool ) req := &ExecuteCodeRequest{ Code: "SELECT * FROM users", Hooks: ExecuteResultHook{ OnExecuteResult: func(result map[string]any, _ int) { gotResult = result }, OnExecuteError: func(err *execute.ErrorOutput) { gotError = err }, OnExecuteComplete: func(time.Duration) { completed = true }, }, } require.NoError(t, c.executeSelectSQLQuery(context.Background(), req)) require.Nil(t, gotError, "unexpected error hook") require.True(t, completed, "expected completion hook to be triggered") raw, ok := gotResult["text/plain"] require.True(t, ok, "expected text/plain payload") var qr QueryResult require.NoError(t, json.Unmarshal([]byte(raw.(string)), &qr)) require.Equal(t, []string{"id", "name"}, qr.Columns, "unexpected columns") require.Len(t, qr.Rows, 2, "unexpected rows") require.Equal(t, "1", qr.Rows[0][0]) require.Equal(t, "bob", qr.Rows[1][1]) } func TestExecuteUpdateSQLQuery_Success(t *testing.T) { driver := &stubDriver{ execRowsAffected: 3, } db := newStubDB(t, driver) c := NewController("", "") c.db = db var ( gotResult map[string]any gotError *execute.ErrorOutput completed bool ) req := &ExecuteCodeRequest{ Code: "UPDATE users SET name='alice' WHERE id=1", Hooks: ExecuteResultHook{ OnExecuteResult: func(result map[string]any, _ int) { gotResult = result }, OnExecuteError: func(err *execute.ErrorOutput) { gotError = err }, OnExecuteComplete: func(time.Duration) { completed = true }, }, } require.NoError(t, c.executeUpdateSQLQuery(context.Background(), req)) require.Nil(t, gotError, "unexpected error hook") require.True(t, completed, "expected completion hook to be triggered") raw, ok := gotResult["text/plain"] require.True(t, ok, "expected text/plain payload") var qr QueryResult require.NoError(t, json.Unmarshal([]byte(raw.(string)), &qr)) require.Equal(t, []string{"affected_rows"}, qr.Columns, "unexpected columns") require.Len(t, qr.Rows, 1, "unexpected rows length") require.Len(t, qr.Rows[0], 1, "unexpected row entry length") require.Equal(t, float64(3), qr.Rows[0][0]) } ================================================ FILE: components/execd/pkg/runtime/types.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package runtime import ( "fmt" "sync" "time" "github.com/alibaba/opensandbox/execd/pkg/jupyter/execute" ) // ExecuteResultHook groups execution callbacks. type ExecuteResultHook struct { OnExecuteInit func(context string) OnExecuteResult func(result map[string]any, count int) OnExecuteStatus func(status string) OnExecuteStdout func(stdout string) //nolint:predeclared OnExecuteStderr func(stderr string) //nolint:predeclared OnExecuteError func(err *execute.ErrorOutput) OnExecuteComplete func(executionTime time.Duration) } // ExecuteCodeRequest represents a code execution request with context and hooks. type ExecuteCodeRequest struct { Language Language `json:"language"` Code string `json:"code"` Context string `json:"context"` Timeout time.Duration `json:"timeout"` Cwd string `json:"cwd"` Envs map[string]string `json:"envs"` Uid *uint32 `json:"uid,omitempty"` Gid *uint32 `json:"gid,omitempty"` Hooks ExecuteResultHook } // SetDefaultHooks installs stdout logging fallbacks for unset hooks. func (req *ExecuteCodeRequest) SetDefaultHooks() { if req.Hooks.OnExecuteResult == nil { req.Hooks.OnExecuteResult = func(result map[string]any, count int) { fmt.Printf("OnExecuteResult: %d, %++v\n", count, result) } } if req.Hooks.OnExecuteStatus == nil { req.Hooks.OnExecuteStatus = func(status string) { fmt.Printf("OnExecuteStatus: %s\n", status) } } if req.Hooks.OnExecuteStdout == nil { req.Hooks.OnExecuteStdout = func(stdout string) { fmt.Printf("OnExecuteStdout: %s\n", stdout) } } if req.Hooks.OnExecuteStderr == nil { req.Hooks.OnExecuteStderr = func(stderr string) { fmt.Printf("OnExecuteStderr: %s\n", stderr) } } if req.Hooks.OnExecuteError == nil { req.Hooks.OnExecuteError = func(err *execute.ErrorOutput) { fmt.Printf("OnExecuteError: %++v\n", err) } } if req.Hooks.OnExecuteComplete == nil { req.Hooks.OnExecuteComplete = func(executionTime time.Duration) { fmt.Printf("OnExecuteComplete: %v\n", executionTime) } } if req.Hooks.OnExecuteInit == nil { req.Hooks.OnExecuteInit = func(session string) { fmt.Printf("OnExecuteInit: %s\n", session) } } } // CreateContextRequest represents a stateful session creation request. type CreateContextRequest struct { Language Language `json:"language"` Cwd string `json:"cwd"` } type CodeContext struct { ID string `json:"id,omitempty"` Language Language `json:"language"` } // bashSessionConfig holds bash session configuration. type bashSessionConfig struct { // StartupSource is a list of scripts sourced on startup. StartupSource []string // Session is the session identifier. Session string // StartupTimeout is the startup timeout. StartupTimeout time.Duration // Cwd is the working directory. Cwd string } // bashSession represents a bash session. type bashSession struct { config *bashSessionConfig mu sync.Mutex started bool env map[string]string cwd string // currentProcessPid is the pid of the active run's process group leader (bash). // Set after cmd.Start(), cleared when run() returns. Used by close() to kill the process group. currentProcessPid int } ================================================ FILE: components/execd/pkg/runtime/types_test.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package runtime import ( "reflect" "testing" "github.com/stretchr/testify/require" ) func TestExecuteCodeRequest_SetDefaultHooks(t *testing.T) { customResult := func(map[string]any, int) {} req := &ExecuteCodeRequest{ Hooks: ExecuteResultHook{ OnExecuteResult: customResult, }, } req.SetDefaultHooks() require.NotNil(t, req.Hooks.OnExecuteStdout) require.NotNil(t, req.Hooks.OnExecuteStderr) require.NotNil(t, req.Hooks.OnExecuteError) require.NotNil(t, req.Hooks.OnExecuteResult, "expected OnExecuteResult to remain set") require.Equal(t, reflect.ValueOf(customResult).Pointer(), reflect.ValueOf(req.Hooks.OnExecuteResult).Pointer(), "default hooks should not override existing ones") } ================================================ FILE: components/execd/pkg/util/glob/index.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package glob func findUnescapedByteIndex(s string, c byte, allowEscaping bool) int { l := len(s) for i := 0; i < l; i++ { if allowEscaping && s[i] == '\\' { // skip next byte i++ } else if s[i] == c { return i } } return -1 } // findMatchedClosingAltIndex finds the matching `}` for a `{`. func findMatchedClosingAltIndex(s string, allowEscaping bool) int { return findMatchedClosingSymbolsIndex(s, allowEscaping, '{', '}', 1) } // findMatchedClosingBracketIndex finds the matching `)` for a `(`. func findMatchedClosingBracketIndex(s string, allowEscaping bool) int { return findMatchedClosingSymbolsIndex(s, allowEscaping, '(', ')', 0) } // findNextCommaIndex returns the next comma outside nested braces. func findNextCommaIndex(s string, allowEscaping bool) int { alts := 1 l := len(s) for i := 0; i < l; i++ { if allowEscaping && s[i] == '\\' { i++ } else if s[i] == '{' { alts++ } else if s[i] == '}' { alts-- } else if s[i] == ',' && alts == 1 { return i } } return -1 } func findMatchedClosingSymbolsIndex(s string, allowEscaping bool, left, right uint8, begin int) int { l := len(s) for i := 0; i < l; i++ { if allowEscaping && s[i] == '\\' { i++ } else if s[i] == left { begin++ } else if s[i] == right { if begin--; begin == 0 { return i } } } return -1 } ================================================ FILE: components/execd/pkg/util/glob/match.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. // // This code is based on or derived from doublestar // Copyright (c) 2014 Bob Matcuk // Licensed under MIT License // https://github.com/bmatcuk/doublestar/blob/master/LICENSE package glob import ( "path/filepath" "unicode/utf8" globutil "github.com/bmatcuk/doublestar/v4" ) // PathMatch is filepath.Match compatible but honors doublestar semantics. func PathMatch(pattern, name string) (bool, error) { return matchWithSeparator(pattern, name, filepath.Separator, true) } func matchWithSeparator(pattern, name string, separator rune, validate bool) (matched bool, err error) { return doMatchWithSeparator(pattern, name, separator, validate, -1, -1, -1, -1, 0, 0) } //nolint:gocognit,nestif,gocyclo,maintidx func doMatchWithSeparator(pattern, name string, separator rune, validate bool, doublestarPatternBacktrack, doublestarNameBacktrack, starPatternBacktrack, starNameBacktrack, patIdx, nameIdx int) (matched bool, err error) { patLen := len(pattern) nameLen := len(name) startOfSegment := true MATCH: for nameIdx < nameLen { if patIdx < patLen { switch pattern[patIdx] { case '*': if patIdx++; patIdx < patLen && pattern[patIdx] == '*' { // doublestar - must begin with a path separator, otherwise we'll patIdx++ if startOfSegment { if patIdx >= patLen { // pattern ends in `/**`: return true return true, nil } // doublestar must also end with a path separator, otherwise we're patRune, patRuneLen := utf8.DecodeRuneInString(pattern[patIdx:]) if patRune == separator { patIdx += patRuneLen doublestarPatternBacktrack = patIdx doublestarNameBacktrack = nameIdx starPatternBacktrack = -1 starNameBacktrack = -1 continue } } } startOfSegment = false starPatternBacktrack = patIdx starNameBacktrack = nameIdx continue case '?': startOfSegment = false nameRune, nameRuneLen := utf8.DecodeRuneInString(name[nameIdx:]) if nameRune == separator { // `?` cannot match the separator break } patIdx++ nameIdx += nameRuneLen continue case '[': startOfSegment = false if patIdx++; patIdx >= patLen { // class didn't end return false, globutil.ErrBadPattern } nameRune, nameRuneLen := utf8.DecodeRuneInString(name[nameIdx:]) matched := false negate := pattern[patIdx] == '!' || pattern[patIdx] == '^' if negate { patIdx++ } if patIdx >= patLen || pattern[patIdx] == ']' { // class didn't end or empty character class return false, globutil.ErrBadPattern } last := utf8.MaxRune for patIdx < patLen && pattern[patIdx] != ']' { patRune, patRuneLen := utf8.DecodeRuneInString(pattern[patIdx:]) patIdx += patRuneLen // match a range if last < utf8.MaxRune && patRune == '-' && patIdx < patLen && pattern[patIdx] != ']' { if pattern[patIdx] == '\\' { // next character is escaped patIdx++ } patRune, patRuneLen = utf8.DecodeRuneInString(pattern[patIdx:]) patIdx += patRuneLen if last <= nameRune && nameRune <= patRune { matched = true break } // didn't match range - reset `last` last = utf8.MaxRune continue } // not a range - check if the next rune is escaped if patRune == '\\' { patRune, patRuneLen = utf8.DecodeRuneInString(pattern[patIdx:]) patIdx += patRuneLen } // check if the rune matches if patRune == nameRune { matched = true break } // no matches yet last = patRune } if matched == negate { // failed to match - if we reached the end of the pattern, that means if patIdx >= patLen { return false, globutil.ErrBadPattern } break } closingIdx := findUnescapedByteIndex(pattern[patIdx:], ']', true) if closingIdx == -1 { // no closing `]` return false, globutil.ErrBadPattern } patIdx += closingIdx + 1 nameIdx += nameRuneLen continue case '!': negateIdx := patIdx // begin index of ( patIdx++ closingIdx := findMatchedClosingBracketIndex(pattern[patIdx:], separator != '\\') if closingIdx == -1 { return false, globutil.ErrBadPattern } closingIdx += patIdx result, err := doMatchWithSeparator(pattern[:negateIdx]+pattern[patIdx+1:closingIdx]+pattern[closingIdx+1:], name, separator, validate, doublestarPatternBacktrack, doublestarNameBacktrack, starPatternBacktrack, starNameBacktrack, negateIdx, nameIdx) if err != nil { return false, err } else if !result { return true, nil } else { return false, nil } case '{': startOfSegment = false //nolint:ineffassign beforeIdx := patIdx patIdx++ closingIdx := findMatchedClosingAltIndex(pattern[patIdx:], separator != '\\') if closingIdx == -1 { // no closing `}` return false, globutil.ErrBadPattern } closingIdx += patIdx for { commaIdx := findNextCommaIndex(pattern[patIdx:closingIdx], separator != '\\') if commaIdx == -1 { break } commaIdx += patIdx result, err := doMatchWithSeparator(pattern[:beforeIdx]+pattern[patIdx:commaIdx]+pattern[closingIdx+1:], name, separator, validate, doublestarPatternBacktrack, doublestarNameBacktrack, starPatternBacktrack, starNameBacktrack, beforeIdx, nameIdx) if result || err != nil { return result, err } patIdx = commaIdx + 1 } return doMatchWithSeparator(pattern[:beforeIdx]+pattern[patIdx:closingIdx]+pattern[closingIdx+1:], name, separator, validate, doublestarPatternBacktrack, doublestarNameBacktrack, starPatternBacktrack, starNameBacktrack, beforeIdx, nameIdx) case '\\': if separator != '\\' { // next rune is "escaped" in the pattern - literal match if patIdx++; patIdx >= patLen { // pattern ended return false, globutil.ErrBadPattern } } fallthrough default: patRune, patRuneLen := utf8.DecodeRuneInString(pattern[patIdx:]) nameRune, nameRuneLen := utf8.DecodeRuneInString(name[nameIdx:]) if patRune != nameRune { if separator != '\\' && patIdx > 0 && pattern[patIdx-1] == '\\' { // if this rune was meant to be escaped, we need to move patIdx patIdx-- } break } patIdx += patRuneLen nameIdx += nameRuneLen startOfSegment = patRune == separator continue } } if starPatternBacktrack >= 0 { // `*` backtrack, but only if the `name` rune isn't the separator nameRune, nameRuneLen := utf8.DecodeRuneInString(name[starNameBacktrack:]) if nameRune != separator { starNameBacktrack += nameRuneLen patIdx = starPatternBacktrack nameIdx = starNameBacktrack startOfSegment = false continue } } if doublestarPatternBacktrack >= 0 { // `**` backtrack, advance `name` past next separator nameIdx = doublestarNameBacktrack for nameIdx < nameLen { nameRune, nameRuneLen := utf8.DecodeRuneInString(name[nameIdx:]) nameIdx += nameRuneLen if nameRune == separator { doublestarNameBacktrack = nameIdx patIdx = doublestarPatternBacktrack startOfSegment = true continue MATCH } } } if validate && patIdx < patLen && !isValidPattern(pattern[patIdx:], separator) { return false, globutil.ErrBadPattern } return false, nil } if nameIdx < nameLen { // we reached the end of `pattern` before the end of `name` return false, nil } // we've reached the end of `name`; we've successfully matched if we've also return isZeroLengthPattern(pattern[patIdx:], separator) } // nolint:nakedret func isZeroLengthPattern(pattern string, separator rune) (ret bool, err error) { // `/**` is a special case - a pattern such as `path/to/a/**` *should* match if pattern == "" || pattern == "*" || pattern == "**" || pattern == string(separator)+"**" { return true, nil } if pattern[0] == '{' { closingIdx := findMatchedClosingAltIndex(pattern[1:], separator != '\\') if closingIdx == -1 { // no closing '}' return false, globutil.ErrBadPattern } closingIdx += 1 patIdx := 1 for { commaIdx := findNextCommaIndex(pattern[patIdx:closingIdx], separator != '\\') if commaIdx == -1 { break } commaIdx += patIdx ret, err = isZeroLengthPattern(pattern[patIdx:commaIdx]+pattern[closingIdx+1:], separator) if ret || err != nil { return } patIdx = commaIdx + 1 } return isZeroLengthPattern(pattern[patIdx:closingIdx]+pattern[closingIdx+1:], separator) } // no luck - validate the rest of the pattern if !isValidPattern(pattern, separator) { return false, globutil.ErrBadPattern } return false, nil } ================================================ FILE: components/execd/pkg/util/glob/match_benchmark_test.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package glob import ( "path/filepath" "testing" ) func BenchmarkPathMatch(b *testing.B) { b.ReportAllocs() for i := 0; i < b.N; i++ { for _, tt := range matchTests { if tt.isStandard && tt.testOnDisk { pattern := filepath.FromSlash(tt.pattern) testPath := filepath.FromSlash(tt.testPath) PathMatch(pattern, testPath) } } } } ================================================ FILE: components/execd/pkg/util/glob/match_test.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. // // This code is based on or derived from doublestar // Copyright (c) 2014 Bob Matcuk // Licensed under MIT License // https://github.com/bmatcuk/doublestar/blob/master/LICENSE package glob import ( "path/filepath" "runtime" "strings" "testing" globutil "github.com/bmatcuk/doublestar/v4" ) type MatchTest struct { pattern, testPath string shouldMatch bool shouldMatchGlob bool expectedErr error expectIOErr bool expectPatternNotExist bool isStandard bool testOnDisk bool numResults int winNumResults int } // Tests which contain escapes and symlinks will not work on Windows var onWindows = runtime.GOOS == "windows" var matchTests = []MatchTest{ {"", "", true, false, nil, true, false, true, true, 0, 0}, {"*", "", true, true, nil, false, false, true, false, 0, 0}, {"*", "/", false, false, nil, false, false, true, false, 0, 0}, {"/*", "/", true, true, nil, false, false, true, false, 0, 0}, {"/*", "/debug/", false, false, nil, false, false, true, false, 0, 0}, {"/*", "//", false, false, nil, false, false, true, false, 0, 0}, {"abc", "abc", true, true, nil, false, false, true, true, 1, 1}, {"*", "abc", true, true, nil, false, false, true, true, 22, 17}, {"*c", "abc", true, true, nil, false, false, true, true, 2, 2}, {"*/", "a/", true, true, nil, false, false, true, false, 0, 0}, {"a*", "a", true, true, nil, false, false, true, true, 9, 9}, {"a*", "abc", true, true, nil, false, false, true, true, 9, 9}, {"a*", "ab/c", false, false, nil, false, false, true, true, 9, 9}, {"a*/b", "abc/b", true, true, nil, false, false, true, true, 2, 2}, {"a*/b", "a/c/b", false, false, nil, false, false, true, true, 2, 2}, {"a*/c/", "a/b", false, false, nil, false, false, false, true, 1, 1}, {"a*b*c*d*e*", "axbxcxdxe", true, true, nil, false, false, true, true, 3, 3}, {"a*b*c*d*e*/f", "axbxcxdxe/f", true, true, nil, false, false, true, true, 2, 2}, {"a*b*c*d*e*/f", "axbxcxdxexxx/f", true, true, nil, false, false, true, true, 2, 2}, {"a*b*c*d*e*/f", "axbxcxdxe/xxx/f", false, false, nil, false, false, true, true, 2, 2}, {"a*b*c*d*e*/f", "axbxcxdxexxx/fff", false, false, nil, false, false, true, true, 2, 2}, {"a*b?c*x", "abxbbxdbxebxczzx", true, true, nil, false, false, true, true, 2, 2}, {"a*b?c*x", "abxbbxdbxebxczzy", false, false, nil, false, false, true, true, 2, 2}, {"ab[c]", "abc", true, true, nil, false, false, true, true, 1, 1}, {"ab[b-d]", "abc", true, true, nil, false, false, true, true, 1, 1}, {"ab[e-g]", "abc", false, false, nil, false, false, true, true, 0, 0}, {"ab[^c]", "abc", false, false, nil, false, false, true, true, 0, 0}, {"ab[^b-d]", "abc", false, false, nil, false, false, true, true, 0, 0}, {"ab[^e-g]", "abc", true, true, nil, false, false, true, true, 1, 1}, {"a\\*b", "ab", false, false, nil, false, true, true, !onWindows, 0, 0}, {"a?b", "a☺b", true, true, nil, false, false, true, true, 1, 1}, {"a[^a]b", "a☺b", true, true, nil, false, false, true, true, 1, 1}, {"a[!a]b", "a☺b", true, true, nil, false, false, false, true, 1, 1}, {"a???b", "a☺b", false, false, nil, false, false, true, true, 0, 0}, {"a[^a][^a][^a]b", "a☺b", false, false, nil, false, false, true, true, 0, 0}, {"[a-ζ]*", "α", true, true, nil, false, false, true, true, 20, 17}, {"*[a-ζ]", "A", false, false, nil, false, false, true, true, 20, 17}, {"a?b", "a/b", false, false, nil, false, false, true, true, 1, 1}, {"a*b", "a/b", false, false, nil, false, false, true, true, 1, 1}, {"[\\]a]", "]", true, true, nil, false, false, true, !onWindows, 2, 2}, {"[\\-]", "-", true, true, nil, false, false, true, !onWindows, 1, 1}, {"[x\\-]", "x", true, true, nil, false, false, true, !onWindows, 2, 2}, {"[x\\-]", "-", true, true, nil, false, false, true, !onWindows, 2, 2}, {"[x\\-]", "z", false, false, nil, false, false, true, !onWindows, 2, 2}, {"[\\-x]", "x", true, true, nil, false, false, true, !onWindows, 2, 2}, {"[\\-x]", "-", true, true, nil, false, false, true, !onWindows, 2, 2}, {"[\\-x]", "a", false, false, nil, false, false, true, !onWindows, 2, 2}, {"[]a]", "]", false, false, globutil.ErrBadPattern, false, false, true, true, 0, 0}, // doublestar, like bash, allows these when path.Match() does not {"[-]", "-", true, true, nil, false, false, false, !onWindows, 1, 0}, {"[x-]", "x", true, true, nil, false, false, false, true, 2, 1}, {"[x-]", "-", true, true, nil, false, false, false, !onWindows, 2, 1}, {"[x-]", "z", false, false, nil, false, false, false, true, 2, 1}, {"[-x]", "x", true, true, nil, false, false, false, true, 2, 1}, {"[-x]", "-", true, true, nil, false, false, false, !onWindows, 2, 1}, {"[-x]", "a", false, false, nil, false, false, false, true, 2, 1}, {"[a-b-d]", "a", true, true, nil, false, false, false, true, 3, 2}, {"[a-b-d]", "b", true, true, nil, false, false, false, true, 3, 2}, {"[a-b-d]", "-", true, true, nil, false, false, false, !onWindows, 3, 2}, {"[a-b-d]", "c", false, false, nil, false, false, false, true, 3, 2}, {"[a-b-x]", "x", true, true, nil, false, false, false, true, 4, 3}, {"\\", "a", false, false, globutil.ErrBadPattern, false, false, true, !onWindows, 0, 0}, {"[", "a", false, false, globutil.ErrBadPattern, false, false, true, true, 0, 0}, {"[^", "a", false, false, globutil.ErrBadPattern, false, false, true, true, 0, 0}, {"[^bc", "a", false, false, globutil.ErrBadPattern, false, false, true, true, 0, 0}, {"a[", "a", false, false, globutil.ErrBadPattern, false, false, true, true, 0, 0}, {"a[", "ab", false, false, globutil.ErrBadPattern, false, false, true, true, 0, 0}, {"ad[", "ab", false, false, globutil.ErrBadPattern, false, false, true, true, 0, 0}, {"*x", "xxx", true, true, nil, false, false, true, true, 4, 4}, {"[abc]", "b", true, true, nil, false, false, true, true, 3, 3}, {"**", "", true, true, nil, false, false, false, false, 38, 38}, {"a/**", "a", true, false, nil, false, false, false, true, 7, 7}, {"a/**", "a/", true, true, nil, false, false, false, false, 7, 7}, {"a/**/", "a/", true, true, nil, false, false, false, false, 4, 4}, {"a/**", "a/b", true, true, nil, false, false, false, true, 7, 7}, {"a/**", "a/b/c", true, true, nil, false, false, false, true, 7, 7}, {"**/c", "c", true, true, nil, !onWindows, false, false, true, 5, 4}, {"**/c", "b/c", true, true, nil, !onWindows, false, false, true, 5, 4}, {"**/c", "a/b/c", true, true, nil, !onWindows, false, false, true, 5, 4}, {"**/c", "a/b", false, false, nil, !onWindows, false, false, true, 5, 4}, {"**/c", "abcd", false, false, nil, !onWindows, false, false, true, 5, 4}, {"**/c", "a/abc", false, false, nil, !onWindows, false, false, true, 5, 4}, {"a/**/b", "a/b", true, true, nil, false, false, false, true, 2, 2}, {"a/**/c", "a/b/c", true, true, nil, false, false, false, true, 2, 2}, {"a/**/d", "a/b/c/d", true, true, nil, false, false, false, true, 1, 1}, {"a/\\**", "a/b/c", false, false, nil, false, false, false, !onWindows, 0, 0}, {"a/\\[*\\]", "a/bc", false, false, nil, false, false, true, !onWindows, 0, 0}, // this fails the FilepathGlob test on Windows {"a/b/c", "a/b//c", false, false, nil, false, false, true, !onWindows, 1, 1}, // odd: Glob + filepath.Glob return results {"a/", "a", false, false, nil, false, false, true, false, 0, 0}, {"ab{c,d}", "abc", true, true, nil, false, true, false, true, 1, 1}, {"ab{c,d,*}", "abcde", true, true, nil, false, true, false, true, 5, 5}, {"ab{c,d}[", "abcd", false, false, globutil.ErrBadPattern, false, false, false, true, 0, 0}, {"a{,bc}", "a", true, true, nil, false, false, false, true, 2, 2}, {"a{,bc}", "abc", true, true, nil, false, false, false, true, 2, 2}, {"a/{b/c,c/b}", "a/b/c", true, true, nil, false, false, false, true, 2, 2}, {"a/{b/c,c/b}", "a/c/b", true, true, nil, false, false, false, true, 2, 2}, {"a/a*{b,c}", "a/abc", true, true, nil, false, false, false, true, 1, 1}, {"{a/{b,c},abc}", "a/b", true, true, nil, false, false, false, true, 3, 3}, {"{a/{b,c},abc}", "a/c", true, true, nil, false, false, false, true, 3, 3}, {"{a/{b,c},abc}", "abc", true, true, nil, false, false, false, true, 3, 3}, {"{a/{b,c},abc}", "a/b/c", false, false, nil, false, false, false, true, 3, 3}, {"{a/ab*}", "a/abc", true, true, nil, false, false, false, true, 1, 1}, {"{a/*}", "a/b", true, true, nil, false, false, false, true, 3, 3}, {"{a/abc}", "a/abc", true, true, nil, false, false, false, true, 1, 1}, {"{a/b,a/c}", "a/c", true, true, nil, false, false, false, true, 2, 2}, {"abc/**", "abc/b", true, true, nil, false, false, false, true, 3, 3}, {"**/abc", "abc", true, true, nil, !onWindows, false, false, true, 2, 2}, {"abc**", "abc/b", false, false, nil, false, false, false, true, 3, 3}, {"**/*.txt", "abc/ßtestß.txt", true, true, nil, !onWindows, false, false, true, 1, 1}, {"**/ß*", "abc/ßtestß.txt", true, true, nil, !onWindows, false, false, true, 1, 1}, {"**/{a,b}", "a/b", true, true, nil, !onWindows, false, false, true, 5, 5}, // unfortunately, io/fs can't handle this, so neither can Glob =( {"broken-symlink", "broken-symlink", true, true, nil, false, false, true, false, 1, 1}, {"broken-symlink/*", "a", false, false, nil, false, true, true, true, 0, 0}, {"broken*/*", "a", false, false, nil, false, false, true, true, 0, 0}, {"working-symlink/c/*", "working-symlink/c/d", true, true, nil, false, false, true, !onWindows, 1, 1}, {"working-sym*/*", "working-symlink/c", true, true, nil, false, false, true, !onWindows, 1, 1}, {"b/**/f", "b/symlink-dir/f", true, true, nil, false, false, false, !onWindows, 2, 2}, {"*/symlink-dir/*", "b/symlink-dir/f", true, true, nil, !onWindows, false, true, !onWindows, 2, 2}, {"e/**", "e/**", true, true, nil, false, false, false, !onWindows, 11, 6}, {"e/**", "e/*", true, true, nil, false, false, false, !onWindows, 11, 6}, {"e/**", "e/?", true, true, nil, false, false, false, !onWindows, 11, 6}, {"e/**", "e/[", true, true, nil, false, false, false, true, 11, 6}, {"e/**", "e/]", true, true, nil, false, false, false, true, 11, 6}, {"e/**", "e/[]", true, true, nil, false, false, false, true, 11, 6}, {"e/**", "e/{", true, true, nil, false, false, false, true, 11, 6}, {"e/**", "e/}", true, true, nil, false, false, false, true, 11, 6}, {"e/**", "e/\\", true, true, nil, false, false, false, !onWindows, 11, 6}, {"e/*", "e/*", true, true, nil, false, false, true, !onWindows, 10, 5}, {"e/?", "e/?", true, true, nil, false, false, true, !onWindows, 7, 4}, {"e/?", "e/*", true, true, nil, false, false, true, !onWindows, 7, 4}, {"e/?", "e/[", true, true, nil, false, false, true, true, 7, 4}, {"e/?", "e/]", true, true, nil, false, false, true, true, 7, 4}, {"e/?", "e/{", true, true, nil, false, false, true, true, 7, 4}, {"e/?", "e/}", true, true, nil, false, false, true, true, 7, 4}, {"e/\\[", "e/[", true, true, nil, false, false, true, !onWindows, 1, 1}, {"e/[", "e/[", false, false, globutil.ErrBadPattern, false, false, true, true, 0, 0}, {"e/]", "e/]", true, true, nil, false, false, true, true, 1, 1}, {"e/\\]", "e/]", true, true, nil, false, false, true, !onWindows, 1, 1}, {"e/\\{", "e/{", true, true, nil, false, false, true, !onWindows, 1, 1}, {"e/\\}", "e/}", true, true, nil, false, false, true, !onWindows, 1, 1}, {"e/[\\*\\?]", "e/*", true, true, nil, false, false, true, !onWindows, 2, 2}, {"e/[\\*\\?]", "e/?", true, true, nil, false, false, true, !onWindows, 2, 2}, {"e/[\\*\\?]", "e/**", false, false, nil, false, false, true, !onWindows, 2, 2}, {"e/[\\*\\?]?", "e/**", true, true, nil, false, false, true, !onWindows, 1, 1}, {"e/{\\*,\\?}", "e/*", true, true, nil, false, false, false, !onWindows, 2, 2}, {"e/{\\*,\\?}", "e/?", true, true, nil, false, false, false, !onWindows, 2, 2}, {"e/\\*", "e/*", true, true, nil, false, false, true, !onWindows, 1, 1}, {"e/\\?", "e/?", true, true, nil, false, false, true, !onWindows, 1, 1}, {"e/\\?", "e/**", false, false, nil, false, false, true, !onWindows, 1, 1}, {"*\\}", "}", true, true, nil, false, false, true, !onWindows, 1, 1}, {"nonexistent-path", "a", false, false, nil, false, true, true, true, 0, 0}, {"nonexistent-path/", "a", false, false, nil, false, true, true, true, 0, 0}, {"nonexistent-path/file", "a", false, false, nil, false, true, true, true, 0, 0}, {"nonexistent-path/*", "a", false, false, nil, false, true, true, true, 0, 0}, {"nonexistent-path/**", "a", false, false, nil, false, true, true, true, 0, 0}, {"nopermission/*", "nopermission/file", true, false, nil, true, false, true, !onWindows, 0, 0}, {"nopermission/dir/", "nopermission/dir", false, false, nil, true, false, true, !onWindows, 0, 0}, {"nopermission/file", "nopermission/file", true, false, nil, true, false, true, !onWindows, 0, 0}, {"node_modules/!(.cache)/**", "node_modules/others/file.txt", true, true, nil, false, false, false, !onWindows, 0, 0}, {"node_modules/!(.cache)/**", "node_modules/.cache/file.txt", false, false, nil, false, false, false, !onWindows, 0, 0}, {"node_modules/!(.cache)/**", "node_modules/file.txt", true, false, nil, false, false, false, !onWindows, 0, 0}, {"node_modules/!(.cache)/**", "node_modules/others/others/file.txt", true, true, nil, false, false, false, !onWindows, 0, 0}, } // numResultsFilesOnly memoizes results with WithFilesOnly. var numResultsFilesOnly []int // numResultsNoFollow memoizes results with WithNoFollow. var numResultsNoFollow []int // numResultsAllOpts memoizes counts with every option enabled. var numResultsAllOpts []int func TestValidatePattern(t *testing.T) { for idx, tt := range matchTests { testValidatePatternWith(t, idx, tt) } } func testValidatePatternWith(t *testing.T, idx int, tt MatchTest) { defer func() { if r := recover(); r != nil { t.Errorf("#%v. Validate(%#q) panicked: %#v", idx, tt.pattern, r) } }() result := isValidPattern(tt.pattern, '/') if result != (tt.expectedErr == nil) { t.Errorf("#%v. ValidatePattern(%#q) = %v want %v", idx, tt.pattern, result, !result) } } func TestPathMatch(t *testing.T) { for idx, tt := range matchTests { // Even though we aren't actually matching paths on disk, we are using if tt.testOnDisk { testPathMatchWith(t, idx, tt) } } } func testPathMatchWith(t *testing.T, idx int, tt MatchTest) { defer func() { if r := recover(); r != nil { t.Errorf("#%v. Match(%#q, %#q) panicked: %#v", idx, tt.pattern, tt.testPath, r) } }() pattern := filepath.FromSlash(tt.pattern) testPath := filepath.FromSlash(tt.testPath) ok, err := PathMatch(pattern, testPath) if ok != tt.shouldMatch || err != tt.expectedErr { t.Errorf("#%v. PathMatch(%#q, %#q) = %v, %v want %v, %v", idx, pattern, testPath, ok, err, tt.shouldMatch, tt.expectedErr) } if tt.isStandard { stdOk, stdErr := filepath.Match(pattern, testPath) if ok != stdOk || !compareErrors(err, stdErr) { t.Errorf("#%v. PathMatch(%#q, %#q) != filepath.Match(...). Got %v, %v want %v, %v", idx, pattern, testPath, ok, err, stdOk, stdErr) } } } func TestPathMatchFake(t *testing.T) { // This test fakes that our path separator is `\\` so we can test what it if onWindows { return } for idx, tt := range matchTests { // Even though we aren't actually matching paths on disk, we are using if tt.testOnDisk && !strings.Contains(tt.pattern, "\\") { testPathMatchFakeWith(t, idx, tt) } } } func testPathMatchFakeWith(t *testing.T, idx int, tt MatchTest) { defer func() { if r := recover(); r != nil { t.Errorf("#%v. Match(%#q, %#q) panicked: %#v", idx, tt.pattern, tt.testPath, r) } }() pattern := strings.ReplaceAll(tt.pattern, "/", "\\") testPath := strings.ReplaceAll(tt.testPath, "/", "\\") ok, err := matchWithSeparator(pattern, testPath, '\\', true) if ok != tt.shouldMatch || err != tt.expectedErr { t.Errorf("#%v. PathMatch(%#q, %#q) = %v, %v want %v, %v", idx, pattern, testPath, ok, err, tt.shouldMatch, tt.expectedErr) } } func compareErrors(a, b error) bool { if a == nil { return b == nil } return b != nil } ================================================ FILE: components/execd/pkg/util/glob/pattern.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package glob // isValidPattern checks whether a glob pattern is well-formed. // //nolint:gocognit func isValidPattern(s string, separator rune) bool { altDepth := 0 l := len(s) VALIDATE: for i := 0; i < l; i++ { switch s[i] { case '\\': if separator != '\\' { if i++; i >= l { return false } } continue case '[': if i++; i >= l { return false } if s[i] == '^' || s[i] == '!' { i++ } if i >= l || s[i] == ']' { return false } for ; i < l; i++ { if separator != '\\' && s[i] == '\\' { i++ } else if s[i] == ']' { continue VALIDATE } } return false case '{': altDepth++ continue case '}': if altDepth == 0 { return false } altDepth-- continue } } return altDepth == 0 } ================================================ FILE: components/execd/pkg/util/safego/safe.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package safego import ( "context" "log" "net/http" "runtime" runtimeutil "k8s.io/apimachinery/pkg/util/runtime" ) func InitPanicLogger(_ context.Context) { runtimeutil.PanicHandlers = []func(context.Context, any){ func(_ context.Context, r any) { if r == http.ErrAbortHandler { // nolint:errorlint return } const size = 64 << 10 stacktrace := make([]byte, size) stacktrace = stacktrace[:runtime.Stack(stacktrace, false)] if _, ok := r.(string); ok { log.Printf("Observed a panic: %s\n%s", r, stacktrace) } else { log.Printf("Observed a panic: %#v (%v)\n%s", r, r, stacktrace) } }, } } func init() { runtimeutil.ReallyCrash = false } func Go(f func()) { go func() { defer runtimeutil.HandleCrash() f() }() } ================================================ FILE: components/execd/pkg/util/safego/safe_test.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package safego import ( "context" "sync" "testing" ) func Test_Go(t *testing.T) { ctx, cancelFunc := context.WithCancel(context.Background()) defer cancelFunc() InitPanicLogger(ctx) var wg sync.WaitGroup wg.Add(1) Go(func() { defer wg.Done() panic("I'm done") }) wg.Wait() } ================================================ FILE: components/execd/pkg/web/controller/basic.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package controller import ( "encoding/json" "net/http" "strconv" "github.com/gin-gonic/gin" "github.com/alibaba/opensandbox/execd/pkg/web/model" ) type basicController struct { ctx *gin.Context } func newBasicController(ctx *gin.Context) *basicController { return &basicController{ctx: ctx} } func (c *basicController) RespondError(status int, code model.ErrorCode, message ...string) { resp := model.ErrorResponse{ Code: code, Message: "", } if len(message) > 0 { resp.Message = message[0] } c.ctx.JSON(status, resp) } func (c *basicController) RespondSuccess(data any) { if data == nil { c.ctx.Status(http.StatusOK) return } c.ctx.JSON(http.StatusOK, data) } func (c *basicController) QueryInt64(query string, defaultValue int64) int64 { val, err := strconv.ParseInt(query, 10, 64) if err != nil { return defaultValue } return val } func (c *basicController) bindJSON(target any) error { decoder := json.NewDecoder(c.ctx.Request.Body) return decoder.Decode(target) } ================================================ FILE: components/execd/pkg/web/controller/basic_test.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package controller import ( "encoding/json" "net/http" "net/http/httptest" "testing" "github.com/alibaba/opensandbox/execd/pkg/web/model" "github.com/stretchr/testify/require" ) func TestBasicControllerRespondSuccess(t *testing.T) { ctx, rec := newTestContext(http.MethodGet, "/", nil) ctrl := &basicController{ctx: ctx} payload := map[string]string{"status": "ok"} ctrl.RespondSuccess(payload) require.Equal(t, http.StatusOK, rec.Code) var resp map[string]string require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) require.Equal(t, "ok", resp["status"]) } func TestBasicControllerRespondError(t *testing.T) { ctx, rec := newTestContext(http.MethodGet, "/", nil) ctrl := &basicController{ctx: ctx} ctrl.RespondError(http.StatusBadRequest, model.ErrorCodeInvalidRequest, "boom") require.Equal(t, http.StatusBadRequest, rec.Code) var resp model.ErrorResponse require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) require.Equal(t, model.ErrorCodeInvalidRequest, resp.Code) require.Equal(t, "boom", resp.Message) } func setupBasicController(method string) (*basicController, *httptest.ResponseRecorder) { ctx, w := newTestContext(method, "/", nil) ctrl := &basicController{ctx: ctx} return ctrl, w } func TestRespondSuccessWritesPayload(t *testing.T) { ctrl, w := setupBasicController(http.MethodGet) payload := map[string]string{"status": "ok"} ctrl.RespondSuccess(payload) require.Equal(t, http.StatusOK, w.Code) var got map[string]string require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got)) require.Equal(t, "ok", got["status"]) } func TestRespondErrorAddsCodeAndMessage(t *testing.T) { ctrl, w := setupBasicController(http.MethodGet) ctrl.RespondError(http.StatusBadRequest, model.ErrorCodeInvalidRequest, "invalid payload") require.Equal(t, http.StatusBadRequest, w.Code) var got model.ErrorResponse require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got)) require.Equal(t, model.ErrorCodeInvalidRequest, got.Code) require.Equal(t, "invalid payload", got.Message) } func TestQueryInt64(t *testing.T) { ctrl := &basicController{} tests := []struct { name string query string def int64 expected int64 }{ {name: "valid number", query: "42", def: 0, expected: 42}, {name: "empty uses default", query: "", def: 5, expected: 5}, {name: "invalid uses default", query: "not-a-number", def: -1, expected: -1}, {name: "negative number", query: "-10", def: 0, expected: -10}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := ctrl.QueryInt64(tt.query, tt.def) require.Equalf(t, tt.expected, got, "QueryInt64(%q, %d)", tt.query, tt.def) }) } } ================================================ FILE: components/execd/pkg/web/controller/codeinterpreting.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package controller import ( "context" "errors" "fmt" "io" "net/http" "sync" "time" "github.com/gin-gonic/gin" "github.com/alibaba/opensandbox/execd/pkg/flag" "github.com/alibaba/opensandbox/execd/pkg/runtime" "github.com/alibaba/opensandbox/execd/pkg/web/model" ) var codeRunner *runtime.Controller func InitCodeRunner() { codeRunner = runtime.NewController(flag.JupyterServerHost, flag.JupyterServerToken) } // CodeInterpretingController handles code execution entrypoints. type CodeInterpretingController struct { *basicController // chunkWriter serializes SSE event writes to prevent interleaved output. chunkWriter sync.Mutex } func NewCodeInterpretingController(ctx *gin.Context) *CodeInterpretingController { return &CodeInterpretingController{ basicController: newBasicController(ctx), } } // CreateContext creates a new code execution context. func (c *CodeInterpretingController) CreateContext() { var request model.CodeContextRequest if err := c.bindJSON(&request); err != nil { c.RespondError( http.StatusBadRequest, model.ErrorCodeInvalidRequest, fmt.Sprintf("error parsing request, MAYBE invalid body format. %v", err), ) return } session, err := codeRunner.CreateContext(&runtime.CreateContextRequest{ Language: runtime.Language(request.Language), Cwd: request.Cwd, }) if err != nil { c.RespondError( http.StatusInternalServerError, model.ErrorCodeRuntimeError, fmt.Sprintf("error creating code context. %v", err), ) return } resp := model.CodeContext{ ID: session, CodeContextRequest: request, } c.RespondSuccess(resp) } // InterruptCode interrupts the execution of running code in a session. func (c *CodeInterpretingController) InterruptCode() { c.interrupt() } // RunCode executes code in a context and streams output via SSE. func (c *CodeInterpretingController) RunCode() { var request model.RunCodeRequest if err := c.bindJSON(&request); err != nil { c.RespondError( http.StatusBadRequest, model.ErrorCodeInvalidRequest, fmt.Sprintf("error parsing request, MAYBE invalid body format. %v", err), ) return } err := request.Validate() if err != nil { c.RespondError( http.StatusBadRequest, model.ErrorCodeInvalidRequest, fmt.Sprintf("invalid request, validation error %v", err), ) return } ctx, cancel := context.WithCancel(c.ctx.Request.Context()) defer cancel() runCodeRequest := c.buildExecuteCodeRequest(request) eventsHandler := c.setServerEventsHandler(ctx) runCodeRequest.Hooks = eventsHandler c.setupSSEResponse() err = codeRunner.Execute(runCodeRequest) if err != nil { c.RespondError( http.StatusInternalServerError, model.ErrorCodeRuntimeError, fmt.Sprintf("error running codes %v", err), ) return } time.Sleep(flag.ApiGracefulShutdownTimeout) } // GetContext returns a specific code context by id. func (c *CodeInterpretingController) GetContext() { contextID := c.ctx.Param("contextId") if contextID == "" { c.RespondError( http.StatusBadRequest, model.ErrorCodeMissingQuery, "missing path parameter 'contextId'", ) return } codeContext, err := codeRunner.GetContext(contextID) if err != nil { if errors.Is(err, runtime.ErrContextNotFound) { c.RespondError( http.StatusNotFound, model.ErrorCodeContextNotFound, fmt.Sprintf("context %s not found", contextID), ) return } c.RespondError( http.StatusInternalServerError, model.ErrorCodeRuntimeError, fmt.Sprintf("error getting code context %s. %v", contextID, err), ) return } c.RespondSuccess(codeContext) } // ListContexts returns active code contexts, optionally filtered by language. func (c *CodeInterpretingController) ListContexts() { language := c.ctx.Query("language") contexts, err := codeRunner.ListContext(language) if err != nil { c.RespondError( http.StatusInternalServerError, model.ErrorCodeRuntimeError, err.Error(), ) return } c.RespondSuccess(contexts) } // DeleteContextsByLanguage deletes all contexts for a given language. func (c *CodeInterpretingController) DeleteContextsByLanguage() { language := c.ctx.Query("language") if language == "" { c.RespondError( http.StatusBadRequest, model.ErrorCodeMissingQuery, "missing query parameter 'language'", ) return } err := codeRunner.DeleteLanguageContext(runtime.Language(language)) if err != nil { c.RespondError( http.StatusInternalServerError, model.ErrorCodeRuntimeError, fmt.Sprintf("error deleting code context %s. %v", language, err), ) return } c.RespondSuccess(nil) } // DeleteContext deletes a specific code context by id. func (c *CodeInterpretingController) DeleteContext() { contextID := c.ctx.Param("contextId") if contextID == "" { c.RespondError( http.StatusBadRequest, model.ErrorCodeMissingQuery, "missing path parameter 'contextId'", ) return } err := codeRunner.DeleteContext(contextID) if err != nil { if errors.Is(err, runtime.ErrContextNotFound) { c.RespondError( http.StatusNotFound, model.ErrorCodeContextNotFound, fmt.Sprintf("context %s not found", contextID), ) return } else { c.RespondError( http.StatusInternalServerError, model.ErrorCodeRuntimeError, fmt.Sprintf("error deleting code context %s. %v", contextID, err), ) return } } c.RespondSuccess(nil) } // CreateSession creates a new bash session (create_session API). // An empty body is allowed and is treated as default options (no cwd override). func (c *CodeInterpretingController) CreateSession() { var request model.CreateSessionRequest if err := c.bindJSON(&request); err != nil && !errors.Is(err, io.EOF) { c.RespondError( http.StatusBadRequest, model.ErrorCodeInvalidRequest, fmt.Sprintf("error parsing request. %v", err), ) return } sessionID, err := codeRunner.CreateBashSession(&runtime.CreateContextRequest{ Cwd: request.Cwd, }) if err != nil { c.RespondError( http.StatusInternalServerError, model.ErrorCodeRuntimeError, fmt.Sprintf("error creating session. %v", err), ) return } c.RespondSuccess(model.CreateSessionResponse{SessionID: sessionID}) } // RunInSession runs code in an existing bash session and streams output via SSE (run_in_session API). func (c *CodeInterpretingController) RunInSession() { sessionID := c.ctx.Param("sessionId") if sessionID == "" { c.RespondError( http.StatusBadRequest, model.ErrorCodeMissingQuery, "missing path parameter 'sessionId'", ) return } var request model.RunInSessionRequest if err := c.bindJSON(&request); err != nil { c.RespondError( http.StatusBadRequest, model.ErrorCodeInvalidRequest, fmt.Sprintf("error parsing request. %v", err), ) return } if err := request.Validate(); err != nil { c.RespondError( http.StatusBadRequest, model.ErrorCodeInvalidRequest, fmt.Sprintf("invalid request. %v", err), ) return } timeout := time.Duration(request.TimeoutMs) * time.Millisecond runReq := &runtime.ExecuteCodeRequest{ Language: runtime.Bash, Context: sessionID, Code: request.Code, Cwd: request.Cwd, Timeout: timeout, } ctx, cancel := context.WithCancel(c.ctx.Request.Context()) defer cancel() runReq.Hooks = c.setServerEventsHandler(ctx) c.setupSSEResponse() err := codeRunner.RunInBashSession(ctx, runReq) if err != nil { c.RespondError( http.StatusInternalServerError, model.ErrorCodeRuntimeError, fmt.Sprintf("error running in session. %v", err), ) return } time.Sleep(flag.ApiGracefulShutdownTimeout) } // DeleteSession deletes a bash session (delete_session API). func (c *CodeInterpretingController) DeleteSession() { sessionID := c.ctx.Param("sessionId") if sessionID == "" { c.RespondError( http.StatusBadRequest, model.ErrorCodeMissingQuery, "missing path parameter 'sessionId'", ) return } err := codeRunner.DeleteBashSession(sessionID) if err != nil { if errors.Is(err, runtime.ErrContextNotFound) { c.RespondError( http.StatusNotFound, model.ErrorCodeContextNotFound, fmt.Sprintf("session %s not found", sessionID), ) return } c.RespondError( http.StatusInternalServerError, model.ErrorCodeRuntimeError, fmt.Sprintf("error deleting session %s. %v", sessionID, err), ) return } c.RespondSuccess(nil) } // buildExecuteCodeRequest converts a RunCodeRequest to runtime format. func (c *CodeInterpretingController) buildExecuteCodeRequest(request model.RunCodeRequest) *runtime.ExecuteCodeRequest { req := &runtime.ExecuteCodeRequest{ Language: runtime.Language(request.Context.Language), Code: request.Code, Context: request.Context.ID, } if req.Language == "" { req.Language = runtime.Command } return req } func (c *CodeInterpretingController) interrupt() { session := c.ctx.Query("id") if session == "" { c.RespondError( http.StatusBadRequest, model.ErrorCodeMissingQuery, "missing query parameter 'id'", ) return } err := codeRunner.Interrupt(session) if err != nil { c.RespondError( http.StatusInternalServerError, model.ErrorCodeRuntimeError, fmt.Sprintf("error interruptting code context. %v", err), ) return } c.RespondSuccess(nil) } ================================================ FILE: components/execd/pkg/web/controller/codeinterpreting_test.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package controller import ( "encoding/json" "net/http" "testing" "github.com/gin-gonic/gin" "github.com/alibaba/opensandbox/execd/pkg/runtime" "github.com/alibaba/opensandbox/execd/pkg/web/model" "github.com/stretchr/testify/require" ) func TestBuildExecuteCodeRequestDefaultsToCommand(t *testing.T) { ctrl := &CodeInterpretingController{} req := model.RunCodeRequest{ Code: "echo 1", Context: model.CodeContext{ ID: "session-1", CodeContextRequest: model.CodeContextRequest{}, }, } execReq := ctrl.buildExecuteCodeRequest(req) require.Equal(t, runtime.Command, execReq.Language, "expected default language") require.Equal(t, "session-1", execReq.Context) require.Equal(t, "echo 1", execReq.Code) } func TestBuildExecuteCodeRequestRespectsLanguage(t *testing.T) { ctrl := &CodeInterpretingController{} req := model.RunCodeRequest{ Code: "print(1)", Context: model.CodeContext{ ID: "session-2", CodeContextRequest: model.CodeContextRequest{ Language: "python", }, }, } execReq := ctrl.buildExecuteCodeRequest(req) require.Equal(t, runtime.Language("python"), execReq.Language) } func TestGetContext_NotFoundReturns404(t *testing.T) { ctx, w := newTestContext(http.MethodGet, "/code/contexts/missing", nil) ctx.Params = append(ctx.Params, gin.Param{Key: "contextId", Value: "missing"}) ctrl := NewCodeInterpretingController(ctx) previous := codeRunner codeRunner = runtime.NewController("", "") t.Cleanup(func() { codeRunner = previous }) ctrl.GetContext() require.Equal(t, http.StatusNotFound, w.Code) var resp model.ErrorResponse require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) require.Equal(t, model.ErrorCodeContextNotFound, resp.Code) require.Equal(t, "context missing not found", resp.Message) } func TestGetContext_MissingIDReturns400(t *testing.T) { ctx, w := newTestContext(http.MethodGet, "/code/contexts/", nil) ctrl := NewCodeInterpretingController(ctx) ctrl.GetContext() require.Equal(t, http.StatusBadRequest, w.Code) var resp model.ErrorResponse require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) require.Equal(t, model.ErrorCodeMissingQuery, resp.Code) require.Equal(t, "missing path parameter 'contextId'", resp.Message) } ================================================ FILE: components/execd/pkg/web/controller/command.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package controller import ( "context" "fmt" "net/http" "strconv" "time" "github.com/alibaba/opensandbox/execd/pkg/flag" "github.com/alibaba/opensandbox/execd/pkg/runtime" "github.com/alibaba/opensandbox/execd/pkg/web/model" ) // RunCommand executes a shell command and streams the output via SSE. func (c *CodeInterpretingController) RunCommand() { var request model.RunCommandRequest if err := c.bindJSON(&request); err != nil { c.RespondError( http.StatusBadRequest, model.ErrorCodeInvalidRequest, fmt.Sprintf("error parsing request, MAYBE invalid body format. %v", err), ) return } err := request.Validate() if err != nil { c.RespondError( http.StatusBadRequest, model.ErrorCodeInvalidRequest, fmt.Sprintf("invalid request, validation error %v", err), ) return } ctx, cancel := context.WithCancel(c.ctx.Request.Context()) defer cancel() runCodeRequest := c.buildExecuteCommandRequest(request) eventsHandler := c.setServerEventsHandler(ctx) runCodeRequest.Hooks = eventsHandler c.setupSSEResponse() err = codeRunner.Execute(runCodeRequest) if err != nil { c.RespondError( http.StatusInternalServerError, model.ErrorCodeRuntimeError, fmt.Sprintf("error running commands %v", err), ) return } time.Sleep(flag.ApiGracefulShutdownTimeout) } // InterruptCommand stops a running shell command session. func (c *CodeInterpretingController) InterruptCommand() { c.interrupt() } // GetCommandStatus returns command status by id. func (c *CodeInterpretingController) GetCommandStatus() { commandID := c.ctx.Param("id") if commandID == "" { c.RespondError(http.StatusBadRequest, model.ErrorCodeInvalidRequest, "missing command execution id") return } status, err := codeRunner.GetCommandStatus(commandID) if err != nil { c.RespondError(http.StatusNotFound, model.ErrorCodeInvalidRequest, err.Error()) return } resp := model.CommandStatusResponse{ ID: status.Session, Running: status.Running, ExitCode: status.ExitCode, Error: status.Error, Content: status.Content, } if !status.StartedAt.IsZero() { resp.StartedAt = status.StartedAt } if status.FinishedAt != nil { resp.FinishedAt = status.FinishedAt } c.RespondSuccess(resp) } // GetBackgroundCommandOutput returns accumulated stdout/stderr for a command session as plain text. func (c *CodeInterpretingController) GetBackgroundCommandOutput() { id := c.ctx.Param("id") if id == "" { c.RespondError(http.StatusBadRequest, model.ErrorCodeMissingQuery, "missing command execution id") return } cursor := c.QueryInt64(c.ctx.Query("cursor"), 0) output, lastCursor, err := codeRunner.SeekBackgroundCommandOutput(id, cursor) if err != nil { c.RespondError(http.StatusBadRequest, model.ErrorCodeInvalidRequest, err.Error()) return } c.ctx.Header("EXECD-COMMANDS-TAIL-CURSOR", strconv.FormatInt(lastCursor, 10)) c.ctx.Header("Content-Type", "text/plain; charset=utf-8") c.ctx.String(http.StatusOK, "%s", output) } func (c *CodeInterpretingController) buildExecuteCommandRequest(request model.RunCommandRequest) *runtime.ExecuteCodeRequest { timeout := time.Duration(request.TimeoutMs) * time.Millisecond if request.Background { return &runtime.ExecuteCodeRequest{ Language: runtime.BackgroundCommand, Code: request.Command, Cwd: request.Cwd, Timeout: timeout, Gid: request.Gid, Uid: request.Uid, Envs: request.Envs, } } else { return &runtime.ExecuteCodeRequest{ Language: runtime.Command, Code: request.Command, Cwd: request.Cwd, Timeout: timeout, Gid: request.Gid, Uid: request.Uid, Envs: request.Envs, } } } ================================================ FILE: components/execd/pkg/web/controller/command_test.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package controller import ( "encoding/json" "net/http" "net/http/httptest" "reflect" "testing" "github.com/alibaba/opensandbox/execd/pkg/runtime" "github.com/alibaba/opensandbox/execd/pkg/web/model" "github.com/stretchr/testify/require" ) func TestBuildExecuteCommandRequestForwardsEnvs(t *testing.T) { ctrl := &CodeInterpretingController{} envs := map[string]string{"FOO": "bar", "BAZ": "qux"} req := model.RunCommandRequest{ Command: "echo hi", Cwd: "/tmp", Envs: envs, } execReq := ctrl.buildExecuteCommandRequest(req) require.Equal(t, runtime.Command, execReq.Language) require.True(t, reflect.DeepEqual(execReq.Envs, envs), "expected envs to be forwarded") require.Equal(t, "/tmp", execReq.Cwd) } func TestBuildExecuteCommandRequestForwardsEnvsBackground(t *testing.T) { ctrl := &CodeInterpretingController{} envs := map[string]string{"FOO": "bar"} req := model.RunCommandRequest{ Command: "echo hi", Background: true, Envs: envs, } execReq := ctrl.buildExecuteCommandRequest(req) require.Equal(t, runtime.BackgroundCommand, execReq.Language) require.True(t, reflect.DeepEqual(execReq.Envs, envs), "expected envs to be forwarded") } func setupCommandController(method, path string) (*CodeInterpretingController, *httptest.ResponseRecorder) { ctx, w := newTestContext(method, path, nil) ctrl := NewCodeInterpretingController(ctx) return ctrl, w } func TestGetCommandStatus_MissingID(t *testing.T) { ctrl, w := setupCommandController(http.MethodGet, "/command/status/") ctrl.GetCommandStatus() require.Equal(t, http.StatusBadRequest, w.Code) var resp model.ErrorResponse require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) require.Equal(t, model.ErrorCodeInvalidRequest, resp.Code) require.Equal(t, "missing command execution id", resp.Message) } func TestGetBackgroundCommandOutput_MissingID(t *testing.T) { ctrl, w := setupCommandController(http.MethodGet, "/command/logs/") ctrl.GetBackgroundCommandOutput() require.Equal(t, http.StatusBadRequest, w.Code) var resp model.ErrorResponse require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) require.Equal(t, model.ErrorCodeMissingQuery, resp.Code) require.Equal(t, "missing command execution id", resp.Message) } ================================================ FILE: components/execd/pkg/web/controller/filesystem.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. //go:build !windows // +build !windows package controller import ( "fmt" "net/http" "os" "os/user" "path/filepath" "strconv" "strings" "syscall" "github.com/gin-gonic/gin" "github.com/alibaba/opensandbox/execd/pkg/util/glob" "github.com/alibaba/opensandbox/execd/pkg/web/model" ) // FilesystemController handles file system operations type FilesystemController struct { *basicController } func NewFilesystemController(ctx *gin.Context) *FilesystemController { return &FilesystemController{basicController: newBasicController(ctx)} } func (c *FilesystemController) handleFileError(err error) { if os.IsNotExist(err) { c.RespondError( http.StatusNotFound, model.ErrorCodeFileNotFound, fmt.Sprintf("file not found. %v", err), ) } else { c.RespondError( http.StatusInternalServerError, model.ErrorCodeRuntimeError, fmt.Sprintf("error accessing file: %v", err), ) } } // GetFilesInfo retrieves metadata for specified file paths func (c *FilesystemController) GetFilesInfo() { paths := c.ctx.QueryArray("path") if len(paths) == 0 { c.RespondSuccess(make(map[string]model.FileInfo)) return } resp := make(map[string]model.FileInfo) for _, filePath := range paths { fileInfo, err := GetFileInfo(filePath) if err != nil { c.handleFileError(err) return } resp[filePath] = fileInfo } c.RespondSuccess(resp) } // RemoveFiles deletes specified files func (c *FilesystemController) RemoveFiles() { paths := c.ctx.QueryArray("path") for _, filePath := range paths { if err := DeleteFile(filePath); err != nil { c.RespondError( http.StatusInternalServerError, model.ErrorCodeRuntimeError, fmt.Sprintf("error removing file %s. %v", filePath, err), ) return } } c.RespondSuccess(nil) } // ChmodFiles changes file permissions for specified files func (c *FilesystemController) ChmodFiles() { var request map[string]model.Permission if err := c.bindJSON(&request); err != nil { c.RespondError( http.StatusBadRequest, model.ErrorCodeInvalidRequest, fmt.Sprintf("error parsing request, MAYBE invalid body format. %v", err), ) return } for file, item := range request { err := ChmodFile(file, item) if err != nil { c.RespondError( http.StatusInternalServerError, model.ErrorCodeRuntimeError, fmt.Sprintf("error changing permissions for %s. %v", file, err), ) return } } c.RespondSuccess(nil) } // RenameFiles renames or moves files to new paths func (c *FilesystemController) RenameFiles() { var request []model.RenameFileItem if err := c.bindJSON(&request); err != nil { c.RespondError( http.StatusBadRequest, model.ErrorCodeInvalidRequest, fmt.Sprintf("error parsing request, MAYBE invalid body format. %v", err), ) return } for _, renameItem := range request { if err := RenameFile(renameItem); err != nil { c.handleFileError(err) return } } c.RespondSuccess(nil) } // MakeDirs creates directories with specified permissions func (c *FilesystemController) MakeDirs() { var request map[string]model.Permission if err := c.bindJSON(&request); err != nil { c.RespondError( http.StatusBadRequest, model.ErrorCodeInvalidRequest, fmt.Sprintf("error parsing request, MAYBE invalid body format. %v", err), ) return } for dir, perm := range request { if err := MakeDir(dir, perm); err != nil { c.handleFileError(err) return } } c.RespondSuccess(nil) } // RemoveDirs recursively removes directories func (c *FilesystemController) RemoveDirs() { paths := c.ctx.QueryArray("path") for _, dir := range paths { if err := os.RemoveAll(dir); err != nil { c.RespondError( http.StatusInternalServerError, model.ErrorCodeRuntimeError, fmt.Sprintf("error removing directory %s. %v", dir, err), ) return } } c.RespondSuccess(nil) } // SearchFiles searches for files matching a pattern in a directory func (c *FilesystemController) SearchFiles() { path := c.ctx.Query("path") if path == "" { c.RespondError( http.StatusBadRequest, model.ErrorCodeMissingQuery, "missing query parameter 'path'", ) return } path, err := filepath.Abs(path) if err != nil { c.RespondError( http.StatusInternalServerError, model.ErrorCodeRuntimeError, fmt.Sprintf("error converting path %s to absolute. %v", path, err), ) return } _, err = os.Stat(path) if err != nil { c.handleFileError(err) return } pattern := c.ctx.Query("pattern") if pattern == "" { pattern = "**" } files := make([]model.FileInfo, 0, 16) err = filepath.Walk(path, func(filePath string, info os.FileInfo, err error) error { if os.IsNotExist(err) { return nil } if err != nil { return fmt.Errorf("error accessing path %s: %w", filePath, err) } if info.IsDir() { return nil } match, err := glob.PathMatch(pattern, info.Name()) if err != nil { return fmt.Errorf("invalid pattern %s: %w", pattern, err) } if match { sys := info.Sys().(*syscall.Stat_t) owner, err := user.LookupId(strconv.FormatUint(uint64(sys.Uid), 10)) if err != nil { return fmt.Errorf("error lookup owner for file %s: %w", filePath, err) } group, err := user.LookupGroupId(strconv.FormatUint(uint64(sys.Gid), 10)) if err != nil { return fmt.Errorf("error lookup group for file %s: %w", filePath, err) } files = append(files, model.FileInfo{ Path: filePath, Size: info.Size(), ModifiedAt: info.ModTime(), CreatedAt: getFileCreateTime(info), Permission: model.Permission{ Owner: owner.Username, Group: group.Name, Mode: func() int { mode := strconv.FormatInt(int64(info.Mode().Perm()), 8) i, _ := strconv.Atoi(mode) return i }(), }, }) } return nil }) if err != nil { c.RespondError( http.StatusInternalServerError, model.ErrorCodeRuntimeError, fmt.Sprintf("error searching files. %v", err), ) return } c.RespondSuccess(files) } // ReplaceContent replaces text content in specified files func (c *FilesystemController) ReplaceContent() { var request map[string]model.ReplaceFileContentItem if err := c.bindJSON(&request); err != nil { c.RespondError( http.StatusBadRequest, model.ErrorCodeInvalidRequest, fmt.Sprintf("error parsing request, MAYBE invalid body format. %v", err), ) return } for file, item := range request { file, err := filepath.Abs(file) if err != nil { c.handleFileError(err) return } if _, err = os.Stat(file); err != nil { c.handleFileError(err) return } content, err := os.ReadFile(file) if err != nil { c.handleFileError(err) return } fileInfo, err := os.Stat(file) if err != nil { c.handleFileError(err) return } mode := fileInfo.Mode() newContent := strings.ReplaceAll(string(content), item.Old, item.New) err = os.WriteFile(file, []byte(newContent), mode) if err != nil { c.handleFileError(err) return } } c.RespondSuccess(nil) } ================================================ FILE: components/execd/pkg/web/controller/filesystem_download.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package controller import ( "fmt" "io" "net/http" "net/url" "os" "path/filepath" "strconv" "github.com/alibaba/opensandbox/execd/pkg/web/model" ) // DownloadFile serves a file for download with support for range requests. func (c *FilesystemController) DownloadFile() { filePath := c.ctx.Query("path") if filePath == "" { c.RespondError( http.StatusBadRequest, model.ErrorCodeMissingQuery, "missing query parameter 'path'", ) return } file, err := os.Open(filePath) if err != nil { c.handleFileError(err) return } defer file.Close() fileInfo, err := file.Stat() if err != nil { c.RespondError( http.StatusInternalServerError, model.ErrorCodeRuntimeError, fmt.Sprintf("error getting file stat info: %s. %v", filePath, err), ) return } c.ctx.Header("Content-Type", "application/octet-stream") c.ctx.Header("Content-Disposition", formatContentDisposition(filepath.Base(filePath))) c.ctx.Header("Content-Length", strconv.FormatInt(fileInfo.Size(), 10)) if rangeHeader := c.ctx.GetHeader("Range"); rangeHeader != "" { ranges, err := ParseRange(rangeHeader, fileInfo.Size()) if err != nil { c.RespondError( http.StatusRequestedRangeNotSatisfiable, model.ErrorCodeUnknown, ) return } if len(ranges) > 0 { r := ranges[0] c.ctx.Status(http.StatusPartialContent) c.ctx.Header("Content-Range", fmt.Sprintf("bytes %d-%d/%d", r.start, r.start+r.length-1, fileInfo.Size())) c.ctx.Header("Content-Length", strconv.FormatInt(r.length, 10)) _, _ = file.Seek(r.start, io.SeekStart) _, _ = io.CopyN(c.ctx.Writer, file, r.length) return } } http.ServeContent(c.ctx.Writer, c.ctx.Request, filepath.Base(filePath), fileInfo.ModTime(), file) } // formatContentDisposition formats the Content-Disposition header value with proper // encoding for non-ASCII filenames according to RFC 6266 and RFC 5987. func formatContentDisposition(filename string) string { // Check if filename contains non-ASCII characters needsEncoding := false for _, r := range filename { if r > 127 { needsEncoding = true break } } if !needsEncoding { return "attachment; filename=\"" + filename + "\"" } // Use RFC 5987 encoding for non-ASCII filenames // Format: attachment; filename="fallback"; filename*=UTF-8''encoded_name encodedFilename := url.PathEscape(filename) return "attachment; filename=\"" + encodedFilename + "\"; filename*=UTF-8''" + encodedFilename } ================================================ FILE: components/execd/pkg/web/controller/filesystem_test.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package controller import ( "encoding/json" "fmt" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "testing" "github.com/alibaba/opensandbox/execd/pkg/web/model" "github.com/stretchr/testify/require" ) func newFilesystemController(t *testing.T, method, rawURL string, body []byte) (*FilesystemController, *httptest.ResponseRecorder) { t.Helper() ctx, rec := newTestContext(method, rawURL, body) ctrl := NewFilesystemController(ctx) return ctrl, rec } func TestFilesystemControllerGetFilesInfo(t *testing.T) { tmpDir := t.TempDir() target := filepath.Join(tmpDir, "foo.txt") require.NoError(t, os.WriteFile(target, []byte("demo"), 0o644)) query := fmt.Sprintf("/files/info?path=%s", url.QueryEscape(target)) ctrl, rec := newFilesystemController(t, http.MethodGet, query, nil) ctrl.GetFilesInfo() require.Equal(t, http.StatusOK, rec.Code) var resp map[string]model.FileInfo require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) info, ok := resp[target] require.True(t, ok, "response missing entry for %s", target) require.NotEmpty(t, info.Path) require.NotZero(t, info.Size) } func TestFilesystemControllerSearchFiles(t *testing.T) { tmpDir := t.TempDir() a := filepath.Join(tmpDir, "alpha.txt") b := filepath.Join(tmpDir, "beta.log") require.NoError(t, os.WriteFile(a, []byte("alpha"), 0o644)) require.NoError(t, os.WriteFile(b, []byte("beta"), 0o644)) rawURL := fmt.Sprintf("/files/search?path=%s&pattern=%s", url.QueryEscape(tmpDir), url.QueryEscape("*.txt")) ctrl, rec := newFilesystemController(t, http.MethodGet, rawURL, nil) ctrl.SearchFiles() require.Equal(t, http.StatusOK, rec.Code) var files []model.FileInfo require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &files)) require.Len(t, files, 1) require.Equal(t, a, files[0].Path) } func TestFilesystemControllerReplaceContent(t *testing.T) { tmpDir := t.TempDir() target := filepath.Join(tmpDir, "content.txt") require.NoError(t, os.WriteFile(target, []byte("hello world"), 0o644)) body, err := json.Marshal(map[string]model.ReplaceFileContentItem{ target: { Old: "world", New: "universe", }, }) require.NoError(t, err) ctrl, rec := newFilesystemController(t, http.MethodPost, "/files/replace", body) ctrl.ReplaceContent() require.Equal(t, http.StatusOK, rec.Code) data, err := os.ReadFile(target) require.NoError(t, err) require.Equal(t, "hello universe", string(data)) } func TestFilesystemControllerSearchFilesHandlesAbsentDir(t *testing.T) { rawURL := "/files/search?path=/not/exists" ctrl, rec := newFilesystemController(t, http.MethodGet, rawURL, nil) ctrl.SearchFiles() require.Equal(t, http.StatusNotFound, rec.Code) } func TestReplaceContentFailsUnknownFile(t *testing.T) { payload, _ := json.Marshal(map[string]model.ReplaceFileContentItem{ filepath.Join(t.TempDir(), "missing.txt"): { Old: "old", New: "new", }, }) ctrl, rec := newFilesystemController(t, http.MethodPost, "/files/replace", payload) ctrl.ReplaceContent() require.Contains(t, []int{http.StatusNotFound, http.StatusInternalServerError}, rec.Code, "expected failure status") } func TestFormatContentDisposition(t *testing.T) { tests := []struct { name string filename string want string }{ { name: "ASCII filename", filename: "test.txt", want: "attachment; filename=\"test.txt\"", }, { name: "Chinese filename", filename: "测试文件.txt", want: "attachment; filename=\"%E6%B5%8B%E8%AF%95%E6%96%87%E4%BB%B6.txt\"; filename*=UTF-8''%E6%B5%8B%E8%AF%95%E6%96%87%E4%BB%B6.txt", }, { name: "Japanese filename", filename: "テスト.txt", want: "attachment; filename=\"%E3%83%86%E3%82%B9%E3%83%88.txt\"; filename*=UTF-8''%E3%83%86%E3%82%B9%E3%83%88.txt", }, { name: "Special characters in filename", filename: "file with spaces.txt", want: "attachment; filename=\"file with spaces.txt\"", }, { name: "Mixed ASCII and non-ASCII", filename: "report-报告.pdf", want: "attachment; filename=\"report-%E6%8A%A5%E5%91%8A.pdf\"; filename*=UTF-8''report-%E6%8A%A5%E5%91%8A.pdf", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := formatContentDisposition(tt.filename) require.Equal(t, tt.want, got) }) } } ================================================ FILE: components/execd/pkg/web/controller/filesystem_upload.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package controller import ( "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "github.com/alibaba/opensandbox/execd/pkg/log" "github.com/alibaba/opensandbox/execd/pkg/web/model" ) // UploadFile uploads files with metadata to specified paths func (c *FilesystemController) UploadFile() { form, err := c.ctx.MultipartForm() if err != nil || form == nil { c.RespondError( http.StatusBadRequest, model.ErrorCodeInvalidFile, "multipart form is empty", ) return } metadataParts := form.File["metadata"] fileParts := form.File["file"] if len(metadataParts) == 0 { c.RespondError( http.StatusBadRequest, model.ErrorCodeInvalidFileMetadata, "metadata file is missing", ) return } if len(fileParts) == 0 { c.RespondError( http.StatusBadRequest, model.ErrorCodeInvalidFileContent, "file is missing", ) return } if len(metadataParts) != len(fileParts) { c.RespondError( http.StatusBadRequest, model.ErrorCodeInvalidFile, fmt.Sprintf("metadata and file count mismatch: %d vs %d", len(metadataParts), len(fileParts)), ) return } for i := range metadataParts { metadataHeader := metadataParts[i] metadataFile, err := metadataHeader.Open() if err != nil { c.RespondError( http.StatusBadRequest, model.ErrorCodeInvalidFileMetadata, fmt.Sprintf("error opening metadata file. %v", err), ) return } metaBytes, err := io.ReadAll(metadataFile) metadataFile.Close() if err != nil { c.RespondError( http.StatusBadRequest, model.ErrorCodeInvalidFileMetadata, fmt.Sprintf("error reading metadata content. %v", err), ) return } var meta model.FileMetadata if err := json.Unmarshal(metaBytes, &meta); err != nil { c.RespondError( http.StatusBadRequest, model.ErrorCodeInvalidFileMetadata, fmt.Sprintf("invalid metadata format. %v", err), ) return } targetPath := meta.Path if targetPath == "" { c.RespondError( http.StatusBadRequest, model.ErrorCodeInvalidFileMetadata, "metadata path is empty", ) return } targetDir := filepath.Dir(targetPath) if err := os.MkdirAll(targetDir, os.ModePerm); err != nil { c.RespondError( http.StatusInternalServerError, model.ErrorCodeRuntimeError, fmt.Sprintf("error creating target directory %s. %v", targetDir, err), ) return } fileHeader := fileParts[i] file, err := fileHeader.Open() if err != nil { c.RespondError( http.StatusInternalServerError, model.ErrorCodeRuntimeError, fmt.Sprintf("error opening file %s. %v", fileHeader.Filename, err), ) return } dst, err := os.OpenFile(targetPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, os.ModePerm) if err != nil { file.Close() c.RespondError( http.StatusInternalServerError, model.ErrorCodeRuntimeError, fmt.Sprintf("error opening destination file %s. %v", targetPath, err), ) return } if _, err := io.Copy(dst, file); err != nil { dst.Close() file.Close() c.RespondError( http.StatusInternalServerError, model.ErrorCodeRuntimeError, fmt.Sprintf("error copying file %s. %v", targetPath, err), ) return } if err := dst.Sync(); err != nil { log.Error("failed to sync target file: %v", err) } if err := dst.Close(); err != nil { log.Error("failed to close target file: %v", err) } file.Close() if err := ChmodFile(targetPath, meta.Permission); err != nil { c.RespondError( http.StatusInternalServerError, model.ErrorCodeRuntimeError, fmt.Sprintf("error chmoding file %s. %v", targetPath, err), ) return } } c.RespondSuccess(nil) } ================================================ FILE: components/execd/pkg/web/controller/filesystem_windows.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. //go:build windows // +build windows package controller import ( "fmt" "net/http" "os" "path/filepath" "strconv" "strings" "github.com/gin-gonic/gin" "github.com/alibaba/opensandbox/execd/pkg/util/glob" "github.com/alibaba/opensandbox/execd/pkg/web/model" ) // FilesystemController handles file system operations. type FilesystemController struct { *basicController } func NewFilesystemController(ctx *gin.Context) *FilesystemController { return &FilesystemController{basicController: newBasicController(ctx)} } func (c *FilesystemController) handleFileError(err error) { if os.IsNotExist(err) { c.RespondError( http.StatusNotFound, model.ErrorCodeFileNotFound, fmt.Sprintf("file not found. %v", err), ) } else { c.RespondError( http.StatusInternalServerError, model.ErrorCodeRuntimeError, fmt.Sprintf("error accessing file: %v", err), ) } } // GetFilesInfo retrieves metadata for specified file paths func (c *FilesystemController) GetFilesInfo() { paths := c.ctx.QueryArray("path") if len(paths) == 0 { c.RespondSuccess(make(map[string]model.FileInfo)) return } resp := make(map[string]model.FileInfo) for _, filePath := range paths { fileInfo, err := GetFileInfo(filePath) if err != nil { c.handleFileError(err) return } resp[filePath] = fileInfo } c.RespondSuccess(resp) } // RemoveFiles deletes specified files func (c *FilesystemController) RemoveFiles() { paths := c.ctx.QueryArray("path") for _, filePath := range paths { if err := DeleteFile(filePath); err != nil { c.RespondError( http.StatusInternalServerError, model.ErrorCodeRuntimeError, fmt.Sprintf("error removing file %s. %v", filePath, err), ) return } } c.RespondSuccess(nil) } // ChmodFiles changes file permissions for specified files func (c *FilesystemController) ChmodFiles() { var request map[string]model.Permission if err := c.bindJSON(&request); err != nil { c.RespondError( http.StatusBadRequest, model.ErrorCodeInvalidRequest, fmt.Sprintf("error parsing request, MAYBE invalid body format. %v", err), ) return } for file, item := range request { err := ChmodFile(file, item) if err != nil { c.RespondError( http.StatusInternalServerError, model.ErrorCodeRuntimeError, fmt.Sprintf("error changing permissions for %s. %v", file, err), ) return } } c.RespondSuccess(nil) } // RenameFiles renames or moves files to new paths func (c *FilesystemController) RenameFiles() { var request []model.RenameFileItem if err := c.bindJSON(&request); err != nil { c.RespondError( http.StatusBadRequest, model.ErrorCodeInvalidRequest, fmt.Sprintf("error parsing request, MAYBE invalid body format. %v", err), ) return } for _, renameItem := range request { if err := RenameFile(renameItem); err != nil { c.handleFileError(err) return } } c.RespondSuccess(nil) } // MakeDirs creates directories with specified permissions func (c *FilesystemController) MakeDirs() { var request map[string]model.Permission if err := c.bindJSON(&request); err != nil { c.RespondError( http.StatusBadRequest, model.ErrorCodeInvalidRequest, fmt.Sprintf("error parsing request, MAYBE invalid body format. %v", err), ) return } for dir, perm := range request { if err := MakeDir(dir, perm); err != nil { c.handleFileError(err) return } } c.RespondSuccess(nil) } // RemoveDirs recursively removes directories func (c *FilesystemController) RemoveDirs() { paths := c.ctx.QueryArray("path") for _, dir := range paths { if err := os.RemoveAll(dir); err != nil { c.RespondError( http.StatusInternalServerError, model.ErrorCodeRuntimeError, fmt.Sprintf("error removing directory %s. %v", dir, err), ) return } } c.RespondSuccess(nil) } // SearchFiles searches for files matching a pattern in a directory func (c *FilesystemController) SearchFiles() { path := c.ctx.Query("path") if path == "" { c.RespondError( http.StatusBadRequest, model.ErrorCodeMissingQuery, "missing query parameter 'path'", ) return } path, err := filepath.Abs(path) if err != nil { c.RespondError( http.StatusInternalServerError, model.ErrorCodeRuntimeError, fmt.Sprintf("error converting path %s to absolute. %v", path, err), ) return } _, err = os.Stat(path) if err != nil { c.handleFileError(err) return } pattern := c.ctx.Query("pattern") if pattern == "" { pattern = "**" } files := make([]model.FileInfo, 0, 16) err = filepath.Walk(path, func(filePath string, info os.FileInfo, err error) error { if os.IsNotExist(err) { return nil } if err != nil { return fmt.Errorf("error accessing path %s: %w", filePath, err) } if info.IsDir() { return nil } match, err := glob.PathMatch(pattern, info.Name()) if err != nil { return fmt.Errorf("invalid pattern %s: %w", pattern, err) } if match { files = append(files, model.FileInfo{ Path: filePath, Size: info.Size(), ModifiedAt: info.ModTime(), CreatedAt: getFileCreateTime(info), Permission: model.Permission{ Owner: "", Group: "", Mode: func() int { mode := strconv.FormatInt(int64(info.Mode().Perm()), 8) i, _ := strconv.Atoi(mode) return i }(), }, }) } return nil }) if err != nil { c.RespondError( http.StatusInternalServerError, model.ErrorCodeRuntimeError, fmt.Sprintf("error searching files. %v", err), ) return } c.RespondSuccess(files) } // ReplaceContent replaces text content in specified files func (c *FilesystemController) ReplaceContent() { var request map[string]model.ReplaceFileContentItem if err := c.bindJSON(&request); err != nil { c.RespondError( http.StatusBadRequest, model.ErrorCodeInvalidRequest, fmt.Sprintf("error parsing request, MAYBE invalid body format. %v", err), ) return } for file, item := range request { file, err := filepath.Abs(file) if err != nil { c.handleFileError(err) return } if _, err = os.Stat(file); err != nil { c.handleFileError(err) return } content, err := os.ReadFile(file) if err != nil { c.handleFileError(err) return } fileInfo, err := os.Stat(file) if err != nil { c.handleFileError(err) return } mode := fileInfo.Mode() newContent := strings.ReplaceAll(string(content), item.Old, item.New) err = os.WriteFile(file, []byte(newContent), mode) if err != nil { c.handleFileError(err) return } } c.RespondSuccess(nil) } ================================================ FILE: components/execd/pkg/web/controller/metric.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package controller import ( "encoding/json" "fmt" "net/http" "runtime" "time" "github.com/gin-gonic/gin" "github.com/shirou/gopsutil/cpu" "github.com/shirou/gopsutil/mem" "github.com/alibaba/opensandbox/execd/pkg/log" "github.com/alibaba/opensandbox/execd/pkg/web/model" ) // MetricController handles system metrics requests type MetricController struct { *basicController } func NewMetricController(ctx *gin.Context) *MetricController { return &MetricController{basicController: newBasicController(ctx)} } // GetMetrics returns current system metrics func (c *MetricController) GetMetrics() { metrics, err := c.readMetrics() if err != nil { c.RespondError( http.StatusInternalServerError, model.ErrorCodeRuntimeError, fmt.Sprintf("error reading runtime metrics. %v", err), ) return } c.RespondSuccess(metrics) } // WatchMetrics streams system metrics via SSE func (c *MetricController) WatchMetrics() { c.setupSSEResponse() for { select { case <-c.ctx.Request.Context().Done(): return case <-time.After(time.Second * 1): func() { if flusher, ok := c.ctx.Writer.(http.Flusher); ok { defer flusher.Flush() } metrics, err := c.readMetrics() if err != nil { msg, _ := json.Marshal(map[string]string{ //nolint:errchkjson "error": err.Error(), }) _, err = c.ctx.Writer.Write(append(msg, '\n')) if err != nil { log.Error("WatchMetrics write data %s error: %v", string(msg), err) } } else { msg, _ := json.Marshal(metrics) //nolint:errchkjson _, err = c.ctx.Writer.Write(append(msg, '\n')) if err != nil { log.Error("WatchMetrics write data %s error: %v", string(msg), err) } } }() } } } // readMetrics collects current CPU and memory metrics func (c *MetricController) readMetrics() (*model.Metrics, error) { metric := model.NewMetrics() metric.CpuCount = float64(runtime.GOMAXPROCS(-1)) cpuPercent, err := cpu.Percent(time.Second, false) if err != nil { return nil, fmt.Errorf("failed to get CPU percent: %w", err) } if len(cpuPercent) > 0 { metric.CpuUsedPct = cpuPercent[0] } vmStat, err := mem.VirtualMemory() if err != nil { return nil, fmt.Errorf("failed to get memory info: %w", err) } metric.MemTotalMiB = float64(vmStat.Total) / 1024 / 1024 metric.MemUsedMiB = float64(vmStat.Used) / 1024 / 1024 return metric, nil } ================================================ FILE: components/execd/pkg/web/controller/metric_test.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package controller import ( "encoding/json" "net/http" "net/http/httptest" "testing" "time" "github.com/stretchr/testify/assert" "github.com/alibaba/opensandbox/execd/pkg/web/model" ) func setupMetricController(method, path string) (*MetricController, *httptest.ResponseRecorder) { ctx, w := newTestContext(method, path, nil) ctrl := NewMetricController(ctx) return ctrl, w } // TestReadMetrics exercises readMetrics end-to-end. func TestReadMetrics(t *testing.T) { ctrl := &MetricController{} metrics, err := ctrl.readMetrics() assert.NoError(t, err) assert.NotNil(t, metrics) // Validate CPU count assert.Greater(t, metrics.CpuCount, 0.0) // Validate CPU utilization assert.GreaterOrEqual(t, metrics.CpuUsedPct, 0.0) assert.Less(t, metrics.CpuUsedPct, 100.1) // CPU usage should be under 100% with small float tolerance // Validate memory information assert.Greater(t, metrics.MemTotalMiB, 0.0) assert.GreaterOrEqual(t, metrics.MemUsedMiB, 0.0) assert.LessOrEqual(t, metrics.MemUsedMiB, metrics.MemTotalMiB) // Used memory should not exceed total // Validate timestamps currentTime := time.Now().UnixMilli() oneMinuteAgo := currentTime - 60*1000 assert.GreaterOrEqual(t, metrics.Timestamp, oneMinuteAgo) // Should be within the last minute assert.LessOrEqual(t, metrics.Timestamp, currentTime) // Should not be in the future } // TestGetMetricsEndpoint covers the happy path. func TestGetMetricsEndpoint(t *testing.T) { ctrl, w := setupMetricController("GET", "/api/metrics") ctrl.GetMetrics() assert.Equal(t, http.StatusOK, w.Code) var metrics model.Metrics err := json.Unmarshal(w.Body.Bytes(), &metrics) assert.NoError(t, err) assert.Greater(t, metrics.CpuCount, 0.0) assert.GreaterOrEqual(t, metrics.CpuUsedPct, 0.0) assert.Greater(t, metrics.MemTotalMiB, 0.0) assert.GreaterOrEqual(t, metrics.MemUsedMiB, 0.0) assert.NotZero(t, metrics.Timestamp) } // TestWatchMetricsHeaders verifies SSE header defaults. func TestWatchMetricsHeaders(t *testing.T) { ctrl, w := setupMetricController("GET", "/api/watch-metrics") ctrl.setupSSEResponse() contentType := w.Header().Get("Content-Type") assert.Equal(t, "text/event-stream", contentType) cacheControl := w.Header().Get("Cache-Control") assert.Equal(t, "no-cache", cacheControl) connection := w.Header().Get("Connection") assert.Equal(t, "keep-alive", connection) buffering := w.Header().Get("X-Accel-Buffering") assert.Equal(t, "no", buffering) } // TestMetricSerialization ensures metrics marshal and unmarshal cleanly. func TestMetricSerialization(t *testing.T) { metrics := &model.Metrics{ CpuCount: 4, CpuUsedPct: 25.5, MemTotalMiB: 8192, MemUsedMiB: 4096, Timestamp: time.Now().UnixMilli(), } data, err := json.Marshal(metrics) assert.NoError(t, err) var decodedMetrics model.Metrics err = json.Unmarshal(data, &decodedMetrics) assert.NoError(t, err) assert.Equal(t, metrics.CpuCount, decodedMetrics.CpuCount) assert.Equal(t, metrics.CpuUsedPct, decodedMetrics.CpuUsedPct) assert.Equal(t, metrics.MemTotalMiB, decodedMetrics.MemTotalMiB) assert.Equal(t, metrics.MemUsedMiB, decodedMetrics.MemUsedMiB) assert.Equal(t, metrics.Timestamp, decodedMetrics.Timestamp) errorMsg := map[string]string{"error": "test error"} errorData, err := json.Marshal(errorMsg) assert.NoError(t, err) var decodedError map[string]string err = json.Unmarshal(errorData, &decodedError) assert.NoError(t, err) assert.Equal(t, "test error", decodedError["error"]) } ================================================ FILE: components/execd/pkg/web/controller/mock_test.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package controller import ( "bytes" "net/http" ) type mockOutput struct { buffer *bytes.Buffer statusCode int header http.Header } func (m *mockOutput) Header() http.Header { if m.header == nil { m.header = make(http.Header) } return m.header } func (m *mockOutput) Write(b []byte) (int, error) { return m.buffer.Write(b) } func (m *mockOutput) WriteHeader(code int) { m.statusCode = code } func (m *mockOutput) Status() int { return m.statusCode } func (m *mockOutput) Body() []byte { return m.buffer.Bytes() } ================================================ FILE: components/execd/pkg/web/controller/ping.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package controller import "github.com/gin-gonic/gin" // MainController handles basic server operations. type MainController struct { *basicController } func NewMainController(ctx *gin.Context) *MainController { return &MainController{basicController: newBasicController(ctx)} } // Ping checks if the server is alive. func (c *MainController) Ping() { c.RespondSuccess(nil) } // PingHandler is the Gin adapter. func PingHandler(ctx *gin.Context) { NewMainController(ctx).Ping() } ================================================ FILE: components/execd/pkg/web/controller/sse.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package controller import ( "context" "io" "net/http" "time" "k8s.io/apimachinery/pkg/util/wait" "github.com/alibaba/opensandbox/execd/pkg/jupyter/execute" "github.com/alibaba/opensandbox/execd/pkg/log" "github.com/alibaba/opensandbox/execd/pkg/runtime" "github.com/alibaba/opensandbox/execd/pkg/util/safego" "github.com/alibaba/opensandbox/execd/pkg/web/model" ) var sseHeaders = map[string]string{ "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no", } func (c *basicController) setupSSEResponse() { for key, value := range sseHeaders { c.ctx.Writer.Header().Set(key, value) } if flusher, ok := c.ctx.Writer.(http.Flusher); ok { flusher.Flush() } } // setServerEventsHandler adapts runtime callbacks to SSE events. func (c *CodeInterpretingController) setServerEventsHandler(ctx context.Context) runtime.ExecuteResultHook { return runtime.ExecuteResultHook{ OnExecuteInit: func(session string) { event := model.ServerStreamEvent{ Type: model.StreamEventTypeInit, Text: session, Timestamp: time.Now().UnixMilli(), } payload := event.ToJSON() c.writeSingleEvent("OnExecuteInit", payload, true, event.Summary()) safego.Go(func() { c.ping(ctx) }) }, OnExecuteResult: func(result map[string]any, count int) { var mutated map[string]any if len(result) > 0 { mutated = make(map[string]any) for k, v := range result { switch k { case "text/plain": mutated["text"] = v default: mutated[k] = v } } } if count > 0 { event := model.ServerStreamEvent{ Type: model.StreamEventTypeCount, ExecutionCount: count, Timestamp: time.Now().UnixMilli(), } payload := event.ToJSON() c.writeSingleEvent("OnExecuteResult", payload, true, event.Summary()) } if len(mutated) > 0 { event := model.ServerStreamEvent{ Type: model.StreamEventTypeResult, Results: mutated, Timestamp: time.Now().UnixMilli(), } payload := event.ToJSON() c.writeSingleEvent("OnExecuteResult", payload, true, event.Summary()) } }, OnExecuteComplete: func(executionTime time.Duration) { event := model.ServerStreamEvent{ Type: model.StreamEventTypeComplete, ExecutionTime: executionTime.Milliseconds(), Timestamp: time.Now().UnixMilli(), } payload := event.ToJSON() c.writeSingleEvent("OnExecuteComplete", payload, true, event.Summary()) }, OnExecuteError: func(err *execute.ErrorOutput) { if err == nil { return } event := model.ServerStreamEvent{ Type: model.StreamEventTypeError, Error: err, Timestamp: time.Now().UnixMilli(), } payload := event.ToJSON() c.writeSingleEvent("OnExecuteError", payload, true, event.Summary()) }, OnExecuteStatus: func(status string) { event := model.ServerStreamEvent{ Type: model.StreamEventTypeStatus, Text: status, Timestamp: time.Now().UnixMilli(), } payload := event.ToJSON() c.writeSingleEvent("OnExecuteStatus", payload, true, event.Summary()) }, OnExecuteStdout: func(text string) { if text == "" { return } event := model.ServerStreamEvent{ Type: model.StreamEventTypeStdout, Text: text, Timestamp: time.Now().UnixMilli(), } payload := event.ToJSON() c.writeSingleEvent("OnExecuteStdout", payload, true, event.Summary()) }, OnExecuteStderr: func(text string) { if text == "" { return } event := model.ServerStreamEvent{ Type: model.StreamEventTypeStderr, Text: text, Timestamp: time.Now().UnixMilli(), } payload := event.ToJSON() c.writeSingleEvent("OnExecuteStderr", payload, true, event.Summary()) }, } } // writeSingleEvent serializes one SSE frame. func (c *CodeInterpretingController) writeSingleEvent(handler string, data []byte, verbose bool, summary string) { if c == nil || c.ctx == nil || c.ctx.Writer == nil { return } select { case <-c.ctx.Request.Context().Done(): log.Error("StreamEvent.%s: client disconnected", handler) return default: } c.chunkWriter.Lock() defer c.chunkWriter.Unlock() defer func() { if flusher, ok := c.ctx.Writer.(http.Flusher); ok { flusher.Flush() } }() payload := append(data, '\n', '\n') n, err := c.ctx.Writer.Write(payload) if err == nil && n != len(payload) { err = io.ErrShortWrite } if err != nil { log.Error("StreamEvent.%s write data %s error: %v", handler, summary, err) } else { if verbose { log.Info("StreamEvent.%s write data %s", handler, summary) } } } // ping periodically keeps the SSE connection alive. func (c *CodeInterpretingController) ping(ctx context.Context) { wait.Until(func() { if c.ctx.Writer == nil { return } event := model.ServerStreamEvent{ Type: model.StreamEventTypePing, Text: "pong", Timestamp: time.Now().UnixMilli(), } payload := event.ToJSON() c.writeSingleEvent("Ping", payload, false, event.Summary()) }, 3*time.Second, ctx.Done()) } ================================================ FILE: components/execd/pkg/web/controller/syscall_linux.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. //go:build linux // +build linux package controller import ( "os" "syscall" "time" ) func getFileCreateTime(fileInfo os.FileInfo) time.Time { stat, ok := fileInfo.Sys().(*syscall.Stat_t) if !ok || stat == nil { return fileInfo.ModTime() } return time.Unix(stat.Ctim.Sec, stat.Ctim.Nsec) } ================================================ FILE: components/execd/pkg/web/controller/syscall_others.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. //go:build !linux // +build !linux package controller import ( "os" "time" ) func getFileCreateTime(_ os.FileInfo) time.Time { return time.Now() } ================================================ FILE: components/execd/pkg/web/controller/test_helpers.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package controller import ( "bytes" "net/http/httptest" "github.com/gin-gonic/gin" ) // nolint:unused func newTestContext(method, path string, body []byte) (*gin.Context, *httptest.ResponseRecorder) { gin.SetMode(gin.TestMode) w := httptest.NewRecorder() ctx, _ := gin.CreateTestContext(w) req := httptest.NewRequest(method, path, bytes.NewReader(body)) ctx.Request = req return ctx, w } ================================================ FILE: components/execd/pkg/web/controller/utils.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. //go:build !windows // +build !windows package controller import ( "errors" "fmt" "os" "os/user" "path/filepath" "strconv" "strings" "syscall" "github.com/alibaba/opensandbox/execd/pkg/log" "github.com/alibaba/opensandbox/execd/pkg/web/model" ) func DeleteFile(filePath string) error { absPath, err := filepath.Abs(filePath) if err != nil { return fmt.Errorf("invalid path: %w", err) } fileInfo, err := os.Stat(absPath) if err != nil { if os.IsNotExist(err) { return nil } return err } if fileInfo.IsDir() { return fmt.Errorf("path is a directory: %s", filePath) } if err := os.Remove(absPath); err != nil { return fmt.Errorf("failed to remove file: %w", err) } return nil } func ChmodFile(file string, perms model.Permission) error { abs, err := filepath.Abs(file) if err != nil { return err } if perms.Mode != 0 { mode, err := strconv.ParseUint(strconv.Itoa(perms.Mode), 8, 32) if err != nil { return err } err = os.Chmod(abs, os.FileMode(mode)) if err != nil { return err } } return SetFileOwnership(abs, perms.Owner, perms.Group) } func SetFileOwnership(absPath string, owner string, group string) error { uid := -1 if owner != "" { userInfo, err := user.Lookup(owner) if err != nil { log.Warning("Failed to lookup user %s: %v", owner, err) } else { uid, err = strconv.Atoi(userInfo.Uid) if err != nil { log.Warning("Failed to convert uid for user %s: %v", owner, err) uid = -1 } } } gid := -1 if group != "" { groupInfo, err := user.LookupGroup(group) if err != nil { log.Warning("Failed to lookup group %s: %v", group, err) } else { gid, err = strconv.Atoi(groupInfo.Gid) if err != nil { log.Warning("Failed to convert gid for group %s: %v", group, err) gid = -1 } } } if uid == -1 && gid == -1 { uid = os.Getuid() gid = os.Getgid() } if err := os.Chown(absPath, uid, gid); err != nil { return fmt.Errorf("failed to set owner/group for %s: %w", absPath, err) } return nil } func RenameFile(item model.RenameFileItem) error { srcPath, err := filepath.Abs(item.Src) if err != nil { return fmt.Errorf("invalid source path: %w", err) } dstPath, err := filepath.Abs(item.Dest) if err != nil { return fmt.Errorf("invalid destination path: %w", err) } if _, err := os.Stat(srcPath); os.IsNotExist(err) { return fmt.Errorf("source path not found: %s", item.Src) } dstDir := filepath.Dir(dstPath) if err := os.MkdirAll(dstDir, 0755); err != nil { return fmt.Errorf("failed to create destination directory: %w", err) } if _, err := os.Stat(dstPath); err == nil { return fmt.Errorf("destination path already exists: %s", item.Dest) } if err := os.Rename(srcPath, dstPath); err != nil { return fmt.Errorf("failed to rename file: %w", err) } return nil } func MakeDir(dir string, perm model.Permission) error { abs, err := filepath.Abs(dir) if err != nil { return err } err = os.MkdirAll(abs, os.ModePerm) if err != nil { return err } return ChmodFile(abs, perm) } func GetFileInfo(filePath string) (model.FileInfo, error) { absPath, err := filepath.Abs(filePath) if err != nil { return model.FileInfo{}, fmt.Errorf("invalid path %s: %w", filePath, err) } fileInfo, err := os.Stat(absPath) if err != nil { if os.IsNotExist(err) { return model.FileInfo{}, fmt.Errorf("file not found: %s", filePath) } return model.FileInfo{}, fmt.Errorf("error accessing file %s: %w", filePath, err) } stat := fileInfo.Sys().(*syscall.Stat_t) owner := strconv.FormatUint(uint64(stat.Uid), 10) if ownerUser, err := user.LookupId(owner); err == nil { owner = ownerUser.Username } group := strconv.FormatUint(uint64(stat.Gid), 10) if groupInfo, err := user.LookupGroupId(group); err == nil { group = groupInfo.Name } mode := strconv.FormatInt(int64(fileInfo.Mode().Perm()), 8) return model.FileInfo{ Path: absPath, Size: fileInfo.Size(), ModifiedAt: fileInfo.ModTime(), CreatedAt: getFileCreateTime(fileInfo), Permission: model.Permission{ Owner: owner, Group: group, Mode: func() int { i, _ := strconv.Atoi(mode); return i }(), }, }, nil } func SearchFileMetadata(metadata map[string]model.FileMetadata, filePath string) (string, model.FileMetadata, bool) { base := filepath.Base(filePath) for path, info := range metadata { if filepath.Base(path) == base { return path, info, true } } return "", model.FileMetadata{}, false } type httpRange struct { start, length int64 } func ParseRange(s string, size int64) ([]httpRange, error) { if !strings.HasPrefix(s, "bytes=") { return nil, errors.New("invalid range") } ranges := strings.Split(s[6:], ",") result := make([]httpRange, 0, len(ranges)) for _, ra := range ranges { ra = strings.TrimSpace(ra) if ra == "" { continue } i := strings.Index(ra, "-") if i < 0 { return nil, errors.New("invalid range") } start, end := strings.TrimSpace(ra[:i]), strings.TrimSpace(ra[i+1:]) var r httpRange if start == "" { // suffix-length n, err := strconv.ParseInt(end, 10, 64) if err != nil || n < 0 { return nil, errors.New("invalid range") } if n > size { n = size } r.start = size - n r.length = size - r.start } else { // start-end i, err := strconv.ParseInt(start, 10, 64) if err != nil || i < 0 { return nil, errors.New("invalid range") } if end == "" { // start- r.start = i r.length = size - i } else { // start-end j, err := strconv.ParseInt(end, 10, 64) if err != nil || j < i { return nil, errors.New("invalid range") } r.start = i r.length = j - i + 1 } } if r.start >= size { continue } if r.start+r.length > size { r.length = size - r.start } result = append(result, r) } return result, nil } ================================================ FILE: components/execd/pkg/web/controller/utils_test.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package controller import ( "os" "path/filepath" "reflect" "testing" "github.com/alibaba/opensandbox/execd/pkg/web/model" "github.com/stretchr/testify/require" ) func TestDeleteFile(t *testing.T) { tmp := t.TempDir() file := filepath.Join(tmp, "sample.txt") require.NoError(t, os.WriteFile(file, []byte("hello"), 0o644)) require.NoError(t, DeleteFile(file)) _, err := os.Stat(file) require.True(t, os.IsNotExist(err), "expected file removed, got err=%v", err) // removing a non-existent file should be a no-op require.NoError(t, DeleteFile(file), "expected no error deleting missing file") } func TestRenameFile(t *testing.T) { tmp := t.TempDir() src := filepath.Join(tmp, "src.txt") require.NoError(t, os.WriteFile(src, []byte("data"), 0o644)) dst := filepath.Join(tmp, "nested", "renamed.txt") require.NoError(t, RenameFile(model.RenameFileItem{Src: src, Dest: dst})) _, err := os.Stat(dst) require.NoError(t, err) _, err = os.Stat(src) require.True(t, os.IsNotExist(err), "expected source removed, got err=%v", err) // destination exists -> expect error require.NoError(t, os.WriteFile(src, []byte("data"), 0o644)) require.Error(t, RenameFile(model.RenameFileItem{Src: src, Dest: dst}), "expected error when destination already exists") } func TestSearchFileMetadata(t *testing.T) { metadata := map[string]model.FileMetadata{ "/tmp/a/notes.txt": {Path: "/tmp/a/notes.txt"}, "/tmp/b/readme.md": {Path: "/tmp/b/readme.md"}, } path, info, ok := SearchFileMetadata(metadata, "/any/notes.txt") require.True(t, ok, "expected metadata entry") require.Equal(t, "/tmp/a/notes.txt", path) require.Equal(t, "/tmp/a/notes.txt", info.Path) _, _, ok = SearchFileMetadata(metadata, "/foo/unknown.txt") require.False(t, ok, "expected no match") } func TestParseRange(t *testing.T) { tests := []struct { name string header string size int64 want []httpRange expectErr bool }{ { name: "start-end", header: "bytes=0-9", size: 20, want: []httpRange{{start: 0, length: 10}}, }, { name: "suffix", header: "bytes=-5", size: 10, want: []httpRange{{start: 5, length: 5}}, }, { name: "invalid", header: "bytes=foo", size: 10, expectErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := ParseRange(tt.header, tt.size) if tt.expectErr { require.Error(t, err) return } require.NoError(t, err) require.True(t, reflect.DeepEqual(got, tt.want), "got %+v want %+v", got, tt.want) }) } } ================================================ FILE: components/execd/pkg/web/controller/utils_windows.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. //go:build windows // +build windows package controller import ( "errors" "fmt" "os" "path/filepath" "strconv" "strings" "syscall" "time" "github.com/alibaba/opensandbox/execd/pkg/web/model" ) func DeleteFile(filePath string) error { absPath, err := filepath.Abs(filePath) if err != nil { return fmt.Errorf("invalid path: %w", err) } fileInfo, err := os.Stat(absPath) if err != nil { if os.IsNotExist(err) { return nil } return err } if fileInfo.IsDir() { return fmt.Errorf("path is a directory: %s", filePath) } if err := os.Remove(absPath); err != nil { return fmt.Errorf("failed to remove file: %w", err) } return nil } func ChmodFile(file string, perms model.Permission) error { abs, err := filepath.Abs(file) if err != nil { return err } if perms.Mode != 0 { mode, err := strconv.ParseUint(strconv.Itoa(perms.Mode), 8, 32) if err != nil { return err } err = os.Chmod(abs, os.FileMode(mode)) if err != nil { return err } } return SetFileOwnership(abs, perms.Owner, perms.Group) } // SetFileOwnership is a placeholder on Windows where POSIX ownership is not supported. func SetFileOwnership(_ string, _ string, _ string) error { // TODO: add Windows ACL support if needed. return nil } func RenameFile(item model.RenameFileItem) error { srcPath, err := filepath.Abs(item.Src) if err != nil { return fmt.Errorf("invalid source path: %w", err) } dstPath, err := filepath.Abs(item.Dest) if err != nil { return fmt.Errorf("invalid destination path: %w", err) } if _, err := os.Stat(srcPath); os.IsNotExist(err) { return fmt.Errorf("source path not found: %s", item.Src) } dstDir := filepath.Dir(dstPath) if err := os.MkdirAll(dstDir, 0755); err != nil { return fmt.Errorf("failed to create destination directory: %w", err) } if _, err := os.Stat(dstPath); err == nil { return fmt.Errorf("destination path already exists: %s", item.Dest) } if err := os.Rename(srcPath, dstPath); err != nil { return fmt.Errorf("failed to rename file: %w", err) } return nil } func MakeDir(dir string, perm model.Permission) error { abs, err := filepath.Abs(dir) if err != nil { return err } err = os.MkdirAll(abs, os.ModePerm) if err != nil { return err } return ChmodFile(abs, perm) } func GetFileInfo(filePath string) (model.FileInfo, error) { absPath, err := filepath.Abs(filePath) if err != nil { return model.FileInfo{}, fmt.Errorf("invalid path %s: %w", filePath, err) } fileInfo, err := os.Stat(absPath) if err != nil { if os.IsNotExist(err) { return model.FileInfo{}, fmt.Errorf("file not found: %s", filePath) } return model.FileInfo{}, fmt.Errorf("error accessing file %s: %w", filePath, err) } createdAt := getFileCreateTime(fileInfo) if data, ok := fileInfo.Sys().(*syscall.Win32FileAttributeData); ok && data != nil { createdAt = time.Unix(0, data.CreationTime.Nanoseconds()) } mode := strconv.FormatInt(int64(fileInfo.Mode().Perm()), 8) return model.FileInfo{ Path: absPath, Size: fileInfo.Size(), ModifiedAt: fileInfo.ModTime(), CreatedAt: createdAt, Permission: model.Permission{ Owner: "", Group: "", Mode: func() int { i, _ := strconv.Atoi(mode) return i }(), }, }, nil } func SearchFileMetadata(metadata map[string]model.FileMetadata, filePath string) (string, model.FileMetadata, bool) { base := filepath.Base(filePath) for path, info := range metadata { if filepath.Base(path) == base { return path, info, true } } return "", model.FileMetadata{}, false } type httpRange struct { start, length int64 } func ParseRange(s string, size int64) ([]httpRange, error) { if !strings.HasPrefix(s, "bytes=") { return nil, errors.New("invalid range") } ranges := strings.Split(s[6:], ",") result := make([]httpRange, 0, len(ranges)) for _, ra := range ranges { ra = strings.TrimSpace(ra) if ra == "" { continue } i := strings.Index(ra, "-") if i < 0 { return nil, errors.New("invalid range") } start, end := strings.TrimSpace(ra[:i]), strings.TrimSpace(ra[i+1:]) var r httpRange if start == "" { // suffix-length n, err := strconv.ParseInt(end, 10, 64) if err != nil || n < 0 { return nil, errors.New("invalid range") } if n > size { n = size } r.start = size - n r.length = size - r.start } else { // start-end i, err := strconv.ParseInt(start, 10, 64) if err != nil || i < 0 { return nil, errors.New("invalid range") } if end == "" { // start- r.start = i r.length = size - i } else { // start-end j, err := strconv.ParseInt(end, 10, 64) if err != nil || j < i { return nil, errors.New("invalid range") } r.start = i r.length = j - i + 1 } } if r.start >= size { continue } if r.start+r.length > size { r.length = size - r.start } result = append(result, r) } return result, nil } ================================================ FILE: components/execd/pkg/web/model/codeinterpreting.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package model import ( "encoding/json" "errors" "fmt" "strings" "github.com/go-playground/validator/v10" "github.com/alibaba/opensandbox/execd/pkg/jupyter/execute" ) // RunCodeRequest represents a code execution request. type RunCodeRequest struct { Context CodeContext `json:"context,omitempty"` Code string `json:"code" validate:"required"` } func (r *RunCodeRequest) Validate() error { validate := validator.New() return validate.Struct(r) } // CodeContext tracks session metadata. type CodeContext struct { ID string `json:"id,omitempty"` CodeContextRequest `json:",inline"` } type CodeContextRequest struct { Language string `json:"language,omitempty"` Cwd string `json:"cwd,omitempty"` } // RunCommandRequest represents a shell command execution request. type RunCommandRequest struct { Command string `json:"command" validate:"required"` Cwd string `json:"cwd,omitempty"` Background bool `json:"background,omitempty"` // TimeoutMs caps execution duration; 0 uses server default. TimeoutMs int64 `json:"timeout,omitempty" validate:"omitempty,gte=1"` Uid *uint32 `json:"uid,omitempty"` Gid *uint32 `json:"gid,omitempty"` Envs map[string]string `json:"envs,omitempty"` } func (r *RunCommandRequest) Validate() error { validate := validator.New() if err := validate.Struct(r); err != nil { return err } if r.Gid != nil && r.Uid == nil { return errors.New("uid is required when gid is provided") } return nil } type ServerStreamEventType string const ( StreamEventTypeInit ServerStreamEventType = "init" StreamEventTypeStatus ServerStreamEventType = "status" StreamEventTypeError ServerStreamEventType = "error" StreamEventTypeStdout ServerStreamEventType = "stdout" StreamEventTypeStderr ServerStreamEventType = "stderr" StreamEventTypeResult ServerStreamEventType = "result" StreamEventTypeComplete ServerStreamEventType = "execution_complete" StreamEventTypeCount ServerStreamEventType = "execution_count" StreamEventTypePing ServerStreamEventType = "ping" ) // ServerStreamEvent is emitted to clients over SSE. type ServerStreamEvent struct { Type ServerStreamEventType `json:"type,omitempty"` Text string `json:"text,omitempty"` ExecutionCount int `json:"execution_count,omitempty"` ExecutionTime int64 `json:"execution_time,omitempty"` Timestamp int64 `json:"timestamp,omitempty"` Results map[string]any `json:"results,omitempty"` Error *execute.ErrorOutput `json:"error,omitempty"` } // ToJSON serializes the event for streaming. func (s ServerStreamEvent) ToJSON() []byte { bytes, _ := json.Marshal(s) return bytes } // Summary renders a lightweight, log-friendly string without JSON. func (s ServerStreamEvent) Summary() string { parts := []string{fmt.Sprintf("type=%s", s.Type)} if s.Text != "" { parts = append(parts, fmt.Sprintf("text=%s", truncateString(s.Text, 100))) } if s.ExecutionTime > 0 { parts = append(parts, fmt.Sprintf("elapsed_ms=%d", s.ExecutionTime)) } if len(s.Results) > 0 { parts = append(parts, fmt.Sprintf("results=%d", len(s.Results))) } if s.Error != nil { errLabel := s.Error.EName if errLabel == "" { errLabel = "error" } parts = append(parts, fmt.Sprintf("error=%s: %s", errLabel, truncateString(s.Error.EValue, 80))) } return strings.Join(parts, " ") } func truncateString(value string, maxCount int) string { if maxCount <= 0 || len(value) <= maxCount { return value } return value[:maxCount] + "..." } ================================================ FILE: components/execd/pkg/web/model/codeinterpreting_test.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package model import ( "encoding/json" "strings" "testing" "github.com/alibaba/opensandbox/execd/pkg/jupyter/execute" "github.com/stretchr/testify/require" ) func TestRunCodeRequestValidate(t *testing.T) { req := RunCodeRequest{ Code: "print('hi')", } require.NoError(t, req.Validate()) req.Code = "" require.Error(t, req.Validate(), "expected validation error when code is empty") } func TestRunCommandRequestValidate(t *testing.T) { req := RunCommandRequest{Command: "ls"} require.NoError(t, req.Validate(), "expected command validation success") req.TimeoutMs = -100 require.Error(t, req.Validate(), "expected validation error when timeout is negative") req.TimeoutMs = 0 req.Command = "ls" require.NoError(t, req.Validate(), "expected success when timeout is omitted/zero") req.TimeoutMs = 10 req.Command = "" require.Error(t, req.Validate(), "expected validation error when command is empty") } func ptr32(v uint32) *uint32 { return &v } func TestRunCommandRequestValidateUidGid(t *testing.T) { // uid-only: valid req := RunCommandRequest{Command: "id", Uid: ptr32(1000)} require.NoError(t, req.Validate(), "expected success with uid only") // uid + gid: valid req = RunCommandRequest{Command: "id", Uid: ptr32(1000), Gid: ptr32(1000)} require.NoError(t, req.Validate(), "expected success with uid and gid") // gid-only: must be rejected req = RunCommandRequest{Command: "id", Gid: ptr32(1000)} require.Error(t, req.Validate(), "expected validation error when gid is set without uid") } func TestServerStreamEventToJSON(t *testing.T) { event := ServerStreamEvent{ Type: StreamEventTypeStdout, Text: "hello", ExecutionCount: 3, } data := event.ToJSON() var decoded ServerStreamEvent require.NoError(t, json.Unmarshal(data, &decoded)) require.Equal(t, event.Type, decoded.Type) require.Equal(t, event.Text, decoded.Text) require.Equal(t, event.ExecutionCount, decoded.ExecutionCount) } func TestServerStreamEventSummary(t *testing.T) { longText := strings.Repeat("a", 120) tests := []struct { name string event ServerStreamEvent contains []string }{ { name: "basic stdout", event: ServerStreamEvent{ Type: StreamEventTypeStdout, Text: "hello", ExecutionCount: 2, }, contains: []string{"type=stdout", "text=hello"}, }, { name: "truncated text and error", event: ServerStreamEvent{ Type: StreamEventTypeError, Text: longText, Error: &execute.ErrorOutput{EName: "ValueError", EValue: "boom"}, }, contains: []string{ "type=error", "text=" + strings.Repeat("a", 100) + "...", "error=ValueError: boom", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { summary := tt.event.Summary() for _, want := range tt.contains { require.Containsf(t, summary, want, "summary missing %q", want) } }) } } ================================================ FILE: components/execd/pkg/web/model/command.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package model import "time" // CommandStatusResponse represents command status for REST APIs. type CommandStatusResponse struct { ID string `json:"id"` Content string `json:"content,omitempty"` Running bool `json:"running"` ExitCode *int `json:"exit_code,omitempty"` Error string `json:"error,omitempty"` StartedAt time.Time `json:"started_at,omitempty"` FinishedAt *time.Time `json:"finished_at,omitempty"` } ================================================ FILE: components/execd/pkg/web/model/error.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package model type ErrorCode string const ( ErrorCodeInvalidRequest ErrorCode = "INVALID_REQUEST_BODY" ErrorCodeMissingQuery ErrorCode = "MISSING_QUERY" ErrorCodeRuntimeError ErrorCode = "RUNTIME_ERROR" ErrorCodeInvalidFile ErrorCode = "INVALID_FILE" ErrorCodeInvalidFileContent ErrorCode = "INVALID_FILE_CONTENT" ErrorCodeInvalidFileMetadata ErrorCode = "INVALID_FILE_METADATA" ErrorCodeFileNotFound ErrorCode = "FILE_NOT_FOUND" ErrorCodeUnknown ErrorCode = "UNKNOWN" ErrorCodeContextNotFound ErrorCode = "CONTEXT_NOT_FOUND" ) type ErrorResponse struct { Code ErrorCode `json:"code,omitempty"` Message string `json:"message,omitempty"` } ================================================ FILE: components/execd/pkg/web/model/filesystem.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package model import "time" // FileInfo represents file metadata including path and permissions type FileInfo struct { Path string `json:"path,omitempty"` Size int64 `json:"size"` ModifiedAt time.Time `json:"modified_at,omitempty"` CreatedAt time.Time `json:"created_at,omitempty"` Permission `json:",inline"` } type FileMetadata struct { Path string `json:"path,omitempty"` Permission `json:",inline"` } // Permission represents file ownership and mode type Permission struct { Owner string `json:"owner"` Group string `json:"group"` Mode int `json:"mode"` } // RenameFileItem represents a file rename operation type RenameFileItem struct { Src string `json:"src,omitempty"` Dest string `json:"dest,omitempty"` } // ReplaceFileContentItem represents a content replacement operation type ReplaceFileContentItem struct { Old string `json:"old,omitempty"` New string `json:"new,omitempty"` } ================================================ FILE: components/execd/pkg/web/model/header.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package model const ( // ApiAccessTokenHeader carries the auth token. ApiAccessTokenHeader = "X-EXECD-ACCESS-TOKEN" ) ================================================ FILE: components/execd/pkg/web/model/metric.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package model import "time" // Metrics represents system resource usage metrics type Metrics struct { CpuCount float64 `json:"cpu_count"` CpuUsedPct float64 `json:"cpu_used_pct"` MemTotalMiB float64 `json:"mem_total_mib"` MemUsedMiB float64 `json:"mem_used_mib"` Timestamp int64 `json:"timestamp"` } func NewMetrics() *Metrics { return &Metrics{ CpuCount: 0, CpuUsedPct: 0, MemTotalMiB: 0, MemUsedMiB: 0, Timestamp: time.Now().UnixMilli(), } } ================================================ FILE: components/execd/pkg/web/model/session.go ================================================ // Copyright 2026 Alibaba Group Holding Ltd. // // 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. package model import ( "github.com/go-playground/validator/v10" ) // CreateSessionRequest is the request body for creating a bash session. type CreateSessionRequest struct { Cwd string `json:"cwd,omitempty"` } // CreateSessionResponse is the response for create_session. type CreateSessionResponse struct { SessionID string `json:"session_id"` } // RunInSessionRequest is the request body for running code in an existing session. type RunInSessionRequest struct { Code string `json:"code" validate:"required"` Cwd string `json:"cwd,omitempty"` TimeoutMs int64 `json:"timeout_ms,omitempty" validate:"omitempty,gte=0"` } // Validate validates RunInSessionRequest. func (r *RunInSessionRequest) Validate() error { validate := validator.New() return validate.Struct(r) } ================================================ FILE: components/execd/pkg/web/proxy.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package web import ( "net" "net/http" "net/http/httputil" "net/url" "strings" "time" "github.com/gin-gonic/gin" "github.com/alibaba/opensandbox/execd/pkg/log" ) func ProxyMiddleware() gin.HandlerFunc { return func(c *gin.Context) { if !strings.HasPrefix(c.Request.URL.Path, "/proxy/") { c.Next() return } r := c.Request w := c.Writer rest := strings.TrimPrefix(r.URL.Path, "/proxy/") parts := strings.SplitN(rest, "/", 2) if len(parts) == 0 || parts[0] == "" { http.Error(w, "port is required", http.StatusBadRequest) c.Abort() return } port := parts[0] path := "/" if len(parts) == 2 && parts[1] != "" { path += parts[1] } target := &url.URL{ Scheme: "http", Host: "127.0.0.1:" + port, Path: path, } isWebSocket := strings.ToLower(r.Header.Get("Upgrade")) == "websocket" proxy := httputil.NewSingleHostReverseProxy(target) // Flush SSE chunks promptly; a small interval avoids buffering breaks chunked streams. proxy.FlushInterval = 200 * time.Millisecond proxy.Director = func(req *http.Request) { req.URL.Scheme = "http" req.URL.Host = "127.0.0.1:" + port req.URL.Path = path req.URL.RawQuery = r.URL.RawQuery req.URL.RawPath = "" req.RequestURI = "" req.Header.Set("X-Forwarded-For", getClientIP(r)) req.Header.Set("X-Forwarded-Proto", "http") req.Header.Del("X-Forwarded-Host") if isWebSocket { req.Header.Set("Connection", "Upgrade") req.Header.Set("Upgrade", "websocket") req.Header.Set("Sec-WebSocket-Version", "13") if key := r.Header.Get("Sec-WebSocket-Key"); key != "" { req.Header.Set("Sec-WebSocket-Key", key) } } } proxy.Transport = &http.Transport{ DialContext: (&net.Dialer{ Timeout: 600 * time.Second, KeepAlive: 30 * time.Second, }).DialContext, MaxIdleConns: 100, MaxIdleConnsPerHost: 100, IdleConnTimeout: 600 * time.Second, } proxy.ErrorHandler = func(rw http.ResponseWriter, req *http.Request, err error) { log.Error("Proxy error: %v, request: %s %s", err, req.Method, req.RequestURI) http.Error(rw, "Bad Gateway", http.StatusBadGateway) } log.Info("Proxy: %s %s -> %s (WebSocket: %v)", r.Method, r.RequestURI, target.Host, isWebSocket) proxy.ServeHTTP(w, r) c.Abort() } } func getClientIP(r *http.Request) string { if ip := r.Header.Get("X-Forwarded-For"); ip != "" { return strings.Split(ip, ",")[0] } if ip := r.Header.Get("X-Real-IP"); ip != "" { return ip } return r.RemoteAddr } ================================================ FILE: components/execd/pkg/web/router.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package web import ( "net/http" "github.com/gin-gonic/gin" "github.com/alibaba/opensandbox/execd/pkg/log" "github.com/alibaba/opensandbox/execd/pkg/web/controller" "github.com/alibaba/opensandbox/execd/pkg/web/model" ) // NewRouter builds a Gin engine with all execd routes. func NewRouter(accessToken string) *gin.Engine { gin.SetMode(gin.ReleaseMode) r := gin.New() r.Use(gin.Recovery()) r.Use(logMiddleware(), accessTokenMiddleware(accessToken), ProxyMiddleware()) r.GET("/ping", controller.PingHandler) files := r.Group("/files") { files.DELETE("", withFilesystem(func(c *controller.FilesystemController) { c.RemoveFiles() })) files.GET("/info", withFilesystem(func(c *controller.FilesystemController) { c.GetFilesInfo() })) files.POST("/mv", withFilesystem(func(c *controller.FilesystemController) { c.RenameFiles() })) files.POST("/permissions", withFilesystem(func(c *controller.FilesystemController) { c.ChmodFiles() })) files.GET("/search", withFilesystem(func(c *controller.FilesystemController) { c.SearchFiles() })) files.POST("/replace", withFilesystem(func(c *controller.FilesystemController) { c.ReplaceContent() })) files.POST("/upload", withFilesystem(func(c *controller.FilesystemController) { c.UploadFile() })) files.GET("/download", withFilesystem(func(c *controller.FilesystemController) { c.DownloadFile() })) } directories := r.Group("/directories") { directories.POST("", withFilesystem(func(c *controller.FilesystemController) { c.MakeDirs() })) directories.DELETE("", withFilesystem(func(c *controller.FilesystemController) { c.RemoveDirs() })) } code := r.Group("/code") { code.POST("", withCode(func(c *controller.CodeInterpretingController) { c.RunCode() })) code.DELETE("", withCode(func(c *controller.CodeInterpretingController) { c.InterruptCode() })) code.POST("/context", withCode(func(c *controller.CodeInterpretingController) { c.CreateContext() })) code.GET("/contexts", withCode(func(c *controller.CodeInterpretingController) { c.ListContexts() })) code.DELETE("/contexts", withCode(func(c *controller.CodeInterpretingController) { c.DeleteContextsByLanguage() })) code.DELETE("/contexts/:contextId", withCode(func(c *controller.CodeInterpretingController) { c.DeleteContext() })) code.GET("/contexts/:contextId", withCode(func(c *controller.CodeInterpretingController) { c.GetContext() })) } session := r.Group("/session") { session.POST("", withCode(func(c *controller.CodeInterpretingController) { c.CreateSession() })) session.POST("/:sessionId/run", withCode(func(c *controller.CodeInterpretingController) { c.RunInSession() })) session.DELETE("/:sessionId", withCode(func(c *controller.CodeInterpretingController) { c.DeleteSession() })) } command := r.Group("/command") { command.POST("", withCode(func(c *controller.CodeInterpretingController) { c.RunCommand() })) command.DELETE("", withCode(func(c *controller.CodeInterpretingController) { c.InterruptCommand() })) command.GET("/status/:id", withCode(func(c *controller.CodeInterpretingController) { c.GetCommandStatus() })) command.GET("/:id/logs", withCode(func(c *controller.CodeInterpretingController) { c.GetBackgroundCommandOutput() })) } metric := r.Group("/metrics") { metric.GET("", withMetric(func(c *controller.MetricController) { c.GetMetrics() })) metric.GET("/watch", withMetric(func(c *controller.MetricController) { c.WatchMetrics() })) } return r } func withFilesystem(fn func(*controller.FilesystemController)) gin.HandlerFunc { return func(ctx *gin.Context) { fn(controller.NewFilesystemController(ctx)) } } func withCode(fn func(*controller.CodeInterpretingController)) gin.HandlerFunc { return func(ctx *gin.Context) { fn(controller.NewCodeInterpretingController(ctx)) } } func withMetric(fn func(*controller.MetricController)) gin.HandlerFunc { return func(ctx *gin.Context) { fn(controller.NewMetricController(ctx)) } } func accessTokenMiddleware(token string) gin.HandlerFunc { return func(ctx *gin.Context) { if token == "" { ctx.Next() return } requestedToken := ctx.GetHeader(model.ApiAccessTokenHeader) if requestedToken == "" || requestedToken != token { ctx.AbortWithStatusJSON(http.StatusUnauthorized, map[string]any{ "error": "Unauthorized: invalid or missing header " + model.ApiAccessTokenHeader, }) return } ctx.Next() } } func logMiddleware() gin.HandlerFunc { return func(ctx *gin.Context) { log.Info("Requested: %v - %v", ctx.Request.Method, ctx.Request.URL.String()) ctx.Next() } } ================================================ FILE: components/execd/tests/jupyter.sh ================================================ #!/bin/bash # Copyright 2025 Alibaba Group Holding Ltd. # # 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. export JUPYTER_PORT=54321 export JUPYTER_TOKEN=opensandboxexecdintegrationtest install_jupyter() { # install jupyter notebook for integration testing python --version pip install ipykernel jupyter echo "Starting jupyter notebook ..." jupyter notebook --ip=0.0.0.0 --port=$JUPYTER_PORT --allow-root --no-browser --NotebookApp.token=$JUPYTER_TOKEN >/tmp/jupyter.log 2>&1 & sleep 3 } ================================================ FILE: components/execd/tests/smoke.sh ================================================ #!/bin/bash # Copyright 2025 Alibaba Group Holding Ltd. # # 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. set -euxo pipefail source tests/jupyter.sh install_jupyter export EXECD_API_GRACE_SHUTDOWN=500ms export EXECD_LOG_FILE=execd.log ./bin/execd -jupyter-host=http://127.0.0.1:${JUPYTER_PORT} --jupyter-token=${JUPYTER_TOKEN} --log-level=7 >startup.log 2>&1 & ================================================ FILE: components/execd/tests/smoke_api.py ================================================ #!/usr/bin/env python3 # Copyright 2025 Alibaba Group Holding Ltd. # # 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. """ Simple smoke tests for execd APIs. Prerequisites: - execd server running locally (default http://localhost:44772) - Optional: set env BASE_URL to override - Optional: set env API_TOKEN if server expects X-EXECD-ACCESS-TOKEN """ import json import os import sys import time import uuid import tempfile import pathlib import requests BASE_URL = os.environ.get("BASE_URL", "http://localhost:44772").rstrip("/") API_TOKEN = os.environ.get("API_TOKEN") HEADERS = {} if API_TOKEN: HEADERS["X-EXECD-ACCESS-TOKEN"] = API_TOKEN session = requests.Session() session.headers.update(HEADERS) def expect(cond: bool, msg: str): if not cond: raise SystemExit(msg) def sse_get_command_id() -> str: url = f"{BASE_URL}/command" payload = {"command": "echo smoke-command && sleep 1", "background": True} with session.post(url, json=payload, stream=True, timeout=15) as resp: expect(resp.status_code == 200, f"SSE start failed: {resp.status_code} {resp.text}") for line in resp.iter_lines(): if not line or not line.startswith(b"data:"): # controller emits raw JSON lines without SSE 'data:' prefix try: data = json.loads(line.decode()) except Exception: continue else: data = json.loads(line[len(b"data:") :].decode()) if data.get("type") == "init": cmd_id = data.get("text") expect(cmd_id, "missing command id in init event") return cmd_id raise SystemExit("Failed to obtain command id from SSE") def wait_status(cmd_id: str, timeout: float = 15.0) -> dict: url = f"{BASE_URL}/command/status/{cmd_id}" deadline = time.time() + timeout last = None while time.time() < deadline: r = session.get(url, timeout=5) expect(r.status_code == 200, f"status failed: {r.status_code} {r.text}") last = r.json() if not last.get("running", True): return last time.sleep(0.3) return last def fetch_logs(cmd_id: str, cursor: int = 0): url = f"{BASE_URL}/command/{cmd_id}/logs" r = session.get(url, params={"cursor": cursor}, timeout=10) expect(r.status_code == 200, f"logs failed: {r.status_code} {r.text}") return r.text, r.headers.get("EXECD-COMMANDS-TAIL-CURSOR") def sse_disconnect_should_stop_ping(): """ Open an SSE stream for a long-running command, receive init, then close the client side early to ensure the server handles disconnects (ping loop should stop). We verify the server is still responsive afterwards. """ url = f"{BASE_URL}/command" payload = { # long command so the server would keep pinging if not cancelled "command": "sh -c 'echo long-run-start && sleep 20 && echo long-run-end'", "background": False, } with session.post(url, json=payload, stream=True, timeout=10) as resp: expect(resp.status_code == 200, f"SSE start failed: {resp.status_code} {resp.text}") for line in resp.iter_lines(): if not line: continue try: if line.startswith(b"data:"): data = json.loads(line[len(b"data:") :].decode()) else: data = json.loads(line.decode()) except Exception: continue if data.get("type") == "init": break # explicitly close to simulate client drop resp.close() # Give server a moment to observe disconnect and ensure API remains healthy time.sleep(1) pong = session.get(f"{BASE_URL}/ping", timeout=5) expect(pong.status_code == 200, "ping failed after SSE disconnect") def upload_and_download(): tmp_dir = f"/tmp/execd-smoke-{uuid.uuid4().hex}" path = f"{tmp_dir}/hello.txt" metadata = json.dumps({"path": path}) files = { "metadata": ("metadata", metadata, "application/json"), "file": ("file", b"hello execd\n", "application/octet-stream"), } up = session.post(f"{BASE_URL}/files/upload", files=files, timeout=10) expect(up.status_code == 200, f"upload failed: {up.status_code} {up.text}") down = session.get(f"{BASE_URL}/files/download", params={"path": path}, timeout=10) expect(down.status_code == 200, f"download failed: {down.status_code} {down.text}") expect(down.content == b"hello execd\n", "downloaded content mismatch") def filesystem_smoke(): base_dir = os.path.join(tempfile.gettempdir(), f"execd-smoke-{uuid.uuid4().hex}") sub_dir = os.path.join(base_dir, "sub") file_path = os.path.join(sub_dir, "hello.txt") renamed_path = os.path.join(sub_dir, "hello_renamed.txt") # create dirs mk = session.post(f"{BASE_URL}/directories", json={sub_dir: {"mode": 0}}, timeout=10) expect(mk.status_code == 200, f"mkdir failed: {mk.status_code} {mk.text}") # upload a file metadata = json.dumps({"path": file_path}) files = { "metadata": ("metadata", metadata, "application/json"), "file": ("file", b"hello execd\n", "application/octet-stream"), } up = session.post(f"{BASE_URL}/files/upload", files=files, timeout=10) expect(up.status_code == 200, f"upload failed: {up.status_code} {up.text}") # get info info = session.get(f"{BASE_URL}/files/info", params={"path": [file_path]}, timeout=10) expect(info.status_code == 200, f"info failed: {info.status_code} {info.text}") # search search = session.get(f"{BASE_URL}/files/search", params={"path": base_dir, "pattern": "*.txt"}, timeout=10) expect(search.status_code == 200, f"search failed: {search.status_code} {search.text}") found = False for f in search.json(): p = f.get("path") if not p: continue if pathlib.Path(p).resolve() == pathlib.Path(file_path).resolve(): found = True break expect(found, "search did not find file") # replace content rep = session.post( f"{BASE_URL}/files/replace", json={file_path: {"old": "hello", "new": "hi"}}, timeout=10, ) expect(rep.status_code == 200, f"replace failed: {rep.status_code} {rep.text}") # download to verify replace down = session.get(f"{BASE_URL}/files/download", params={"path": file_path}, timeout=10) expect(down.status_code == 200, f"download failed: {down.status_code} {down.text}") expect(down.content == b"hi execd\n", "replace content mismatch") # chmod (mode only) chmod = session.post(f"{BASE_URL}/files/permissions", json={file_path: {"mode": 644}}, timeout=10) expect(chmod.status_code == 200, f"chmod failed: {chmod.status_code} {chmod.text}") # rename mv = session.post( f"{BASE_URL}/files/mv", json=[{"src": file_path, "dest": renamed_path}], timeout=10, ) expect(mv.status_code == 200, f"rename failed: {mv.status_code} {mv.text}") # remove file rm_file = session.delete(f"{BASE_URL}/files", params={"path": [renamed_path]}, timeout=10) expect(rm_file.status_code == 200, f"remove file failed: {rm_file.status_code} {rm_file.text}") # remove dir rm_dir = session.delete(f"{BASE_URL}/directories", params={"path": [base_dir]}, timeout=10) expect(rm_dir.status_code == 200, f"remove dir failed: {rm_dir.status_code} {rm_dir.text}") def main(): print(f"[+] base: {BASE_URL}") r = session.get(f"{BASE_URL}/ping", timeout=5) expect(r.status_code == 200, "ping failed") print("[+] ping ok") sse_disconnect_should_stop_ping() print("[+] SSE disconnect handled") cmd_id = sse_get_command_id() print(f"[+] command id: {cmd_id}") status = wait_status(cmd_id) print(f"[+] status: {status}") logs, cursor = fetch_logs(cmd_id, cursor=0) print(f"[+] logs (cursor={cursor}):\n{logs}") filesystem_smoke() print("[+] filesystem APIs ok") print("[+] smoke tests PASS") if __name__ == "__main__": try: main() except SystemExit as exc: print(f"[!] smoke tests FAIL: {exc}", file=sys.stderr) sys.exit(1) ================================================ FILE: components/ingress/.golangci.yml ================================================ run: skip-dirs: - vendor - tests - scripts skip-files: - .*/zz_generated.deepcopy.go - .*/mock/*.go tests: false timeout: 10m linters-settings: funlen: lines: 500 statements: 200 gocyclo: min-complexity: 40 gosimple: checks: ["S1019", "S1002"] staticcheck: checks: ["SA4006"] govet: enable: - asmdecl - assign - atomic - atomicalign - bools - buildtag - cgocall - copylocks - deepequalerrors - errorsas - findcall - framepointer - httpresponse - ifaceassert - lostcancel - nilfunc - nilness - reflectvaluecompare - shift - sigchanyzer - sortslice - stdmethods - stringintconv - testinggoroutine - tests - unmarshal - unreachable - unsafeptr - unusedresult - printf disable: - composites - loopclosure - fieldalignment - shadow - structtag - unusedwrite errcheck: exclude-functions: - flag.Set - os.Setenv - os.Unsetenv - logger.Sync - fmt.Fprintf - fmt.Fprintln - (io.Closer).Close - (io.ReadCloser).Close - (k8s.io/client-go/tools/cache.SharedInformer).AddEventHandler nestif: # 复杂度大于32的认为阻塞 min-complexity: 32 goconst: # Minimal length of string constant. # Default: 3 min-len: 3 # Minimum occurrences of constant string count to trigger issue. # Default: 3 min-occurrences: 3 # Ignore test files. # Default: false ignore-tests: true match-constant: false numbers: true min: 2 max: 10 ignore-calls: true gosec: includes: - G101 # Look for hard coded credentials - G102 # Bind to all interfaces - G103 # Audit the use of unsafe block - G104 # Audit errors not checked - G106 # Audit the use of ssh.InsecureIgnoreHostKey - G107 # Url provided to HTTP request as taint input - G108 # Profiling endpoint automatically exposed on /debug/pprof - G109 # Potential Integer overflow made by strconv.Atoi result conversion to int16/32 - G110 # Potential DoS vulnerability via decompression bomb - G111 # Potential directory traversal - G112 # Potential slowloris attack - G113 # Usage of Rat.SetString in math/big with an overflow (CVE-2022-23772) # - G114 # Use of net/http serve function that has no support for setting timeouts - G201 # SQL query construction using format string - G202 # SQL query construction using string concatenation - G203 # Use of unescaped data in HTML templates #- G204 # Audit use of command execution - G301 # Poor file permissions used when creating a directory - G302 # Poor file permissions used with chmod - G303 # Creating tempfile using a predictable path - G304 # File path provided as taint input - G305 # File traversal when extracting zip/tar archive - G306 # Poor file permissions used when writing to a new file - G307 # Deferring a method which returns an error #- G401 # Detect the usage of DES, RC4, MD5 or SHA1 - G402 # Look for bad TLS connection settings - G403 # Ensure minimum RSA key length of 2048 bits - G404 # Insecure random number source (rand) #- G501 # Import blocklist: crypto/md5 - G502 # Import blocklist: crypto/des - G503 # Import blocklist: crypto/rc4 - G504 # Import blocklist: net/http/cgi - G505 # Import blocklist: crypto/sha1 - G601 # Implicit memory aliasing of items from a range statement # Exclude generated files # Default: false exclude-generated: true # Filter out the issues with a lower severity than the given value. # Valid options are: low, medium, high. # Default: low severity: medium # Filter out the issues with a lower confidence than the given value. # Valid options are: low, medium, high. # Default: low confidence: medium # Concurrency value. # Default: the number of logical CPUs usable by the current process. concurrency: 12 # To specify the configuration of rules. config: # Globals are applicable to all rules. global: nosec: true show-ignored: true audit: true G101: # Regexp pattern for variables and constants to find. # Default: "(?i)passwd|pass|password|pwd|secret|token|pw|apiKey|bearer|cred" pattern: "(?i)example" # If true, complain about all cases (even with low entropy). # Default: false ignore_entropy: false # Maximum allowed entropy of the string. # Default: "80.0" entropy_threshold: "80.0" per_char_threshold: "3.0" truncate: "32" G104: fmt: - Fscanf G111: # Regexp pattern to find potential directory traversal. # Default: "http\\.Dir\\(\"\\/\"\\)|http\\.Dir\\('\\/'\\)" pattern: "custom\\.Dir\\(\\)" # Maximum allowed permissions mode for os.Mkdir and os.MkdirAll # Default: "0750" G301: "0750" # Maximum allowed permissions mode for os.OpenFile and os.Chmod # Default: "0600" G302: "0600" # Maximum allowed permissions mode for os.WriteFile and ioutil.WriteFile # Default: "0600" G306: "0600" nilnil: checked-types: - ptr - map - chan depguard: rules: prevent_unmaintained_packages: list-mode: lax # allow unless explicitely denied files: - $all - "!$test" allow: - $gostd - path/filepath deny: - pkg: io/ioutil desc: "replaced by io and os packages since Go 1.16: https://tip.golang.org/doc/go1.16#ioutil" - pkg: path desc: "replaced by cross-platform package path/filepath" gci: # Section configuration to compare against. # Section names are case-insensitive and may contain parameters in (). # The default order of sections is `standard > default > custom > blank > dot > alias > localmodule`, # If `custom-order` is `true`, it follows the order of `sections` option. # Default: ["standard", "default"] sections: - standard # Standard section: captures all standard packages. - default # Default section: contains all imports that could not be matched to another section type.: - prefix(github.com/org/project) # Custom section: groups all imports with the specified Prefix. - blank # Blank section: contains all blank imports. This section is not present unless explicitly enabled. - dot # Dot section: contains all dot imports. This section is not present unless explicitly enabled. - localmodule # Local module section: contains all local packages. This section is not present unless explicitly enabled. # Skip generated files. # Default: true skip-generated: true # Enable custom order of sections. # If `true`, make the section order the same as the order of `sections`. # Default: false custom-order: true # Drops lexical ordering for custom sections. # Default: false no-lex-order: true forbidigo: forbid: # Forbid spew Dump, whether it is called as function or method. # Depends on analyze-types below. - ^spew\.(ConfigState\.)?Dump$ # The package name might be ambiguous. # The full import path can be used as additional criteria. # Depends on analyze-types below. - p: ^v1.Dump$ pkg: ^example.com/pkg/api/v1$ linters: enable: - asasalint - asciicheck - bidichk - bodyclose # - cyclop - decorder - depguard - errcheck # - errchkjson - errorlint - forbidigo # - forcetypeassert - funlen - ineffassign - gocognit - gocyclo - goheader - gomodguard - goprintffuncname - gosimple - gosec - grouper - importas - maintidx - misspell - nakedret - nilerr - nilnil # - noctx - nosprintfhostport - paralleltest - predeclared # - promlinter - reassign - sqlclosecheck - staticcheck - tenv - testpackage - tparallel # del # - typecheck - usestdlibvars - nestif - unused - makezero - govet - goconst - gci # - rowserrcheck # 1.59 version no new lints # 1.58 version new lints # - fatcontext - canonicalheader # 1.57 version new lints - copyloopvar - intrange # 1.56 version new lints - spancheck # 1.55 version new lints - gochecksumtype - perfsprint - sloglint - testifylint - mirror - zerologlint # 1.51 version new lints - gocheckcompilerdirectives # 1.50 version new lints - testableexamples issues: # Note: path identifiers are regular expressions, hence the \.go suffixes. exclude-rules: - path: main\.go linters: - forbidigo - path: _test\.go linters: - dogsled - errcheck - goconst - gosec - ineffassign - maintidx - typecheck - path: \.go$ text: "should have a package comment" - path: \.go$ text: 'exported (.+) should have comment( \(or a comment on this block\))? or be unexported' - path: \.go$ text: "fmt.Sprintf can be replaced with string concatenation" ================================================ FILE: components/ingress/DEVELOPMENT.md ================================================ # Development Guide (Quick) ## Prerequisites - Go 1.24+ - Docker (optional, for image build) - Access to a Kubernetes cluster with BatchSandbox CRD installed. ## Install deps ```bash cd components/ingress go mod tidy && go mod vendor ``` ## Build & Run ```bash make build # binary at bin/ingress with ldflags version info ./bin/ingress \ --namespace \ --port 28888 \ --log-level info ``` ## Tests & Lint ```bash make test # go test ./... go vet ./... # included in make build ``` ## Docker (with build args) ```bash docker build \ --build-arg VERSION=$(git describe --tags --always --dirty) \ --build-arg GIT_COMMIT=$(git rev-parse HEAD) \ --build-arg BUILD_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \ -t opensandbox/ingress:dev . ``` ## Key Paths - `main.go` — entrypoint, HTTP routes, provider initialization. - `pkg/proxy/` — HTTP/WebSocket reverse proxy logic. - `pkg/sandbox/` — Sandbox provider abstraction and BatchSandbox implementation. - `version/` — build metadata (ldflags). ## Tips - Health check: `/status.ok` - Proxy endpoint: `/` (routes based on `OpenSandbox-Ingress-To` header or Host) - Env overrides: `VERSION/GIT_COMMIT/BUILD_TIME` usable via Makefile and build.sh. - BatchSandbox must have `sandbox.opensandbox.io/endpoints` annotation with JSON array of IPs. ================================================ FILE: components/ingress/Dockerfile ================================================ # Copyright 2025 Alibaba Group Holding Ltd. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. FROM golang:1.24.0 AS builder WORKDIR /build ARG VERSION=dev ARG GIT_COMMIT=unknown ARG BUILD_TIME=unknown COPY kubernetes ./kubernetes # Prepare local modules to satisfy replace directives. COPY components/internal/go.mod components/internal/go.sum ./components/internal/ COPY components/ingress/go.mod components/ingress/go.sum ./components/ingress/ WORKDIR /build RUN cd components/internal && go mod download RUN cd components/ingress && go mod download # Copy sources. COPY components/internal ./components/internal COPY components/ingress/. ./components/ingress WORKDIR /build/components/ingress RUN CGO_ENABLED=0 go build \ -ldflags "-X 'github.com/alibaba/opensandbox/internal/version.Version=${VERSION}' \ -X 'github.com/alibaba/opensandbox/internal/version.BuildTime=${BUILD_TIME}' \ -X 'github.com/alibaba/opensandbox/internal/version.GitCommit=${GIT_COMMIT}'" \ -o /build/ingress ./main.go FROM alpine:latest COPY --from=builder /build/ingress . ENTRYPOINT ["./ingress"] ================================================ FILE: components/ingress/Makefile ================================================ .PHONY: fmt fmt: ## Run go fmt against code. go fmt ./... .PHONY: vet vet: ## Run go vet against code. go mod tidy && go mod vendor go vet ./... .PHONY: test test: vet ## Run tests go test -v -coverpkg=./... ./pkg/... ##@ Linter .PHONY: install-golint install-golint: @if ! command -v golangci-lint &> /dev/null; then \ echo "installing golangci-lint..."; \ go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest; \ else \ echo "golangci-lint already installed"; \ fi .PHONY: golint golint: fmt install-golint golangci-lint run -v --fix ./... VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") GIT_COMMIT ?= $(shell git rev-parse HEAD 2>/dev/null || echo "unknown") BUILD_TIME ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") LDFLAGS := -X 'github.com/alibaba/opensandbox/internal/version.Version=$(VERSION)' \ -X 'github.com/alibaba/opensandbox/internal/version.BuildTime=$(BUILD_TIME)' \ -X 'github.com/alibaba/opensandbox/internal/version.GitCommit=$(GIT_COMMIT)' .PHONY: build build: vet ## Build the binary. @mkdir -p bin go build -ldflags "$(LDFLAGS)" -o bin/router main.go .PHONY: clean clean: ## Clean build artifacts. rm -rf bin/ vendor/ ================================================ FILE: components/ingress/README.md ================================================ # OpenSandbox Ingress ## Overview - HTTP/WebSocket reverse proxy that routes to sandbox instances. - Watches sandbox CRs (BatchSandbox or AgentSandbox, chosen by `--provider-type`) in a target Namespace: - BatchSandbox: reads endpoints from `sandbox.opensandbox.io/endpoints` annotation. - AgentSandbox: reads `status.serviceFQDN`. - Exposes `/status.ok` health check; prints build metadata (version, commit, time, Go/platform) at startup. ## Quick Start ```bash go run main.go \ --namespace \ --provider-type \ --mode \ --port 28888 \ --log-level info ``` Endpoints: `/` (proxy), `/status.ok` (health). ## Routing Modes The ingress supports two routing modes for discovering sandbox instances: ### Header Mode (default: `--mode header`) Routes requests based on the `OpenSandbox-Ingress-To` header or the `Host` header. **Format:** - Header: `OpenSandbox-Ingress-To: -` - Host: `-.` **Example:** ```bash # Using OpenSandbox-Ingress-To header curl -H "OpenSandbox-Ingress-To: my-sandbox-8080" https://ingress.opensandbox.io/api/users # Using Host header curl -H "Host: my-sandbox-8080.example.com" https://ingress.opensandbox.io/api/users ``` **Parsing logic:** - Extracts sandbox ID and port from the format `-` - The last segment after the last `-` is treated as the port - Everything before the last `-` is treated as the sandbox ID ### URI Mode (`--mode uri`) Routes requests based on the URI path structure. **Format:** `///` **Example:** ```bash # Request to sandbox "my-sandbox" on port 8080, forwarding to /api/users curl https://ingress.opensandbox.io/my-sandbox/8080/api/users # WebSocket example wss://ingress.opensandbox.io/my-sandbox/8080/ws ``` **Parsing logic:** - First path segment: sandbox ID - Second path segment: sandbox port - Remaining path: forwarded to the target sandbox as the request URI - If no remaining path is provided, defaults to `/` **Use cases:** - When you cannot modify HTTP headers - When you need path-based routing - For simpler client configuration without custom headers ## Auto-Renew on Ingress Access (OSEP-0009) When enabled, the ingress publishes **renew-intent** events to a Redis list on each proxied request (after resolving the sandbox). The OpenSandbox server consumes these events and may extend sandbox expiration for sandboxes that opted in at creation time. See [OSEP-0009](https://github.com/alibaba/opensandbox/blob/main/oseps/0009-auto-renew-sandbox-on-ingress-access.md) for the full design. **Requirements:** The server must have auto-renew and Redis consumer enabled; the sandbox must be created with `extensions["auto_renew_on_access"]="true"`. This feature is best-effort and disabled by default. | Flag | Default | Description | |------|---------|-------------| | `--renew-intent-enabled` | `false` | Enable publishing renew-intent events to Redis | | `--renew-intent-redis-dsn` | `redis://127.0.0.1:6379/0` | Redis DSN (may include `user:password@`) | | `--renew-intent-queue-key` | `opensandbox:renew:intent` | Redis List key for intent payloads | | `--renew-intent-queue-max-len` | `0` | Max list length (0 = no cap); LTRIM applied when > 0 | | `--renew-intent-min-interval` | `60` | Min seconds between intents per sandbox (client-side throttle) | **Example (with Redis):** ```bash go run main.go \ --namespace opensandbox \ --renew-intent-enabled \ --renew-intent-redis-dsn "redis://user:pass@redis:6379/0" \ --renew-intent-min-interval 120 ``` ## Build ```bash cd components/ingress make build # override build metadata if needed VERSION=1.2.3 GIT_COMMIT=$(git rev-parse HEAD) BUILD_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") make build ``` ## Docker Build Dockerfile already wires ldflags via build args: ```bash docker build \ --build-arg VERSION=$(git describe --tags --always --dirty) \ --build-arg GIT_COMMIT=$(git rev-parse HEAD) \ --build-arg BUILD_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \ -t opensandbox/ingress:local . ``` ## Multi-arch Publish Script `build.sh` uses buildx to build/push linux/amd64 and linux/arm64: ```bash cd components/ingress TAG=local VERSION=1.2.3 GIT_COMMIT=abc BUILD_TIME=2025-01-01T00:00:00Z bash build.sh ``` ## Runtime Requirements - Access to Kubernetes API (in-cluster or via KUBECONFIG). - If `--provider-type=batchsandbox`: BatchSandbox CRs in the specified Namespace with `sandbox.opensandbox.io/endpoints` annotation containing Pod IPs. - If `--provider-type=agent-sandbox`: AgentSandbox CRs with `status.serviceFQDN` populated. ## Implementation Notes ### Header Mode Behavior - Routing key priority: `OpenSandbox-Ingress-To` header first, otherwise Host parsing `-.*`. - Sandbox name extracted from request is used to query the sandbox CR (BatchSandbox or AgentSandbox) via informer cache: - BatchSandbox → endpoints annotation. - AgentSandbox → `status.serviceFQDN`. - The original request path is preserved and forwarded to the target sandbox. ### URI Mode Behavior - Routing information is extracted from the URI path: `///`. - The sandbox ID and port are extracted from the first two path segments. - The remaining path (`/`) is forwarded to the target sandbox as the request URI. - If no remaining path is provided, the request URI defaults to `/`. ### Commons - Error handling: - `ErrSandboxNotFound` (sandbox resource not exists) → HTTP 404 - `ErrSandboxNotReady` (not enough replicas, missing endpoints, invalid config) → HTTP 503 - Other errors (K8s API errors, etc.) → HTTP 502 - WebSocket path forwards essential headers and X-Forwarded-*; HTTP path strips `OpenSandbox-Ingress-To` before proxying (header mode only). ## Development & Tests ```bash cd components/ingress go test ./... ``` Key code: - `main.go`: entrypoint and handlers. - `pkg/proxy/`: HTTP/WebSocket proxy logic, sandbox endpoint resolution. - `pkg/sandbox/`: Sandbox provider abstraction and BatchSandbox implementation. - `version/`: build metadata output (populated via ldflags). ================================================ FILE: components/ingress/build.sh ================================================ #!/bin/bash # Copyright 2025 Alibaba Group Holding Ltd. # # 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. set -ex TAG=${TAG:-latest} VERSION=${VERSION:-$(git describe --tags --always --dirty 2>/dev/null || echo "dev")} GIT_COMMIT=${GIT_COMMIT:-$(git rev-parse HEAD 2>/dev/null || echo "unknown")} BUILD_TIME=${BUILD_TIME:-$(date -u +"%Y-%m-%dT%H:%M:%SZ")} REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || realpath "$(dirname "$0")/../..") cd "${REPO_ROOT}" docker buildx rm ingress-builder || true docker buildx create --use --name ingress-builder docker buildx inspect --bootstrap docker buildx ls LATEST_TAGS=() if [[ "${TAG}" == v* ]]; then LATEST_TAGS+=(-t opensandbox/ingress:latest -t sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/ingress:latest) fi docker buildx build \ -t opensandbox/ingress:${TAG} \ -t sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/ingress:${TAG} \ "${LATEST_TAGS[@]}" \ -f components/ingress/Dockerfile \ --build-arg VERSION="${VERSION}" \ --build-arg GIT_COMMIT="${GIT_COMMIT}" \ --build-arg BUILD_TIME="${BUILD_TIME}" \ --platform linux/amd64,linux/arm64 \ --push \ . ================================================ FILE: components/ingress/go.mod ================================================ module github.com/alibaba/opensandbox/ingress go 1.24.0 require ( github.com/alibaba/OpenSandbox/sandbox-k8s v0.0.0 github.com/alibaba/opensandbox/internal v0.0.0 github.com/alicebob/miniredis/v2 v2.37.0 github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 github.com/redis/go-redis/v9 v9.18.0 github.com/stretchr/testify v1.11.1 k8s.io/apimachinery v0.34.3 k8s.io/client-go v0.34.3 knative.dev/pkg v0.0.0-20260120122510-4a022ed9999a ) require ( github.com/blendle/zapdriver v1.3.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/golang-lru v1.0.2 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/kelseyhightower/envconfig v1.4.0 // indirect github.com/mailru/easyjson v0.9.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/yuin/gopher-lua v1.1.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel v1.40.0 // indirect go.opentelemetry.io/otel/metric v1.40.0 // indirect go.opentelemetry.io/otel/trace v1.40.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/net v0.49.0 // indirect golang.org/x/oauth2 v0.32.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/term v0.39.0 // indirect golang.org/x/text v0.33.0 // indirect golang.org/x/time v0.10.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/api v0.34.3 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect sigs.k8s.io/controller-runtime v0.21.0 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) replace github.com/alibaba/OpenSandbox/sandbox-k8s => ../../kubernetes replace github.com/alibaba/opensandbox/internal => ../internal ================================================ FILE: components/ingress/go.sum ================================================ github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68= github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/blendle/zapdriver v1.3.1 h1:C3dydBOWYRiOk+B8X9IVZ5IOe+7cl+tGOexN4QqHfpE= github.com/blendle/zapdriver v1.3.1/go.mod h1:mdXfREi6u5MArG4j9fewC+FGnXaBR+T4Ox4J2u4eHCc= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/evanphx/json-patch v5.9.0+incompatible h1:fBXyNpNMuTTDdquAq/uisOr2lShz4oaXpDTX2bLe7ls= github.com/evanphx/json-patch v5.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg= github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= github.com/onsi/gomega v1.36.1 h1:bJDPBO7ibjxcbHMgSCoo4Yj18UWbKDlLwX1x9sybDcw= github.com/onsi/gomega v1.36.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= k8s.io/api v0.34.3 h1:D12sTP257/jSH2vHV2EDYrb16bS7ULlHpdNdNhEw2S4= k8s.io/api v0.34.3/go.mod h1:PyVQBF886Q5RSQZOim7DybQjAbVs8g7gwJNhGtY5MBk= k8s.io/apimachinery v0.34.3 h1:/TB+SFEiQvN9HPldtlWOTp0hWbJ+fjU+wkxysf/aQnE= k8s.io/apimachinery v0.34.3/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= k8s.io/client-go v0.34.3 h1:wtYtpzy/OPNYf7WyNBTj3iUA0XaBHVqhv4Iv3tbrF5A= k8s.io/client-go v0.34.3/go.mod h1:OxxeYagaP9Kdf78UrKLa3YZixMCfP6bgPwPwNBQBzpM= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= knative.dev/pkg v0.0.0-20260120122510-4a022ed9999a h1:9f29OTA7w/iVIX6PS6yveVVzNbcUS74eQfchVe8o2/4= knative.dev/pkg v0.0.0-20260120122510-4a022ed9999a/go.mod h1:Tz3GoxcNC5vH3Zo//cW3mnHL474u+Y1wbsUIZ11p8No= sigs.k8s.io/controller-runtime v0.21.0 h1:CYfjpEuicjUecRk+KAeyYh+ouUBn4llGyDYytIGcJS8= sigs.k8s.io/controller-runtime v0.21.0/go.mod h1:OSg14+F65eWqIu4DceX7k/+QRAbTTvxeQSNSOQpukWM= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= ================================================ FILE: components/ingress/main.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package main import ( "context" "fmt" "log" "net/http" "time" "k8s.io/apimachinery/pkg/runtime" "knative.dev/pkg/injection" "knative.dev/pkg/signals" "github.com/alibaba/opensandbox/ingress/pkg/flag" "github.com/alibaba/opensandbox/ingress/pkg/proxy" "github.com/alibaba/opensandbox/ingress/pkg/renewintent" "github.com/alibaba/opensandbox/ingress/pkg/sandbox" slogger "github.com/alibaba/opensandbox/internal/logger" "github.com/alibaba/opensandbox/internal/version" ) func main() { version.EchoVersion("OpenSandbox Ingress") flag.InitFlags() if flag.Namespace == "" { log.Panicf("'-namespace' not set.") } cfg := injection.ParseAndGetRESTConfigOrDie() cfg.ContentType = runtime.ContentTypeProtobuf cfg.UserAgent = "opensandbox-ingress/" + version.GitCommit ctx := signals.NewContext() ctx = withLogger(ctx, flag.LogLevel) // Create sandbox provider factory providerFactory := sandbox.NewProviderFactory( cfg, flag.Namespace, time.Second*30, // resync period ) // Create sandbox provider based on provider type sandboxProvider, err := providerFactory.CreateProvider(sandbox.ProviderType(flag.ProviderType)) if err != nil { log.Panicf("Failed to create sandbox provider: %v", err) } // Start provider (includes cache sync) if err := sandboxProvider.Start(ctx); err != nil { log.Panicf("Failed to start sandbox provider: %v", err) } var renewPublisher renewintent.Publisher if flag.RenewIntentEnabled { redisClient, err := renewintent.RedisClientFromDSN(flag.RenewIntentRedisDSN) if err != nil { log.Panicf("Failed to create Redis client for renew-intent: %v", err) } renewPublisher = renewintent.NewRedisPublisher(ctx, redisClient, renewintent.RedisPublisherConfig{ QueueKey: flag.RenewIntentQueueKey, QueueMaxLen: flag.RenewIntentQueueMaxLen, MinInterval: time.Duration(flag.RenewIntentMinIntervalSec) * time.Second, Logger: proxy.Logger, }) } // Create reverse proxy with sandbox provider reverseProxy := proxy.NewProxy(ctx, sandboxProvider, proxy.Mode(flag.Mode), renewPublisher) http.Handle("/", reverseProxy) http.HandleFunc("/status.ok", proxy.Healthz) if err := http.ListenAndServe(fmt.Sprintf(":%v", flag.Port), nil); err != nil { log.Panicf("Error starting http server: %v", err) } panic("unreachable") } func withLogger(ctx context.Context, logLevel string) context.Context { logger := slogger.MustNew(slogger.Config{Level: logLevel}).Named("opensandbox.ingress") return proxy.WithLogger(ctx, logger) } ================================================ FILE: components/ingress/pkg/flag/flags.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package flag var ( // LogLevel controls the router log verbosity. LogLevel string // Port controls the HTTP listener port. Port int // Namespace filters the target sandbox instances. Namespace string // ProviderType specifies the sandbox provider type (e.g., batchsandbox). ProviderType string // Mode specifies the sandbox service discovery mode (e.g., header, uri). Mode string RenewIntentEnabled bool RenewIntentRedisDSN string RenewIntentQueueKey string RenewIntentQueueMaxLen int RenewIntentMinIntervalSec int ) ================================================ FILE: components/ingress/pkg/flag/parser.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package flag import ( "flag" ) func InitFlags() { flag.StringVar(&LogLevel, "log-level", "info", "Server log level") flag.IntVar(&Port, "port", 28888, "Server listening port (default: 28888)") flag.StringVar(&Namespace, "namespace", "opensandbox", "The Kubernetes namespace to watch for sandbox resources") flag.StringVar(&ProviderType, "provider-type", "batchsandbox", "The sandbox provider type (default: batchsandbox)") flag.StringVar(&Mode, "mode", "header", "The sandbox service discovery mode (default: header)") flag.BoolVar(&RenewIntentEnabled, "renew-intent-enabled", false, "Enable publishing renew-intent events to Redis (OSEP-0009)") flag.StringVar(&RenewIntentRedisDSN, "renew-intent-redis-dsn", "redis://127.0.0.1:6379/0", "Redis DSN for renew-intent queue") flag.StringVar(&RenewIntentQueueKey, "renew-intent-queue-key", "opensandbox:renew:intent", "Redis List key for renew-intent payloads") flag.IntVar(&RenewIntentQueueMaxLen, "renew-intent-queue-max-len", 0, "Max renew-intent queue length (0 = no cap)") flag.IntVar(&RenewIntentMinIntervalSec, "renew-intent-min-interval", 60, "Min seconds between publishing intents for the same sandbox (client-side throttle)") flag.Parse() } ================================================ FILE: components/ingress/pkg/proxy/header.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package proxy import "net/http" var ( XRealIP = http.CanonicalHeaderKey("X-Real-IP") XForwardedFor = http.CanonicalHeaderKey("X-Forwarded-For") XForwardedProto = http.CanonicalHeaderKey("X-Forwarded-Proto") SandboxIngress = http.CanonicalHeaderKey("OpenSandbox-Ingress-To") // DeprecatedSandboxIngress is the deprecated header name // Deprecated DeprecatedSandboxIngress = http.CanonicalHeaderKey("OPEN-SANDBOX-INGRESS") AccessControlAllowOrigin = http.CanonicalHeaderKey("Access-Control-Allow-Origin") ReverseProxyServerPowerBy = http.CanonicalHeaderKey("Reverse-Proxy-Server-PowerBy") SecWebSocketProtocol = http.CanonicalHeaderKey("Sec-WebSocket-Protocol") Cookie = http.CanonicalHeaderKey("Cookie") SetCookie = http.CanonicalHeaderKey("Set-Cookie") Host = http.CanonicalHeaderKey("Host") Origin = http.CanonicalHeaderKey("Origin") ) ================================================ FILE: components/ingress/pkg/proxy/healthz.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package proxy import "net/http" func Healthz(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("OK")) } ================================================ FILE: components/ingress/pkg/proxy/healthz_test.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package proxy import ( "net/http" "net/http/httptest" "testing" "github.com/stretchr/testify/assert" ) func TestHealthz(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/healthz", nil) rr := httptest.NewRecorder() Healthz(rr, req) assert.Equal(t, http.StatusOK, rr.Code) assert.Equal(t, "OK", rr.Body.String()) } ================================================ FILE: components/ingress/pkg/proxy/host.go ================================================ // Copyright 2026 Alibaba Group Holding Ltd. // // 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. package proxy import ( "errors" "fmt" "net/http" "strconv" "strings" ) type Mode string const ( // ModeHeader is the mode that uses the Host or SandboxIngress header // to determine the sandbox instance. ModeHeader Mode = "header" // ModeURI is the mode that uses the URI path to determine the // sandbox instance. // // Pattern is 'hostname///'. ModeURI Mode = "uri" ) func (p *Proxy) getSandboxHostDefinition(r *http.Request) (*sandboxHost, error) { switch p.mode { case ModeHeader: targetHost := p.parseTargetHostByHeader(r) if targetHost == "" { return nil, fmt.Errorf("missing header '%s' or 'Host'", SandboxIngress) } host, err := p.parseSandboxHost(targetHost) if err != nil || host.ingressKey == "" || host.port == 0 { return nil, fmt.Errorf("invalid host: %s", targetHost) } return host, nil case ModeURI: return p.parseSandboxURI(r) } return nil, fmt.Errorf("unknown ingress mode: %s", p.mode) } func (p *Proxy) parseTargetHostByHeader(r *http.Request) string { targetHost := r.Header.Get(SandboxIngress) if targetHost != "" { return targetHost } deprecatedTargetHost := r.Header.Get(DeprecatedSandboxIngress) if deprecatedTargetHost != "" { return deprecatedTargetHost } return r.Host } type sandboxHost struct { ingressKey string port int requestURI string } func (p *Proxy) parseSandboxHost(s string) (*sandboxHost, error) { domain := strings.Split(strings.TrimPrefix(strings.TrimPrefix(s, "https://"), "http://"), ".") if len(domain) < 1 { return &sandboxHost{}, fmt.Errorf("invalid host: %s", s) } ingressAndPort := strings.Split(domain[0], "-") if len(ingressAndPort) <= 1 || ingressAndPort[0] == "" { return &sandboxHost{}, fmt.Errorf("invalid host: %s", s) } ingress := strings.Join(ingressAndPort[:len(ingressAndPort)-1], "-") port, err := strconv.Atoi(ingressAndPort[len(ingressAndPort)-1]) if err != nil { return &sandboxHost{}, fmt.Errorf("invalid port format: %w", err) } return &sandboxHost{ingress, port, ""}, nil } func (p *Proxy) parseSandboxURI(r *http.Request) (*sandboxHost, error) { path := r.URL.Path if path == "" { return nil, errors.New("missing URI path") } // Remove leading slash and split by '/' path = strings.TrimPrefix(path, "/") parts := strings.SplitN(path, "/", 3) if len(parts) < 2 { return nil, fmt.Errorf("invalid URI path format: expected '///', got: %s", r.URL.Path) } sandboxID := parts[0] port, err := strconv.Atoi(parts[1]) if err != nil { return nil, fmt.Errorf("invalid port format: %w", err) } if sandboxID == "" || port <= 0 { return nil, errors.New("missing sandbox-id or sandbox-port in URI path") } // Extract the remaining path (user's target request URI) var requestURI string if len(parts) >= 3 && parts[2] != "" { requestURI = "/" + parts[2] } else { requestURI = "/" } return &sandboxHost{ ingressKey: sandboxID, port: port, requestURI: requestURI, }, nil } ================================================ FILE: components/ingress/pkg/proxy/http.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package proxy import ( "net/http" "net/http/httputil" "net/url" ) type HTTPProxy struct{} func NewHTTPProxy() *HTTPProxy { return &HTTPProxy{} } func (hp *HTTPProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { targetURL := r.URL.String() proxy, err := hp.newReverseProxy(targetURL) if err != nil { http.Error(w, err.Error(), http.StatusBadGateway) return } proxy.ServeHTTP(w, r) } func (hp *HTTPProxy) newReverseProxy(targetHost string) (*httputil.ReverseProxy, error) { url, err := url.Parse(targetHost) if err != nil { return nil, err } proxy := httputil.NewSingleHostReverseProxy(url) proxy.Director = func(req *http.Request) { req.URL.Scheme = url.Scheme req.URL.Host = url.Host req.Host = url.Host req.Header.Del(SandboxIngress) } proxy.ModifyResponse = func(response *http.Response) error { response.Header.Add(ReverseProxyServerPowerBy, "OpenSandbox-ingress") return nil } return proxy, nil } ================================================ FILE: components/ingress/pkg/proxy/http_test.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package proxy import ( "context" "fmt" "io" "net/http" "net/http/httptest" "strconv" "testing" "time" "github.com/alibaba/opensandbox/ingress/pkg/sandbox" slogger "github.com/alibaba/opensandbox/internal/logger" "github.com/stretchr/testify/assert" ) // mockProvider implements sandbox.Provider interface for testing type mockProvider struct { endpoints map[string]string // sandboxName -> IP notReady map[string]bool // sandboxName -> notReady flag } func (m *mockProvider) GetEndpoint(sandboxId string) (string, error) { if m.notReady != nil && m.notReady[sandboxId] { return "", fmt.Errorf("%w: %s", sandbox.ErrSandboxNotReady, sandboxId) } if ip, ok := m.endpoints[sandboxId]; ok { return ip, nil } return "", fmt.Errorf("%w: %s", sandbox.ErrSandboxNotFound, sandboxId) } func (m *mockProvider) Start(_ context.Context) error { return nil } func Test_HTTPProxy(t *testing.T) { t.Run("with header mode", func(t *testing.T) { httpProxyWithHeaderMode(t) }) t.Run("with uri mode", func(t *testing.T) { httpProxyWithURIMode(t) }) } func httpProxyWithHeaderMode(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(realBackendHTTPHandler)) defer server.Close() serverPort := server.URL[len("http://127.0.0.1:"):] // Create mock provider with sandbox endpoint provider := &mockProvider{ endpoints: map[string]string{ "test-sandbox": "127.0.0.1", }, } ctx := context.Background() Logger = slogger.MustNew(slogger.Config{Level: "debug"}) proxy := NewProxy(ctx, provider, ModeHeader, nil) mux := http.NewServeMux() mux.Handle("/", proxy) port, err := findAvailablePort() assert.Nil(t, err) go func() { assert.NoError(t, http.ListenAndServe(":"+strconv.Itoa(port), mux)) }() time.Sleep(2 * time.Second) // no header request, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("http://127.0.0.1:%v/hello", port), nil) assert.Nil(t, err) response, err := http.DefaultClient.Do(request) assert.Nil(t, err) assert.Equal(t, http.StatusBadRequest, response.StatusCode) bytes, _ := io.ReadAll(response.Body) t.Log(string(bytes)) // no sandbox backend request, err = http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("http://127.0.0.1:%v/hello", port), nil) request.Header.Set(SandboxIngress, fmt.Sprintf("non-existent-%v", port)) response, err = http.DefaultClient.Do(request) assert.Nil(t, err) assert.Equal(t, http.StatusNotFound, response.StatusCode) // ErrSandboxNotFound -> 404 bytes, _ = io.ReadAll(response.Body) t.Log(string(bytes)) // valid sandbox request request, err = http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("http://127.0.0.1:%v/hello?a=1&b=2", port), nil) assert.Nil(t, err) request.Header.Set(SandboxIngress, fmt.Sprintf("test-sandbox-%v", serverPort)) response, err = http.DefaultClient.Do(request) assert.Nil(t, err) if response.StatusCode != http.StatusOK { bytes, err := io.ReadAll(response.Body) assert.Nil(t, err) t.Log(string(bytes)) } assert.Equal(t, http.StatusOK, response.StatusCode) // Compatible Host parsing for reverse proxy mode request, err = http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("http://127.0.0.1:%v/hello?a=1&b=2", port), nil) assert.Nil(t, err) request.Host = fmt.Sprintf("test-sandbox-%v.sandbox.alibaba-inc.com", serverPort) response, err = http.DefaultClient.Do(request) assert.Nil(t, err) if response.StatusCode != http.StatusOK { bytes, err := io.ReadAll(response.Body) assert.Nil(t, err) t.Log(string(bytes)) } assert.Equal(t, http.StatusOK, response.StatusCode) } func httpProxyWithURIMode(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(realBackendHTTPHandler)) defer server.Close() serverPort := server.URL[len("http://127.0.0.1:"):] // Create mock provider with sandbox endpoint provider := &mockProvider{ endpoints: map[string]string{ "test-sandbox": "127.0.0.1", }, } ctx := context.Background() Logger = slogger.MustNew(slogger.Config{Level: "debug"}) proxy := NewProxy(ctx, provider, ModeURI, nil) mux := http.NewServeMux() mux.Handle("/", proxy) port, err := findAvailablePort() assert.Nil(t, err) go func() { assert.NoError(t, http.ListenAndServe(":"+strconv.Itoa(port), mux)) }() time.Sleep(2 * time.Second) // uri is empty request, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("http://127.0.0.1:%v", port), nil) assert.Nil(t, err) response, err := http.DefaultClient.Do(request) assert.Nil(t, err) assert.Equal(t, http.StatusBadRequest, response.StatusCode) bytes, _ := io.ReadAll(response.Body) t.Log(string(bytes)) // no sandbox backend request, err = http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("http://127.0.0.1:%v/non-existent-xxx/80/hello", port), nil) response, err = http.DefaultClient.Do(request) assert.Nil(t, err) assert.Equal(t, http.StatusNotFound, response.StatusCode) // ErrSandboxNotFound -> 404 bytes, _ = io.ReadAll(response.Body) t.Log(string(bytes)) // valid sandbox request request, err = http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("http://127.0.0.1:%v/test-sandbox/%v/hello?a=1&b=2", port, serverPort), nil) assert.Nil(t, err) response, err = http.DefaultClient.Do(request) assert.Nil(t, err) if response.StatusCode != http.StatusOK { bytes, err := io.ReadAll(response.Body) assert.Nil(t, err) t.Log(string(bytes)) } assert.Equal(t, http.StatusOK, response.StatusCode) } func realBackendHTTPHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) return } if r.URL.Path != "/hello" { http.Error(w, fmt.Sprintf("path is not /hello, but %s", r.URL.Path), http.StatusBadRequest) } if r.URL.RawQuery != "a=1&b=2" { http.Error(w, fmt.Sprintf("query is not a=1&b=2, but %s", r.URL.RawQuery), http.StatusBadRequest) } w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("hello world")) } ================================================ FILE: components/ingress/pkg/proxy/logger.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package proxy import ( "context" slogger "github.com/alibaba/opensandbox/internal/logger" ) var Logger slogger.Logger func WithLogger(ctx context.Context, logger slogger.Logger) context.Context { Logger = logger return ctx } ================================================ FILE: components/ingress/pkg/proxy/proxy.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package proxy import ( "context" "errors" "fmt" "net" "net/http" "strings" "github.com/alibaba/opensandbox/ingress/pkg/renewintent" "github.com/alibaba/opensandbox/ingress/pkg/sandbox" slogger "github.com/alibaba/opensandbox/internal/logger" ) type Proxy struct { sandboxProvider sandbox.Provider mode Mode renewIntentPublisher renewintent.Publisher } func NewProxy(_ context.Context, sandboxProvider sandbox.Provider, mode Mode, renewIntentPublisher renewintent.Publisher) *Proxy { return &Proxy{ sandboxProvider: sandboxProvider, mode: mode, renewIntentPublisher: renewIntentPublisher, } } func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { defer func() { if err := recover(); err != nil { Logger.With(slogger.Field{Key: "error", Value: err}).Errorf("Proxy: proxy causes panic") var errMsg string if e, ok := err.(error); ok { errMsg = e.Error() } else { errMsg = fmt.Sprintf("%v", err) } http.Error(w, errMsg, http.StatusBadGateway) } }() host, err := p.getSandboxHostDefinition(r) if err != nil { http.Error(w, fmt.Sprintf("OpenSandbox Ingress: %v", err), http.StatusBadRequest) return } targetHost, err, code := p.resolveRealHost(host) if err != nil { http.Error(w, fmt.Sprintf("OpenSandbox Ingress: %v", err), code) return } if p.renewIntentPublisher != nil { p.renewIntentPublisher.PublishIntent(host.ingressKey, host.port, host.requestURI) } // modify if requestURI is not empty if host.requestURI != "" { r.URL.Path = host.requestURI } r.Host = targetHost r.URL.Host = targetHost r.Header.Del(SandboxIngress) Logger.With( slogger.Field{Key: "target", Value: targetHost}, slogger.Field{Key: "client", Value: p.getClientIP(r)}, slogger.Field{Key: "uri", Value: r.RequestURI}, slogger.Field{Key: "method", Value: r.Method}, ).Infof("ingress requested") p.serve(w, r) } func (p *Proxy) serve(w http.ResponseWriter, r *http.Request) { if p.isWebSocketRequest(r) { if r.URL == nil { http.Error(w, "invalid request URL", http.StatusBadRequest) return } if r.URL.Scheme == "" { if r.TLS != nil { r.URL.Scheme = "wss" } else { r.URL.Scheme = "ws" } } NewWebSocketProxy(r.URL).ServeHTTP(w, r) } else { if r.URL.Scheme == "" { if r.TLS != nil { r.URL.Scheme = "https" } else { r.URL.Scheme = "http" } } NewHTTPProxy().ServeHTTP(w, r) } } func (p *Proxy) isWebSocketRequest(r *http.Request) bool { if r.Method != http.MethodGet { return false } if r.Header.Get("Upgrade") != "websocket" { return false } if r.Header.Get("Connection") != "Upgrade" { return false } return true } func (p *Proxy) resolveRealHost(host *sandboxHost) (string, error, int) { // Get endpoint IP from sandbox provider endpointIP, err := p.sandboxProvider.GetEndpoint(host.ingressKey) if err != nil { // Map sandbox errors to HTTP status codes switch { case errors.Is(err, sandbox.ErrSandboxNotFound): return "", err, http.StatusNotFound case errors.Is(err, sandbox.ErrSandboxNotReady): return "", err, http.StatusServiceUnavailable default: return "", err, http.StatusBadGateway } } // Construct target host with port targetHost := fmt.Sprintf("%s:%d", endpointIP, host.port) return targetHost, nil, 0 } func (p *Proxy) getClientIP(r *http.Request) string { clientIP, _, _ := net.SplitHostPort(r.RemoteAddr) if len(r.Header.Get(XForwardedFor)) != 0 { xff := r.Header.Get(XForwardedFor) s := strings.Index(xff, ", ") if s == -1 { s = len(r.Header.Get(XForwardedFor)) } clientIP = xff[:s] } else if len(r.Header.Get(XRealIP)) != 0 { clientIP = r.Header.Get(XRealIP) } return clientIP } ================================================ FILE: components/ingress/pkg/proxy/proxy_test.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package proxy import ( "net" "net/http" "net/http/httptest" "testing" "github.com/stretchr/testify/assert" ) // Test_WatchPods is removed as we now use BatchSandbox Provider instead of direct Pod watching func TestIsWebSocketRequest(t *testing.T) { proxy := &Proxy{} // Valid websocket request req := httptest.NewRequest(http.MethodGet, "/ws", nil) req.Header.Set("Upgrade", "websocket") req.Header.Set("Connection", "Upgrade") assert.True(t, proxy.isWebSocketRequest(req)) // Missing upgrade headers req = httptest.NewRequest(http.MethodGet, "/ws", nil) assert.False(t, proxy.isWebSocketRequest(req)) // Wrong method req = httptest.NewRequest(http.MethodPost, "/ws", nil) req.Header.Set("Upgrade", "websocket") req.Header.Set("Connection", "Upgrade") assert.False(t, proxy.isWebSocketRequest(req)) } func TestParseSandboxHost(t *testing.T) { proxy := &Proxy{} host, err := proxy.parseSandboxHost("sandbox-1234.example.com") assert.NoError(t, err) assert.Equal(t, "sandbox", host.ingressKey) assert.Equal(t, 1234, host.port) host, err = proxy.parseSandboxHost("https://alpha-beta-8080.sandbox.test") assert.NoError(t, err) assert.Equal(t, "alpha-beta", host.ingressKey) assert.Equal(t, 8080, host.port) _, err = proxy.parseSandboxHost("invalidhost") assert.Error(t, err) _, err = proxy.parseSandboxHost("-1234.example.com") assert.Error(t, err) } func TestGetClientIP(t *testing.T) { proxy := &Proxy{} req := httptest.NewRequest(http.MethodGet, "/", nil) req.RemoteAddr = "192.0.2.1:12345" assert.Equal(t, "192.0.2.1", proxy.getClientIP(req)) req = httptest.NewRequest(http.MethodGet, "/", nil) req.RemoteAddr = "192.0.2.1:12345" req.Header.Set(XRealIP, "203.0.113.5") assert.Equal(t, "203.0.113.5", proxy.getClientIP(req)) req = httptest.NewRequest(http.MethodGet, "/", nil) req.RemoteAddr = "192.0.2.1:12345" req.Header.Set(XForwardedFor, "10.0.0.1, 198.51.100.2") assert.Equal(t, "10.0.0.1", proxy.getClientIP(req)) } func findAvailablePort() (int, error) { listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { return 0, err } defer listener.Close() port := listener.Addr().(*net.TCPAddr).Port return port, nil } ================================================ FILE: components/ingress/pkg/proxy/websocket.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package proxy import ( "fmt" "io" "net" "net/http" "net/url" "strings" slogger "github.com/alibaba/opensandbox/internal/logger" "github.com/gorilla/websocket" ) var ( // defaultWebSocketDialer is a dialer with all fields set to the default zero values. defaultWebSocketDialer = websocket.DefaultDialer // defaultUpgrader specifies the parameters for upgrading an HTTP // connection to a WebSocket connection. defaultUpgrader = &websocket.Upgrader{ ReadBufferSize: 1024, WriteBufferSize: 1024, } ) // WebSocketProxy is an HTTP Handler that takes an incoming WebSocket // connection and proxies it to another server. type WebSocketProxy struct { // director, if non-nil, is a function that may copy additional request // headers from the incoming WebSocket connection into the output headers // which will be forwarded to another server. director func(incoming *http.Request, out http.Header) // backend returns the backend URL which the proxy uses to reverse proxy // the incoming WebSocket connection. Request is the initial incoming and // unmodified request. backend func(*http.Request) *url.URL // dialer contains options for connecting to the backend WebSocket server. // If nil, DefaultDialer is used. dialer *websocket.Dialer // upgrader specifies the parameters for upgrading a incoming HTTP // connection to a WebSocket connection. If nil, DefaultUpgrader is used. upgrader *websocket.Upgrader } // ProxyHandler returns a new http.Handler interface that reverse proxies the // request to the given target. func ProxyHandler(target *url.URL) http.Handler { return NewWebSocketProxy(target) } // NewWebSocketProxy returns a new Websocket reverse proxy that rewrites the // URL's to the scheme, host and base path provider in target. func NewWebSocketProxy(target *url.URL) *WebSocketProxy { backend := func(r *http.Request) *url.URL { // Shallow copy u := *target u.Fragment = r.URL.Fragment u.Path = r.URL.Path u.RawQuery = r.URL.RawQuery return &u } return &WebSocketProxy{backend: backend} } //nolint:gocognit func (w *WebSocketProxy) ServeHTTP(rw http.ResponseWriter, r *http.Request) { if w.backend == nil { http.Error(rw, "WebSocketProxy: backend is not defined", http.StatusInternalServerError) return } backendURL := w.backend(r) if backendURL == nil { http.Error(rw, "WebSocketProxy: backend URL is nil", http.StatusInternalServerError) return } dialer := w.dialer if w.dialer == nil { dialer = defaultWebSocketDialer } // Pass headers from the incoming request to the dialer to forward them to // the final destinations. requestHeader := http.Header{} if origin := r.Header.Get(Origin); origin != "" { requestHeader.Add(Origin, origin) } for _, prot := range r.Header[SecWebSocketProtocol] { requestHeader.Add(SecWebSocketProtocol, prot) } for _, cokiee := range r.Header[Cookie] { requestHeader.Add(Cookie, cokiee) } if r.Host != "" { requestHeader.Set(Host, r.Host) } // Pass X-Forwarded-For headers too, code below is a part of // httputil.ReverseProxy. See http://en.wikipedia.org/wiki/X-Forwarded-For // for more information if clientIP, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { // If we aren't the first proxy retain prior // X-Forwarded-For information as a comma+space // separated list and fold multiple headers into one. if prior, ok := r.Header[XForwardedFor]; ok { clientIP = strings.Join(prior, ", ") + ", " + clientIP } requestHeader.Set(XForwardedFor, clientIP) } // Set the originating protocol of the incoming HTTP request. The SSL might // be terminated on our site and because we doing proxy adding this would // be helpful for applications on the backend. requestHeader.Set(XForwardedProto, "http") if r.TLS != nil { requestHeader.Set(XForwardedProto, "https") } // Enable the director to copy any additional headers it desires for // forwarding to the remote server. if w.director != nil { w.director(r, requestHeader) } // Connect to the backend URL, also pass the headers we get from the requst // together with the Forwarded headers we prepared above. connBackend, resp, err := dialer.Dial(backendURL.String(), requestHeader) if err != nil { Logger.With(slogger.Field{Key: "error", Value: err}).Errorf("WebSocketProxy: couldn't dial to remote backend") if resp != nil { // If the WebSocket handshake fails, ErrBadHandshake is returned // along with a non-nil *http.Response so that callers can handle // redirects, authentication, etcetera. if err := copyResponse(rw, resp); err != nil { Logger.With(slogger.Field{Key: "error", Value: err}).Errorf("WebSocketProxy: couldn't write response after failed remote backend handshake") } } else { http.Error(rw, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable) } return } defer connBackend.Close() upgrader := w.upgrader if w.upgrader == nil { upgrader = defaultUpgrader } // Only pass those headers to the upgrader. upgradeHeader := http.Header{} if hdr := resp.Header.Get(SecWebSocketProtocol); hdr != "" { upgradeHeader.Set(SecWebSocketProtocol, hdr) } if hdr := resp.Header.Get(SetCookie); hdr != "" { upgradeHeader.Set(SetCookie, hdr) } // Now upgrade the existing incoming request to a WebSocket connection. // Also pass the header that we gathered from the Dial handshake. connPub, err := upgrader.Upgrade(rw, r, upgradeHeader) if err != nil { Logger.With(slogger.Field{Key: "error", Value: err}).Errorf("WebSocketProxy: couldn't upgrade websocket connection") return } defer connPub.Close() errClient := make(chan error, 1) errBackend := make(chan error, 1) replicateWebsocketConn := func(dst, src *websocket.Conn, errc chan error) { for { msgType, msg, err := src.ReadMessage() if err != nil { m := websocket.FormatCloseMessage(websocket.CloseNormalClosure, fmt.Sprintf("%v", err)) if e, ok := err.(*websocket.CloseError); ok { //nolint:errorlint if e.Code != websocket.CloseNoStatusReceived { m = websocket.FormatCloseMessage(e.Code, e.Text) } } errc <- err _ = dst.WriteMessage(websocket.CloseMessage, m) break } err = dst.WriteMessage(msgType, msg) if err != nil { errc <- err break } } } go replicateWebsocketConn(connPub, connBackend, errClient) go replicateWebsocketConn(connBackend, connPub, errBackend) var message string select { case err = <-errClient: message = "WebSocketProxy: Error when copying from backend to client: %v" case err = <-errBackend: message = "WebSocketProxy: Error when copying from client to backend: %v" } if e, ok := err.(*websocket.CloseError); !ok || e.Code == websocket.CloseAbnormalClosure { //nolint:errorlint Logger.With(slogger.Field{Key: "error", Value: err}).Errorf(message, err) } } func copyResponse(rw http.ResponseWriter, resp *http.Response) error { copyHeader(rw.Header(), resp.Header) rw.WriteHeader(resp.StatusCode) defer resp.Body.Close() _, err := io.Copy(rw, resp.Body) return err } func copyHeader(dst, src http.Header) { for k, vv := range src { for _, v := range vv { dst.Add(k, v) } } } ================================================ FILE: components/ingress/pkg/proxy/websocket_test.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package proxy import ( "context" "fmt" "log" "net/http" "strconv" "strings" "testing" "time" slogger "github.com/alibaba/opensandbox/internal/logger" "github.com/gorilla/websocket" "github.com/stretchr/testify/assert" ) func Test_WebSocketProxy(t *testing.T) { t.Run("with header mode", func(t *testing.T) { webSocketProxyWithHeaderMode(t) }) t.Run("with uri mode", func(t *testing.T) { webSocketProxyWithURIMode(t) }) } func webSocketProxyWithHeaderMode(t *testing.T) { // Create mock provider provider := &mockProvider{ endpoints: map[string]string{ "test-sandbox": "127.0.0.1", }, } ctx := context.Background() Logger = slogger.MustNew(slogger.Config{Level: "debug"}) proxy := NewProxy(ctx, provider, ModeHeader, nil) mux := http.NewServeMux() mux.Handle("/", proxy) proxyPort, err := findAvailablePort() proxyURL := "ws://127.0.0.1:" + strconv.Itoa(proxyPort) assert.Nil(t, err) go func() { assert.NoError(t, http.ListenAndServe(":"+strconv.Itoa(proxyPort), mux)) }() time.Sleep(2 * time.Second) backendPort, err := findAvailablePort() assert.Nil(t, err) // backend echo server go func() { mux2 := http.NewServeMux() mux2.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { t.Logf("r.URL.Path: %s", r.URL.Path) t.Logf("r.URL.RawPath: %s", r.URL.RawPath) t.Logf("r.Host: %s", r.Host) // Don't upgrade if original host header isn't preserved assert.True(t, strings.HasPrefix(r.Host, "127.0.0.1")) conn, err := defaultUpgrader.Upgrade(w, r, nil) if err != nil { log.Println(err) return } messageType, p, err := conn.ReadMessage() if err != nil { return } if err = conn.WriteMessage(messageType, p); err != nil { return } }) err := http.ListenAndServe(":"+strconv.Itoa(backendPort), mux2) if err != nil { t.Error("ListenAndServe: ", err) return } }() time.Sleep(time.Millisecond * 100) // frontend server, dial now our proxy, which will reverse proxy our // message to the backend websocket server. h := http.Header{} h.Set(SandboxIngress, "test-sandbox-"+strconv.Itoa(backendPort)) conn, _, err := websocket.DefaultDialer.Dial(proxyURL+"/ws", h) if err != nil { t.Fatal(err) } // write a message and send it to the backend server msg := "hello kite" err = conn.WriteMessage(websocket.TextMessage, []byte(msg)) if err != nil { t.Error(err) } messageType, p, err := conn.ReadMessage() if err != nil { t.Error(err) } if messageType != websocket.TextMessage { t.Error("incoming message type is not Text") } if msg != string(p) { t.Errorf("expecting: %s, got: %s", msg, string(p)) } } func webSocketProxyWithURIMode(t *testing.T) { // Create mock provider provider := &mockProvider{ endpoints: map[string]string{ "test-sandbox": "127.0.0.1", }, } ctx := context.Background() Logger = slogger.MustNew(slogger.Config{Level: "debug"}) proxy := NewProxy(ctx, provider, ModeURI, nil) mux := http.NewServeMux() mux.Handle("/", proxy) proxyPort, err := findAvailablePort() proxyURL := "ws://127.0.0.1:" + strconv.Itoa(proxyPort) assert.Nil(t, err) go func() { assert.NoError(t, http.ListenAndServe(":"+strconv.Itoa(proxyPort), mux)) }() time.Sleep(2 * time.Second) backendPort, err := findAvailablePort() assert.Nil(t, err) // backend echo server go func() { mux2 := http.NewServeMux() mux2.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { t.Logf("r.URL.Path: %s", r.URL.Path) t.Logf("r.URL.RawPath: %s", r.URL.RawPath) t.Logf("r.Host: %s", r.Host) // Don't upgrade if original host header isn't preserved assert.True(t, strings.HasPrefix(r.Host, "127.0.0.1")) conn, err := defaultUpgrader.Upgrade(w, r, nil) if err != nil { log.Println(err) return } messageType, p, err := conn.ReadMessage() if err != nil { return } if err = conn.WriteMessage(messageType, p); err != nil { return } }) err := http.ListenAndServe(":"+strconv.Itoa(backendPort), mux2) if err != nil { t.Error("ListenAndServe: ", err) return } }() time.Sleep(time.Millisecond * 100) // frontend server, dial now our proxy, which will reverse proxy our // message to the backend websocket server. h := http.Header{} h.Set(SandboxIngress, "test-sandbox-"+strconv.Itoa(backendPort)) conn, _, err := websocket.DefaultDialer.Dial(proxyURL+fmt.Sprintf("/test-sandbox/%v", backendPort)+"/ws", h) if err != nil { t.Fatal(err) } // write a message and send it to the backend server msg := "hello kite" err = conn.WriteMessage(websocket.TextMessage, []byte(msg)) if err != nil { t.Error(err) } messageType, p, err := conn.ReadMessage() if err != nil { t.Error(err) } if messageType != websocket.TextMessage { t.Error("incoming message type is not Text") } if msg != string(p) { t.Errorf("expecting: %s, got: %s", msg, string(p)) } } ================================================ FILE: components/ingress/pkg/renewintent/intent.go ================================================ // Copyright 2026 Alibaba Group Holding Ltd. // // 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. package renewintent import "time" type Intent struct { SandboxID string `json:"sandbox_id"` ObservedAt string `json:"observed_at"` Port int `json:"port,omitempty"` RequestURI string `json:"request_uri,omitempty"` } func NewIntent(sandboxID string, port int, requestURI string) Intent { return Intent{ SandboxID: sandboxID, ObservedAt: time.Now().UTC().Format(time.RFC3339Nano), Port: port, RequestURI: requestURI, } } ================================================ FILE: components/ingress/pkg/renewintent/intent_test.go ================================================ // Copyright 2026 Alibaba Group Holding Ltd. // // 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. package renewintent import ( "encoding/json" "testing" "github.com/stretchr/testify/assert" ) func TestNewIntent(t *testing.T) { intent := NewIntent("sb-123", 8080, "/api/foo") assert.Equal(t, "sb-123", intent.SandboxID) assert.Equal(t, 8080, intent.Port) assert.Equal(t, "/api/foo", intent.RequestURI) assert.NotEmpty(t, intent.ObservedAt) } func TestIntent_JSONRoundTrip(t *testing.T) { intent := NewIntent("my-sandbox", 80, "/") data, err := json.Marshal(intent) assert.NoError(t, err) var decoded Intent err = json.Unmarshal(data, &decoded) assert.NoError(t, err) assert.Equal(t, intent.SandboxID, decoded.SandboxID) assert.Equal(t, intent.Port, decoded.Port) assert.Equal(t, intent.RequestURI, decoded.RequestURI) } func TestIntent_JSONHasRequiredFields(t *testing.T) { intent := NewIntent("id", 0, "") data, err := json.Marshal(intent) assert.NoError(t, err) var m map[string]interface{} err = json.Unmarshal(data, &m) assert.NoError(t, err) for _, key := range []string{"sandbox_id", "observed_at"} { _, ok := m[key] assert.True(t, ok, "missing required JSON field %q", key) } } ================================================ FILE: components/ingress/pkg/renewintent/publisher.go ================================================ // Copyright 2026 Alibaba Group Holding Ltd. // // 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. package renewintent type Publisher interface { PublishIntent(sandboxID string, port int, requestURI string) } ================================================ FILE: components/ingress/pkg/renewintent/redis.go ================================================ // Copyright 2026 Alibaba Group Holding Ltd. // // 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. package renewintent import ( "context" "encoding/json" "errors" "sync" "sync/atomic" "time" "github.com/alibaba/opensandbox/internal/logger" "github.com/redis/go-redis/v9" "k8s.io/apimachinery/pkg/util/wait" ) const ( redisOpTimeout = 5 * time.Second publishWorkers = 4 publishChanCap = 8192 ) type RedisPublisherConfig struct { QueueKey string QueueMaxLen int MinInterval time.Duration Logger logger.Logger } type intentReq struct { sandboxID string port int requestURI string } type RedisPublisher struct { client *redis.Client cfg RedisPublisherConfig lastSent sync.Map ch chan intentReq stopped atomic.Bool } func NewRedisPublisher(ctx context.Context, client *redis.Client, cfg RedisPublisherConfig) *RedisPublisher { p := &RedisPublisher{client: client, cfg: cfg, ch: make(chan intentReq, publishChanCap)} for i := 0; i < publishWorkers; i++ { go func() { for { select { case req := <-p.ch: p.doPublish(req.sandboxID, req.port, req.requestURI) case <-ctx.Done(): return } } }() } go func() { <-ctx.Done() p.stopped.Store(true) }() if cfg.MinInterval > 0 { go wait.UntilWithContext(ctx, p.runCleanupThrottle, cfg.MinInterval*2) } return p } func (p *RedisPublisher) shouldSendIntent(sandboxID string) bool { if p.cfg.MinInterval <= 0 { return true } now := time.Now() if v, ok := p.lastSent.Load(sandboxID); ok { if now.Sub(v.(time.Time)) < p.cfg.MinInterval { return false } } p.lastSent.Store(sandboxID, now) return true } func (p *RedisPublisher) PublishIntent(sandboxID string, port int, requestURI string) { if p.stopped.Load() { return } select { case p.ch <- intentReq{sandboxID: sandboxID, port: port, requestURI: requestURI}: default: } } func (p *RedisPublisher) doPublish(sandboxID string, port int, requestURI string) { if !p.shouldSendIntent(sandboxID) { return } intent := NewIntent(sandboxID, port, requestURI) payload, err := json.Marshal(intent) if err != nil { p.cfg.Logger.With(logger.Field{Key: "sandbox_id", Value: sandboxID}).Errorf("renewintent: marshal intent: %v", err) return } ctx, cancel := context.WithTimeout(context.Background(), redisOpTimeout) defer cancel() pipe := p.client.Pipeline() pipe.LPush(ctx, p.cfg.QueueKey, string(payload)) if p.cfg.QueueMaxLen > 0 { pipe.LTrim(ctx, p.cfg.QueueKey, 0, int64(p.cfg.QueueMaxLen-1)) } _, err = pipe.Exec(ctx) if err != nil { p.cfg.Logger.With( logger.Field{Key: "sandbox_id", Value: sandboxID}, logger.Field{Key: "queue_key", Value: p.cfg.QueueKey}, logger.Field{Key: "error", Value: err}, ).Errorf("renewintent: redis publish failed") return } p.cfg.Logger.With( logger.Field{Key: "sandbox_id", Value: sandboxID}, logger.Field{Key: "queue_key", Value: p.cfg.QueueKey}, ).Debugf("renewintent: published") } func RedisClientFromDSN(dsn string) (*redis.Client, error) { opts, err := redis.ParseURL(dsn) if err != nil { return nil, err } if opts == nil { return nil, errors.New("renewintent: redis DSN produced nil options") } return redis.NewClient(opts), nil } func (p *RedisPublisher) runCleanupThrottle(_ context.Context) { cutoff := time.Now().Add(-p.cfg.MinInterval * 2) p.lastSent.Range(func(key, value any) bool { if value.(time.Time).Before(cutoff) { p.lastSent.Delete(key) } return true }) } ================================================ FILE: components/ingress/pkg/renewintent/redis_bench_test.go ================================================ // Copyright 2026 Alibaba Group Holding Ltd. // // 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. package renewintent import ( "context" "fmt" "testing" "github.com/alibaba/opensandbox/internal/logger" "github.com/alicebob/miniredis/v2" "github.com/redis/go-redis/v9" ) type nopLogger struct{} func (nopLogger) Debugf(string, ...any) {} func (nopLogger) Infof(string, ...any) {} func (nopLogger) Warnf(string, ...any) {} func (nopLogger) Errorf(string, ...any) {} func (n nopLogger) With(...logger.Field) logger.Logger { return n } func (n nopLogger) Named(string) logger.Logger { return n } func (nopLogger) Sync() error { return nil } // Benchmarks use miniredis (in-memory Redis) so timing excludes real network I/O. func BenchmarkRedisPublisher_PublishIntent(b *testing.B) { mr, err := miniredis.Run() if err != nil { b.Fatal(err) } defer mr.Close() client := redis.NewClient(&redis.Options{Addr: mr.Addr()}) defer client.Close() ctx, cancel := context.WithCancel(context.Background()) defer cancel() cfg := RedisPublisherConfig{ QueueKey: "opensandbox:renew:intent", QueueMaxLen: 0, MinInterval: 0, Logger: nopLogger{}, } p := NewRedisPublisher(ctx, client, cfg) sandboxID := "bench-sandbox" port := 8080 requestURI := "/api/health" b.ResetTimer() for i := 0; i < b.N; i++ { p.PublishIntent(sandboxID, port, requestURI) } } func BenchmarkRedisPublisher_PublishIntent_Throttled(b *testing.B) { mr, err := miniredis.Run() if err != nil { b.Fatal(err) } defer mr.Close() client := redis.NewClient(&redis.Options{Addr: mr.Addr()}) defer client.Close() ctx, cancel := context.WithCancel(context.Background()) defer cancel() cfg := RedisPublisherConfig{ QueueKey: "opensandbox:renew:intent", QueueMaxLen: 0, MinInterval: 1 << 30, // large so throttle skips most Logger: nopLogger{}, } p := NewRedisPublisher(ctx, client, cfg) sandboxID := "bench-sandbox" port := 8080 requestURI := "/api/health" b.ResetTimer() for i := 0; i < b.N; i++ { p.PublishIntent(sandboxID, port, requestURI) } } func BenchmarkRedisPublisher_PublishIntent_ManySandboxes(b *testing.B) { mr, err := miniredis.Run() if err != nil { b.Fatal(err) } defer mr.Close() client := redis.NewClient(&redis.Options{Addr: mr.Addr()}) defer client.Close() ctx, cancel := context.WithCancel(context.Background()) defer cancel() cfg := RedisPublisherConfig{ QueueKey: "opensandbox:renew:intent", QueueMaxLen: 0, MinInterval: 0, Logger: nopLogger{}, } p := NewRedisPublisher(ctx, client, cfg) port := 8080 requestURI := "/api/health" b.ResetTimer() for i := 0; i < b.N; i++ { sandboxID := fmt.Sprintf("sandbox-%d", i%1000) p.PublishIntent(sandboxID, port, requestURI) } } ================================================ FILE: components/ingress/pkg/sandbox/agent_sandbox_provider.go ================================================ // Copyright 2026 Alibaba Group Holding Ltd. // // 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. package sandbox import ( "context" "crypto/sha256" "encoding/hex" "errors" "fmt" "regexp" "strings" "time" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/util/validation" "k8s.io/client-go/dynamic" "k8s.io/client-go/dynamic/dynamicinformer" "k8s.io/client-go/rest" "k8s.io/client-go/tools/cache" ) const ( agentSandboxGroup = "agents.x-k8s.io" agentSandboxVersion = "v1alpha1" agentSandboxResource = "sandboxes" agentSandboxConditionReady = "Ready" agentSandboxNamePrefix = "sandbox" ) var ( dns1035InvalidChars = regexp.MustCompile(`[^a-z0-9-]+`) dns1035DuplicateHyphens = regexp.MustCompile(`-+`) ) // AgentSandboxProvider implements Provider for agents.x-k8s.io Sandbox CR. // It uses a dynamic informer to watch resources in the target namespace. type AgentSandboxProvider struct { informerFactory dynamicinformer.DynamicSharedInformerFactory informer cache.SharedIndexInformer namespace string gvr schema.GroupVersionResource } // NewAgentSandboxProvider creates a Provider backed by dynamic informer. func NewAgentSandboxProvider(config *rest.Config, namespace string, resyncPeriod time.Duration) *AgentSandboxProvider { dyn, err := dynamic.NewForConfig(config) if err != nil { panic(fmt.Sprintf("failed to create dynamic client: %v", err)) } return newAgentSandboxProviderWithClient(dyn, namespace, resyncPeriod) } // newAgentSandboxProviderWithClient is a helper for tests to inject fake dynamic client. func newAgentSandboxProviderWithClient(dyn dynamic.Interface, namespace string, resyncPeriod time.Duration) *AgentSandboxProvider { gvr := schema.GroupVersionResource{ Group: agentSandboxGroup, Version: agentSandboxVersion, Resource: agentSandboxResource, } factory := dynamicinformer.NewFilteredDynamicSharedInformerFactory( dyn, resyncPeriod, namespace, nil, // no extra list options ) informer := factory.ForResource(gvr).Informer() return &AgentSandboxProvider{ informerFactory: factory, informer: informer, namespace: namespace, gvr: gvr, } } func agentSandboxResourceName(sandboxId string) string { return toDNS1035Label(sandboxId, agentSandboxNamePrefix) } func toDNS1035Label(value, prefix string) string { normalized := strings.ToLower(strings.TrimSpace(value)) normalized = dns1035InvalidChars.ReplaceAllString(normalized, "-") normalized = dns1035DuplicateHyphens.ReplaceAllString(normalized, "-") normalized = strings.Trim(normalized, "-") hash := sha256.Sum256([]byte(value)) suffix := hex.EncodeToString(hash[:])[:8] if normalized == "" { normalized = prefix + "-" + suffix } else if !startsWithLetter(normalized) { normalized = prefix + "-" + normalized } if len(normalized) > validation.DNS1035LabelMaxLength { maxBase := validation.DNS1035LabelMaxLength - len(suffix) - 1 base := normalized if len(base) > maxBase { base = base[:maxBase] } base = strings.Trim(base, "-") if !startsWithLetter(base) { base = prefix } normalized = base + "-" + suffix } return strings.Trim(normalized, "-") } func startsWithLetter(value string) bool { if value == "" { return false } first := value[0] return first >= 'a' && first <= 'z' } func legacyAgentSandboxName(sandboxId string) string { legacyPrefix := agentSandboxNamePrefix + "-" if strings.HasPrefix(sandboxId, legacyPrefix) { return sandboxId } return legacyPrefix + sandboxId } func resourceNameCandidates(sandboxId string) []string { candidates := []string{} primary := agentSandboxResourceName(sandboxId) candidates = append(candidates, primary) if sandboxId != primary { candidates = append(candidates, sandboxId) } legacy := legacyAgentSandboxName(sandboxId) if legacy != primary && legacy != sandboxId { candidates = append(candidates, legacy) } return candidates } func (a *AgentSandboxProvider) GetEndpoint(sandboxId string) (string, error) { candidates := resourceNameCandidates(sandboxId) var ( obj any exists bool err error ) for _, name := range candidates { key := fmt.Sprintf("%s/%s", a.namespace, name) obj, exists, err = a.informer.GetStore().GetByKey(key) if err != nil { return "", fmt.Errorf("failed to get AgentSandbox %s: %w", key, err) } if exists { break } } if !exists { return "", fmt.Errorf("%w: %s/%s", ErrSandboxNotFound, a.namespace, sandboxId) } u, ok := obj.(*unstructured.Unstructured) if !ok { return "", fmt.Errorf("unexpected object type for sandbox %s: %T", sandboxId, obj) } status, ok := u.Object["status"].(map[string]any) if !ok { return "", fmt.Errorf("%w: sandbox %s missing status", ErrSandboxNotReady, sandboxId) } // Check ready condition first; must be Ready=True to proceed. if ready, reason, message := a.checkSandboxReadyCondition(status); !ready { return "", fmt.Errorf("%w: sandbox %s not ready (%s: %s)", ErrSandboxNotReady, sandboxId, reason, message) } serviceFQDN, _ := status["serviceFQDN"].(string) if serviceFQDN == "" { return "", fmt.Errorf("%w: sandbox %s has no serviceFQDN", ErrSandboxNotReady, sandboxId) } return serviceFQDN, nil } // Start starts the informer factory and waits for cache sync. func (a *AgentSandboxProvider) Start(ctx context.Context) error { a.informerFactory.Start(ctx.Done()) if !cache.WaitForCacheSync(ctx.Done(), a.informer.HasSynced) { return errors.New("failed to sync AgentSandbox informer cache") } return nil } // checkSandboxReadyCondition inspects status.conditions for Ready=True. // Returns (isReady, reason, message). // // https://github.com/kubernetes-sigs/agent-sandbox/blob/main/controllers/sandbox_controller.go#L195 func (a *AgentSandboxProvider) checkSandboxReadyCondition(status map[string]any) (bool, string, string) { conds, ok := status["conditions"].([]any) if !ok { return false, "NoConditions", "no sandbox conditions reported" } for _, c := range conds { m, ok := c.(map[string]any) if !ok { continue } if t, _ := m["type"].(string); t != agentSandboxConditionReady { continue } if s, _ := m["status"].(string); s == string(metav1.ConditionTrue) { return true, agentSandboxConditionReady, "" } reason, _ := m["reason"].(string) message, _ := m["message"].(string) if reason == "" { reason = "DependenciesNotReady" } if message == "" { message = "Ready condition is not True" } return false, reason, message } return false, "ReadyConditionMissing", "ready condition missing" } var _ Provider = (*AgentSandboxProvider)(nil) ================================================ FILE: components/ingress/pkg/sandbox/agent_sandbox_provider_test.go ================================================ // Copyright 2026 Alibaba Group Holding Ltd. // // 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. package sandbox import ( "context" "errors" "strings" "testing" "time" "github.com/stretchr/testify/assert" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" dynamicfake "k8s.io/client-go/dynamic/fake" ) // buildUnstructuredSandbox creates a minimal unstructured Sandbox object. func buildUnstructuredSandbox(name, namespace string) *unstructured.Unstructured { return &unstructured.Unstructured{ Object: map[string]any{ "apiVersion": agentSandboxGroup + "/" + agentSandboxVersion, "kind": "Sandbox", "metadata": map[string]any{ "name": name, "namespace": namespace, }, "spec": map[string]any{ "podTemplate": map[string]any{ "spec": map[string]any{ "containers": []any{}, }, "metadata": map[string]any{}, }, }, }, } } func TestAgentSandboxProvider_Start_Success(t *testing.T) { namespace := "test-ns" obj := buildUnstructuredSandbox("demo", namespace) scheme := runtime.NewScheme() gvr := schema.GroupVersionResource{ Group: agentSandboxGroup, Version: agentSandboxVersion, Resource: agentSandboxResource, } fakeDyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds( scheme, map[schema.GroupVersionResource]string{ gvr: "SandboxList", }, obj, ) provider := newAgentSandboxProviderWithClient(fakeDyn, namespace, 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() err := provider.Start(ctx) assert.NoError(t, err, "Start should succeed with fake dynamic informer") // Manually seed store (fake dynamic client doesn't backfill informer cache automatically) err = provider.informer.GetStore().Add(obj) assert.NoError(t, err) key := obj.GetNamespace() + "/" + obj.GetName() _, exists, _ := provider.informer.GetStore().GetByKey(key) assert.True(t, exists, "informer cache should accept added object after start") } func TestAgentSandboxProvider_Start_ContextCancelled(t *testing.T) { namespace := "test-ns" scheme := runtime.NewScheme() gvr := schema.GroupVersionResource{ Group: agentSandboxGroup, Version: agentSandboxVersion, Resource: agentSandboxResource, } fakeDyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds( scheme, map[schema.GroupVersionResource]string{ gvr: "SandboxList", }, ) provider := newAgentSandboxProviderWithClient(fakeDyn, namespace, 30*time.Second) ctx, cancel := context.WithCancel(context.Background()) cancel() // cancel before start err := provider.Start(ctx) assert.Error(t, err, "Start should fail when context already cancelled") } func TestAgentSandboxProvider_GetEndpoint_ServiceFQDN(t *testing.T) { namespace := "test-ns" obj := buildUnstructuredSandbox("demo", namespace) obj.Object["status"] = map[string]any{ "serviceFQDN": "sandbox.demo.svc.cluster.local", "conditions": []any{ map[string]any{ "type": "Ready", "status": "True", }, }, } scheme := runtime.NewScheme() gvr := schema.GroupVersionResource{ Group: agentSandboxGroup, Version: agentSandboxVersion, Resource: agentSandboxResource, } fakeDyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds( scheme, map[schema.GroupVersionResource]string{ gvr: "SandboxList", }, ) provider := newAgentSandboxProviderWithClient(fakeDyn, namespace, 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() err := provider.Start(ctx) assert.NoError(t, err) // Seed store err = provider.informer.GetStore().Add(obj) assert.NoError(t, err) endpoint, err := provider.GetEndpoint("demo") assert.NoError(t, err) assert.Equal(t, "sandbox.demo.svc.cluster.local", endpoint) } func TestAgentSandboxProvider_GetEndpoint_NotFound(t *testing.T) { namespace := "test-ns" scheme := runtime.NewScheme() gvr := schema.GroupVersionResource{ Group: agentSandboxGroup, Version: agentSandboxVersion, Resource: agentSandboxResource, } fakeDyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds( scheme, map[schema.GroupVersionResource]string{ gvr: "SandboxList", }, ) provider := newAgentSandboxProviderWithClient(fakeDyn, namespace, 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() err := provider.Start(ctx) assert.NoError(t, err) _, err = provider.GetEndpoint("missing") assert.Error(t, err) assert.True(t, errors.Is(err, ErrSandboxNotFound)) } func TestAgentSandboxProvider_GetEndpoint_NoServiceFQDN(t *testing.T) { namespace := "test-ns" obj := buildUnstructuredSandbox("demo", namespace) obj.Object["status"] = map[string]any{} scheme := runtime.NewScheme() gvr := schema.GroupVersionResource{ Group: agentSandboxGroup, Version: agentSandboxVersion, Resource: agentSandboxResource, } fakeDyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds( scheme, map[schema.GroupVersionResource]string{ gvr: "SandboxList", }, ) provider := newAgentSandboxProviderWithClient(fakeDyn, namespace, 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() err := provider.Start(ctx) assert.NoError(t, err) // Seed store err = provider.informer.GetStore().Add(obj) assert.NoError(t, err) _, err = provider.GetEndpoint("demo") assert.Error(t, err) assert.True(t, errors.Is(err, ErrSandboxNotReady)) } func TestAgentSandboxProvider_GetEndpoint_NotReadyCondition(t *testing.T) { namespace := "test-ns" obj := buildUnstructuredSandbox("demo", namespace) obj.Object["status"] = map[string]any{ "serviceFQDN": "sandbox.demo.svc.cluster.local", "conditions": []any{ map[string]any{ "type": "Ready", "status": "False", "reason": "DependenciesNotReady", "message": "Pod not ready", }, }, } scheme := runtime.NewScheme() gvr := schema.GroupVersionResource{ Group: agentSandboxGroup, Version: agentSandboxVersion, Resource: agentSandboxResource, } fakeDyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds( scheme, map[schema.GroupVersionResource]string{ gvr: "SandboxList", }, ) provider := newAgentSandboxProviderWithClient(fakeDyn, namespace, 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() err := provider.Start(ctx) assert.NoError(t, err) // Seed store err = provider.informer.GetStore().Add(obj) assert.NoError(t, err) _, err = provider.GetEndpoint("demo") assert.Error(t, err) assert.True(t, errors.Is(err, ErrSandboxNotReady)) } func TestToDNS1035Label_HashOnSymbolOnlyIDs(t *testing.T) { name1 := toDNS1035Label("!!!", agentSandboxNamePrefix) name2 := toDNS1035Label("???", agentSandboxNamePrefix) assert.NotEqual(t, name1, name2) assert.Regexp(t, `^sandbox-[0-9a-f]{8}$`, name1) assert.Regexp(t, `^sandbox-[0-9a-f]{8}$`, name2) } func TestToDNS1035Label_PrefixesDigitStart(t *testing.T) { name := toDNS1035Label("1234", agentSandboxNamePrefix) assert.Equal(t, "sandbox-1234", name) } func TestToDNS1035Label_TruncatesWithHashSuffix(t *testing.T) { input := "A" + strings.Repeat("b", 100) name := toDNS1035Label(input, agentSandboxNamePrefix) assert.LessOrEqual(t, len(name), 63) assert.Regexp(t, `^[a-z][a-z0-9-]*$`, name) assert.Regexp(t, `[0-9a-f]{8}$`, name) } ================================================ FILE: components/ingress/pkg/sandbox/batchsandbox_provider.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package sandbox import ( "context" "errors" "fmt" "time" kerrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/client-go/rest" "k8s.io/client-go/tools/cache" clientset "github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/client/clientset/versioned" informers "github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/client/informers/externalversions" listers "github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/client/listers/sandbox/v1alpha1" "github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/utils" ) // BatchSandboxProvider implements Provider interface for BatchSandbox resources type BatchSandboxProvider struct { informerFactory informers.SharedInformerFactory lister listers.BatchSandboxLister informerSynced cache.InformerSynced namespace string } // NewBatchSandboxProvider creates a new BatchSandboxProvider func NewBatchSandboxProvider( config *rest.Config, namespace string, resyncPeriod time.Duration, ) *BatchSandboxProvider { clientset, err := clientset.NewForConfig(config) if err != nil { panic(fmt.Sprintf("failed to create sandbox clientset: %v", err)) } informerFactory := informers.NewSharedInformerFactoryWithOptions( clientset, resyncPeriod, informers.WithNamespace(namespace), ) batchSandboxInformer := informerFactory.Sandbox().V1alpha1().BatchSandboxes() return &BatchSandboxProvider{ informerFactory: informerFactory, lister: batchSandboxInformer.Lister(), informerSynced: batchSandboxInformer.Informer().HasSynced, namespace: namespace, } } // Start starts the informer factory and waits for cache sync func (p *BatchSandboxProvider) Start(ctx context.Context) error { p.informerFactory.Start(ctx.Done()) // Wait for cache sync if !cache.WaitForCacheSync(ctx.Done(), p.informerSynced) { return errors.New("failed to sync BatchSandbox informer cache") } return nil } // GetEndpoint retrieves the endpoint IP for a BatchSandbox func (p *BatchSandboxProvider) GetEndpoint(sandboxId string) (string, error) { // Get BatchSandbox from cache using lister with provider's namespace batchSandbox, err := p.lister.BatchSandboxes(p.namespace).Get(sandboxId) if err != nil { if kerrors.IsNotFound(err) { return "", fmt.Errorf("%w: %s/%s", ErrSandboxNotFound, p.namespace, sandboxId) } return "", fmt.Errorf("failed to get BatchSandbox %s/%s: %w", p.namespace, sandboxId, err) } // Check if BatchSandbox is ready if batchSandbox.Status.Ready < 1 { return "", fmt.Errorf("%w: %s/%s (ready: %d/%d)", ErrSandboxNotReady, p.namespace, sandboxId, batchSandbox.Status.Ready, batchSandbox.Status.Replicas) } // Get endpoints from BatchSandbox using kubernetes utils endpoints, err := utils.GetEndpoints(batchSandbox) if err != nil { return "", fmt.Errorf("%w: %s/%s: %w", ErrSandboxNotReady, p.namespace, sandboxId, err) } // Return the first available endpoint return endpoints[0], nil } var _ Provider = (*BatchSandboxProvider)(nil) ================================================ FILE: components/ingress/pkg/sandbox/batchsandbox_provider_test.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package sandbox import ( "context" "errors" "testing" "time" "github.com/stretchr/testify/assert" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" sandboxv1alpha1 "github.com/alibaba/OpenSandbox/sandbox-k8s/apis/sandbox/v1alpha1" fakeclientset "github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/client/clientset/versioned/fake" informers "github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/client/informers/externalversions" "github.com/alibaba/OpenSandbox/sandbox-k8s/pkg/utils" ) // Note: Integration tests with real informers are in e2e tests // Unit tests here focus on provider behavior // TestBatchSandboxProvider_WithFakeInformer tests the provider using fake clientset and informer func TestBatchSandboxProvider_WithFakeInformer(t *testing.T) { namespace := "test-namespace" // Create a ready BatchSandbox with valid endpoints readyBatchSandbox := &sandboxv1alpha1.BatchSandbox{ ObjectMeta: metav1.ObjectMeta{ Name: "ready-sandbox", Namespace: namespace, Annotations: map[string]string{ utils.AnnotationEndpoints: `["10.0.0.1", "10.0.0.2"]`, }, }, Spec: sandboxv1alpha1.BatchSandboxSpec{ Replicas: ptr(int32(2)), }, Status: sandboxv1alpha1.BatchSandboxStatus{ Replicas: 2, Ready: 2, }, } // Create a not ready BatchSandbox notReadyBatchSandbox := &sandboxv1alpha1.BatchSandbox{ ObjectMeta: metav1.ObjectMeta{ Name: "not-ready-sandbox", Namespace: namespace, }, Spec: sandboxv1alpha1.BatchSandboxSpec{ Replicas: ptr(int32(1)), }, Status: sandboxv1alpha1.BatchSandboxStatus{ Replicas: 1, Ready: 0, }, } // Create fake clientset with test objects fakeClient := fakeclientset.NewSimpleClientset(readyBatchSandbox, notReadyBatchSandbox) // Create informer factory informerFactory := informers.NewSharedInformerFactoryWithOptions( fakeClient, time.Second*30, informers.WithNamespace(namespace), ) batchSandboxInformer := informerFactory.Sandbox().V1alpha1().BatchSandboxes() // Create provider provider := &BatchSandboxProvider{ informerFactory: informerFactory, lister: batchSandboxInformer.Lister(), informerSynced: batchSandboxInformer.Informer().HasSynced, namespace: namespace, } // Start informer and wait for cache sync ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() err := provider.Start(ctx) assert.NoError(t, err, "Provider should start successfully") // Manually add objects to informer cache (fake clientset doesn't auto-populate informer) err = batchSandboxInformer.Informer().GetStore().Add(readyBatchSandbox) assert.NoError(t, err) err = batchSandboxInformer.Informer().GetStore().Add(notReadyBatchSandbox) assert.NoError(t, err) // Test 1: Get endpoint from ready sandbox t.Run("GetEndpoint from ready sandbox", func(t *testing.T) { endpoint, err := provider.GetEndpoint("ready-sandbox") assert.NoError(t, err) assert.Equal(t, "10.0.0.1", endpoint, "Should return first endpoint IP") }) // Test 2: Get endpoint from not ready sandbox t.Run("GetEndpoint from not ready sandbox", func(t *testing.T) { _, err := provider.GetEndpoint("not-ready-sandbox") assert.Error(t, err) assert.True(t, errors.Is(err, ErrSandboxNotReady), "Should return ErrSandboxNotReady") assert.Contains(t, err.Error(), "not ready") }) // Test 3: Get endpoint from non-existent sandbox t.Run("GetEndpoint from non-existent sandbox", func(t *testing.T) { _, err := provider.GetEndpoint("non-existent") assert.Error(t, err) assert.True(t, errors.Is(err, ErrSandboxNotFound), "Should return ErrSandboxNotFound") assert.Contains(t, err.Error(), "not found") }) } // TestBatchSandboxProvider_MissingAnnotation tests sandbox without endpoints annotation func TestBatchSandboxProvider_MissingAnnotation(t *testing.T) { namespace := "test-namespace" // Create BatchSandbox without endpoints annotation batchSandbox := &sandboxv1alpha1.BatchSandbox{ ObjectMeta: metav1.ObjectMeta{ Name: "no-annotation-sandbox", Namespace: namespace, }, Spec: sandboxv1alpha1.BatchSandboxSpec{ Replicas: ptr(int32(1)), }, Status: sandboxv1alpha1.BatchSandboxStatus{ Replicas: 1, Ready: 1, }, } fakeClient := fakeclientset.NewSimpleClientset(batchSandbox) informerFactory := informers.NewSharedInformerFactoryWithOptions( fakeClient, time.Second*30, informers.WithNamespace(namespace), ) batchSandboxInformer := informerFactory.Sandbox().V1alpha1().BatchSandboxes() provider := &BatchSandboxProvider{ informerFactory: informerFactory, lister: batchSandboxInformer.Lister(), informerSynced: batchSandboxInformer.Informer().HasSynced, namespace: namespace, } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() err := provider.Start(ctx) assert.NoError(t, err) // Manually add object to informer cache err = batchSandboxInformer.Informer().GetStore().Add(batchSandbox) assert.NoError(t, err) _, err = provider.GetEndpoint("no-annotation-sandbox") assert.Error(t, err) assert.True(t, errors.Is(err, ErrSandboxNotReady), "Should return ErrSandboxNotReady") assert.Contains(t, err.Error(), "has no annotations") } // TestBatchSandboxProvider_InvalidAnnotation tests sandbox with invalid annotation format func TestBatchSandboxProvider_InvalidAnnotation(t *testing.T) { namespace := "test-namespace" batchSandbox := &sandboxv1alpha1.BatchSandbox{ ObjectMeta: metav1.ObjectMeta{ Name: "invalid-annotation-sandbox", Namespace: namespace, Annotations: map[string]string{ utils.AnnotationEndpoints: `invalid-json`, }, }, Spec: sandboxv1alpha1.BatchSandboxSpec{ Replicas: ptr(int32(1)), }, Status: sandboxv1alpha1.BatchSandboxStatus{ Replicas: 1, Ready: 1, }, } fakeClient := fakeclientset.NewSimpleClientset(batchSandbox) informerFactory := informers.NewSharedInformerFactoryWithOptions( fakeClient, time.Second*30, informers.WithNamespace(namespace), ) batchSandboxInformer := informerFactory.Sandbox().V1alpha1().BatchSandboxes() provider := &BatchSandboxProvider{ informerFactory: informerFactory, lister: batchSandboxInformer.Lister(), informerSynced: batchSandboxInformer.Informer().HasSynced, namespace: namespace, } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() err := provider.Start(ctx) assert.NoError(t, err) // Manually add object to informer cache err = batchSandboxInformer.Informer().GetStore().Add(batchSandbox) assert.NoError(t, err) _, err = provider.GetEndpoint("invalid-annotation-sandbox") assert.Error(t, err) assert.True(t, errors.Is(err, ErrSandboxNotReady), "Should return ErrSandboxNotReady") assert.Contains(t, err.Error(), "failed to parse") } // TestBatchSandboxProvider_DynamicUpdate tests adding object after informer starts func TestBatchSandboxProvider_DynamicUpdate(t *testing.T) { namespace := "test-namespace" fakeClient := fakeclientset.NewSimpleClientset() informerFactory := informers.NewSharedInformerFactoryWithOptions( fakeClient, time.Second*30, informers.WithNamespace(namespace), ) batchSandboxInformer := informerFactory.Sandbox().V1alpha1().BatchSandboxes() provider := &BatchSandboxProvider{ informerFactory: informerFactory, lister: batchSandboxInformer.Lister(), informerSynced: batchSandboxInformer.Informer().HasSynced, namespace: namespace, } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() err := provider.Start(ctx) assert.NoError(t, err) // Initially no sandbox exists _, err = provider.GetEndpoint("dynamic-sandbox") assert.Error(t, err) assert.True(t, errors.Is(err, ErrSandboxNotFound), "Should return ErrSandboxNotFound") assert.Contains(t, err.Error(), "not found") // Add a new BatchSandbox newBatchSandbox := &sandboxv1alpha1.BatchSandbox{ ObjectMeta: metav1.ObjectMeta{ Name: "dynamic-sandbox", Namespace: namespace, Annotations: map[string]string{ utils.AnnotationEndpoints: `["10.0.0.100"]`, }, }, Spec: sandboxv1alpha1.BatchSandboxSpec{ Replicas: ptr(int32(1)), }, Status: sandboxv1alpha1.BatchSandboxStatus{ Replicas: 1, Ready: 1, }, } _, err = fakeClient.SandboxV1alpha1().BatchSandboxes(namespace).Create( context.Background(), newBatchSandbox, metav1.CreateOptions{}) assert.NoError(t, err) // Wait for informer to pick up the change assert.Eventually(t, func() bool { endpoint, err := provider.GetEndpoint("dynamic-sandbox") return err == nil && endpoint == "10.0.0.100" }, 3*time.Second, 100*time.Millisecond, "Informer should eventually sync the new object") } // TestBatchSandboxProvider_StartCacheSyncFailure tests cache sync timeout func TestBatchSandboxProvider_StartCacheSyncFailure(t *testing.T) { namespace := "test-namespace" fakeClient := fakeclientset.NewSimpleClientset() informerFactory := informers.NewSharedInformerFactoryWithOptions( fakeClient, time.Second*30, informers.WithNamespace(namespace), ) batchSandboxInformer := informerFactory.Sandbox().V1alpha1().BatchSandboxes() provider := &BatchSandboxProvider{ informerFactory: informerFactory, lister: batchSandboxInformer.Lister(), informerSynced: batchSandboxInformer.Informer().HasSynced, namespace: namespace, } // Create a context that expires immediately ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond) defer cancel() // Wait for context to expire time.Sleep(10 * time.Millisecond) err := provider.Start(ctx) assert.Error(t, err, "Should fail when cache sync times out") assert.Contains(t, err.Error(), "failed to sync") } // TestBatchSandboxProvider_GetEndpointNonNotFoundError tests non-IsNotFound K8s errors func TestBatchSandboxProvider_GetEndpointNonNotFoundError(t *testing.T) { namespace := "test-namespace" // Create a sandbox with Ready status but missing endpoint annotation batchSandbox := &sandboxv1alpha1.BatchSandbox{ ObjectMeta: metav1.ObjectMeta{ Name: "missing-endpoint-sandbox", Namespace: namespace, Annotations: map[string]string{ utils.AnnotationEndpoints: `["10.0.0.1"]`, }, }, Spec: sandboxv1alpha1.BatchSandboxSpec{ Replicas: ptr(int32(1)), }, Status: sandboxv1alpha1.BatchSandboxStatus{ Replicas: 1, Ready: 1, }, } fakeClient := fakeclientset.NewSimpleClientset(batchSandbox) informerFactory := informers.NewSharedInformerFactoryWithOptions( fakeClient, time.Second*30, informers.WithNamespace(namespace), ) batchSandboxInformer := informerFactory.Sandbox().V1alpha1().BatchSandboxes() provider := &BatchSandboxProvider{ informerFactory: informerFactory, lister: batchSandboxInformer.Lister(), informerSynced: batchSandboxInformer.Informer().HasSynced, namespace: namespace, } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() err := provider.Start(ctx) assert.NoError(t, err) // Manually add object to informer cache err = batchSandboxInformer.Informer().GetStore().Add(batchSandbox) assert.NoError(t, err) // Should successfully get endpoint endpoint, err := provider.GetEndpoint("missing-endpoint-sandbox") assert.NoError(t, err) assert.Equal(t, "10.0.0.1", endpoint) } // ptr is a helper function to create int32 pointer func ptr(i int32) *int32 { return &i } ================================================ FILE: components/ingress/pkg/sandbox/errors_test.go ================================================ // Copyright 2026 Alibaba Group Holding Ltd. // // 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. package sandbox import ( "errors" "fmt" "testing" ) // Ensure wrapping ErrSandboxNotReady keeps errors.Is behavior. func TestErrSandboxNotReadyWrapping(t *testing.T) { wrapped := fmt.Errorf("%w: custom detail", ErrSandboxNotReady) if !errors.Is(wrapped, ErrSandboxNotReady) { t.Fatalf("expected errors.Is to match ErrSandboxNotReady, got false; err=%v", wrapped) } } ================================================ FILE: components/ingress/pkg/sandbox/factory.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package sandbox import ( "fmt" "time" "k8s.io/client-go/rest" ) // DefaultProviderFactory is the default implementation of ProviderFactory type DefaultProviderFactory struct { config *rest.Config namespace string resyncPeriod time.Duration } // NewProviderFactory creates a new DefaultProviderFactory func NewProviderFactory(config *rest.Config, namespace string, resyncPeriod time.Duration) *DefaultProviderFactory { return &DefaultProviderFactory{ config: config, namespace: namespace, resyncPeriod: resyncPeriod, } } // CreateProvider creates a Provider instance based on the provider type func (f *DefaultProviderFactory) CreateProvider(providerType ProviderType) (Provider, error) { switch providerType { case ProviderTypeBatchSandbox: return NewBatchSandboxProvider(f.config, f.namespace, f.resyncPeriod), nil case ProviderTypeAgentSandbox: return NewAgentSandboxProvider(f.config, f.namespace, f.resyncPeriod), nil default: return nil, fmt.Errorf("unsupported provider type: %s", providerType) } } ================================================ FILE: components/ingress/pkg/sandbox/provider.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package sandbox import ( "context" "errors" ) type ProviderType string const ( ProviderTypeBatchSandbox ProviderType = "batchsandbox" ProviderTypeAgentSandbox ProviderType = "agent-sandbox" ) func (tpy ProviderType) String() string { return string(tpy) } // Standard errors for Provider operations var ( // ErrSandboxNotFound indicates the sandbox resource does not exist ErrSandboxNotFound = errors.New("sandbox not found") // ErrSandboxNotReady indicates the sandbox exists but is not ready // This includes: not enough ready replicas, missing endpoints, invalid configuration ErrSandboxNotReady = errors.New("sandbox not ready") ) // Provider defines the interface for sandbox resource providers // Implementations include BatchSandboxProvider, AgentSandboxProvider, etc. type Provider interface { // GetEndpoint retrieves the IP address for a sandbox by its id/name // The namespace is determined by the provider's configuration // Returns the first available IP from the endpoints annotation // Returns error if sandbox not found or no endpoints available // Note: This is a local cache query, no network I/O involved GetEndpoint(sandboxId string) (string, error) // Start initializes and starts the provider's informer cache // Waits for cache sync before returning // Must be called before using GetEndpoint Start(ctx context.Context) error } // ProviderFactory creates a Provider instance based on the provider type type ProviderFactory interface { CreateProvider(providerType ProviderType) (Provider, error) } ================================================ FILE: components/internal/go.mod ================================================ module github.com/alibaba/opensandbox/internal go 1.24.0 require go.uber.org/zap v1.27.0 require go.uber.org/multierr v1.10.0 // indirect ================================================ FILE: components/internal/go.sum ================================================ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: components/internal/logger/logger.go ================================================ // Copyright 2026 Alibaba Group Holding Ltd. // // 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. package logger // Field is a structured logging key/value pair. type Field struct { Key string Value any } // Logger defines the minimal logging surface shared by components. // - Formatted levels: Debugf/Infof/Warnf/Errorf // - With: attach structured fields to derived logger // - Named: derive a sub-logger with name // - Sync: flush buffers (no-op for implementations that don't buffer) type Logger interface { Debugf(template string, args ...any) Infof(template string, args ...any) Warnf(template string, args ...any) Errorf(template string, args ...any) With(fields ...Field) Logger Named(name string) Logger Sync() error } ================================================ FILE: components/internal/logger/zap.go ================================================ // Copyright 2026 Alibaba Group Holding Ltd. // // 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. package logger import ( "os" "strings" "go.uber.org/zap" "go.uber.org/zap/zapcore" ) const envLogOutput = "OPENSANDBOX_LOG_OUTPUT" // Config is the minimal configuration to align execd/ingress defaults. // - JSON encoding, ISO8601 time // - Caller/stacktrace disabled // - Stdout as default output // - Level defaults to info type Config struct { Level string // debug|info|warn|error|fatal (default: info) OutputPaths []string // default: stdout ErrorOutputPaths []string // default: OutputPaths } // New creates a zap-backed Logger with the provided config. func New(cfg Config) (Logger, error) { cfg = applyEnvOutputs(cfg) zapCfg := zap.NewProductionConfig() zapCfg.Level = zap.NewAtomicLevelAt(parseLevel(cfg.Level)) zapCfg.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder zapCfg.EncoderConfig.CallerKey = "" zapCfg.DisableCaller = true zapCfg.DisableStacktrace = true zapCfg.EncoderConfig.StacktraceKey = "" zapCfg.OutputPaths = cfg.OutputPaths zapCfg.ErrorOutputPaths = cfg.ErrorOutputPaths base, err := zapCfg.Build() if err != nil { return nil, err } return &zapLogger{base: base, sugar: base.Sugar()}, nil } // MustNew is a convenience helper that panics on error. func MustNew(cfg Config) Logger { l, err := New(cfg) if err != nil { panic(err) } return l } // AsZapSugared returns the underlying zap SugaredLogger when available. func AsZapSugared(l Logger) (*zap.SugaredLogger, bool) { zl, ok := l.(*zapLogger) if !ok { return nil, false } return zl.sugar, true } type zapLogger struct { base *zap.Logger sugar *zap.SugaredLogger } func (l *zapLogger) Debugf(template string, args ...any) { l.sugar.Debugf(template, args...) } func (l *zapLogger) Infof(template string, args ...any) { l.sugar.Infof(template, args...) } func (l *zapLogger) Warnf(template string, args ...any) { l.sugar.Warnf(template, args...) } func (l *zapLogger) Errorf(template string, args ...any) { l.sugar.Errorf(template, args...) } func (l *zapLogger) With(fields ...Field) Logger { if len(fields) == 0 { return l } zfs := make([]zap.Field, 0, len(fields)) for _, f := range fields { zfs = append(zfs, zap.Any(f.Key, f.Value)) } nb := l.base.With(zfs...) return &zapLogger{base: nb, sugar: nb.Sugar()} } func (l *zapLogger) Named(name string) Logger { nb := l.base.Named(name) return &zapLogger{base: nb, sugar: nb.Sugar()} } func (l *zapLogger) Sync() error { return l.base.Sync() } func parseLevel(level string) zapcore.Level { switch strings.ToLower(level) { case "debug": return zapcore.DebugLevel case "warn", "warning": return zapcore.WarnLevel case "error": return zapcore.ErrorLevel case "fatal": return zapcore.FatalLevel default: return zapcore.InfoLevel } } func applyEnvOutputs(cfg Config) Config { envVal := strings.TrimSpace(os.Getenv(envLogOutput)) if len(cfg.OutputPaths) == 0 { if envVal != "" { cfg.OutputPaths = splitAndTrim(envVal) } else { cfg.OutputPaths = []string{"stdout"} } } if len(cfg.ErrorOutputPaths) == 0 { // Default error output matches output paths. cfg.ErrorOutputPaths = cfg.OutputPaths } return cfg } func splitAndTrim(s string) []string { parts := strings.Split(s, ",") out := make([]string, 0, len(parts)) for _, p := range parts { if v := strings.TrimSpace(p); v != "" { out = append(out, v) } } return out } ================================================ FILE: components/internal/version/version.go ================================================ // Copyright 2026 Alibaba Group Holding Ltd. // // 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. package version import ( "fmt" "runtime" ) // Package values are typically overridden at build time via -ldflags. var ( // Version is the component version. Version = "dirty" // BuildTime is when the binary was built. BuildTime = "assigned-at-build-time" // GitCommit is the commit id used to build the binary. GitCommit = "assigned-at-build-time" ) // EchoVersion prints build info for the given component name (e.g. "OpenSandbox Ingress", "OpenSandbox Execd"). // All components can use this by passing their display name. func EchoVersion(componentName string) { fmt.Println("=====================================================") fmt.Printf(" %s\n", componentName) fmt.Println("-----------------------------------------------------") fmt.Printf(" Version : %s\n", Version) fmt.Printf(" Git Commit : %s\n", GitCommit) fmt.Printf(" Build Time : %s\n", BuildTime) fmt.Printf(" Go Version : %s\n", runtime.Version()) fmt.Printf(" Platform : %s/%s\n", runtime.GOOS, runtime.GOARCH) fmt.Println("=====================================================") } ================================================ FILE: docs/.nvmrc ================================================ 22 ================================================ FILE: docs/.vitepress/config.mts ================================================ import { defineConfig } from "vitepress"; import { loadManifest } from "./scripts/docs-manifest.mjs"; const manifest = loadManifest(); const docsBase = process.env.DOCS_BASE || "/"; export default defineConfig({ title: "OpenSandbox", description: "OpenSandbox documentation site for users and developers", head: [["link", { rel: "icon", type: "image/svg+xml", href: "/favicon.svg" }]], cleanUrls: true, lastUpdated: true, base: docsBase, ignoreDeadLinks: [/^https?:\/\/localhost/, /\/README$/, /\/index$/, "./contributing"], srcExclude: ["node_modules/**", "README_zh.md", "RELEASE_NOTE_TEMPLATE.md"], rewrites: manifest.rewrites, themeConfig: { logo: "/assets/logo.svg", search: { provider: "local", }, socialLinks: [{ icon: "github", link: "https://github.com/alibaba/OpenSandbox" }], nav: manifest.nav.en, sidebar: { ...manifest.sidebar.en, ...manifest.sidebar.zh, }, outline: { level: [2, 3], }, }, locales: { root: { label: "English", lang: "en-US", themeConfig: { nav: manifest.nav.en, }, }, zh: { label: "简体中文", lang: "zh-CN", themeConfig: { nav: manifest.nav.zh, }, }, }, }); ================================================ FILE: docs/.vitepress/scripts/docs-manifest.mjs ================================================ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const repoRoot = path.resolve(__dirname, "../../../"); const docsRoot = path.join(repoRoot, "docs"); const generatedRoot = path.join(docsRoot, "generated"); const manifestPath = path.join(docsRoot, ".vitepress", "generated", "manifest.json"); const blobBaseUrl = "https://github.com/alibaba/OpenSandbox/blob/main"; const treeBaseUrl = "https://github.com/alibaba/OpenSandbox/tree/main"; const rawBaseUrl = "https://raw.githubusercontent.com/alibaba/OpenSandbox/main"; const ignoredDirNames = new Set([ ".git", ".github", "node_modules", ".vitepress", ".pytest_cache", "generated", ".venv", "venv", "__pycache__", "dist", "build", "target", "bin", ]); const zhReadmePattern = /^README(?:[-_](?:zh|zh-cn|zh_cn))?\.md$/i; const standardReadmePattern = /^README\.md$/i; const sectionDefinitions = [ { id: "modules", scanRoots: ["server", "components", "sandboxes", "kubernetes", "specs", "sdks"], includeDevelopment: true, }, { id: "examples", scanRoots: ["examples"], includeDevelopment: false, }, { id: "community", scanRoots: ["oseps"], includeDevelopment: false, }, ]; const manualEntries = [ { key: "guide-home", sectionId: "overview", slug: "overview/home", enPath: "README.md", zhPath: "docs/README_zh.md", titleEn: "OpenSandbox", titleZh: "OpenSandbox", }, { key: "guide-architecture", sectionId: "overview", slug: "overview/architecture", enPath: "docs/architecture.md", zhPath: null, titleEn: "Architecture", titleZh: "架构设计", }, { key: "guide-network", sectionId: "modules", slug: "design/single-host-network", enPath: "docs/single_host_network.md", zhPath: null, titleEn: "Single Host Network", titleZh: "单机场景网络设计", }, { key: "community-contributing", sectionId: "community", slug: "community/contributing", enPath: "CONTRIBUTING.md", zhPath: null, titleEn: "Contributing", titleZh: "参与贡献", }, { key: "community-code-of-conduct", sectionId: "community", slug: "community/code-of-conduct", enPath: "CODE_OF_CONDUCT.md", zhPath: null, titleEn: "Code of Conduct", titleZh: "行为准则", }, ]; const moduleGroupLabels = { en: { sdks: "SDKs", specs: "Specs & API", server: "Server", components: "Components", sandboxes: "Sandboxes", kubernetes: "Kubernetes", design: "Design", }, zh: { sdks: "SDKs", specs: "Specs & API", server: "Server", components: "Components", sandboxes: "Sandboxes", kubernetes: "Kubernetes", design: "设计", }, }; const communityGroupLabels = { en: { community: "Community", oseps: "OSEPs", }, zh: { community: "社区", oseps: "OSEPs", }, }; const shortTitleByPath = { "sdks/code-interpreter/javascript/README.md": "Code Interpreter JS SDK", "sdks/code-interpreter/kotlin/README.md": "Code Interpreter Kotlin SDK", "sdks/code-interpreter/python/README.md": "Code Interpreter Python SDK", "sdks/code-interpreter/csharp/README.md": "Code Interpreter C# SDK", "sdks/sandbox/javascript/README.md": "Sandbox JS SDK", "sdks/sandbox/kotlin/README.md": "Sandbox Kotlin SDK", "sdks/sandbox/python/README.md": "Sandbox Python SDK", "sdks/sandbox/csharp/README.md": "Sandbox C# SDK", "sdks/mcp/sandbox/python/README.md": "MCP Sandbox Python SDK", "cli/README.md": "CLI (Python)", "sdks/sandbox/kotlin/sandbox-api/build/generated/api/execd/README.md": "Sandbox Execd API (Kotlin)", "sdks/sandbox/kotlin/sandbox-api/build/generated/api/lifecycle/README.md": "Sandbox Lifecycle API (Kotlin)", "examples/agent-sandbox/README.md": "Agent Sandbox", "examples/aio-sandbox/README.md": "AIO Sandbox", "examples/chrome/README.md": "Chrome", "examples/claude-code/README.md": "Claude Code", "examples/code-interpreter/README.md": "Code Interpreter", "examples/codex-cli/README.md": "Codex CLI", "examples/desktop/README.md": "Desktop (VNC)", "examples/gemini-cli/README.md": "Gemini CLI", "examples/google-adk/README.md": "Google ADK", "examples/host-volume-mount/README.md": "Host Volume Mount", "examples/langgraph/README.md": "LangGraph", "examples/playwright/README.md": "Playwright", "examples/README.md": "Examples Overview", "examples/rl-training/README.md": "RL Training", "examples/vscode/README.md": "VS Code", "server/README.md": "Server", "server/DEVELOPMENT.md": "Server Development", "components/ingress/README.md": "Ingress", "components/ingress/DEVELOPMENT.md": "Ingress Development", "components/egress/README.md": "Egress Sidecar", "components/execd/README.md": "execd", "components/execd/DEVELOPMENT.md": "execd Development", "sandboxes/code-interpreter/README.md": "Code Interpreter Runtime", "kubernetes/README.md": "Kubernetes Controller", "kubernetes/examples/task-executor/README.md": "Task Executor", "kubernetes/examples/controller/README.md": "Controller Example", "oseps/README.md": "OSEP Overview", }; const shortTitleByPathZh = { "sdks/code-interpreter/javascript/README.md": "代码解释器 JS SDK", "sdks/code-interpreter/kotlin/README.md": "代码解释器 Kotlin SDK", "sdks/code-interpreter/python/README.md": "代码解释器 Python SDK", "sdks/code-interpreter/csharp/README.md": "代码解释器 C# SDK", "sdks/sandbox/javascript/README.md": "沙箱 JS SDK", "sdks/sandbox/kotlin/README.md": "沙箱 Kotlin SDK", "sdks/sandbox/python/README.md": "沙箱 Python SDK", "sdks/sandbox/csharp/README.md": "沙箱 C# SDK", "sdks/mcp/sandbox/python/README.md": "MCP 沙箱 Python SDK", "cli/README.md": "CLI(Python)", "sdks/sandbox/kotlin/sandbox-api/build/generated/api/execd/README.md": "沙箱 Execd API(Kotlin)", "sdks/sandbox/kotlin/sandbox-api/build/generated/api/lifecycle/README.md": "沙箱生命周期 API(Kotlin)", "examples/agent-sandbox/README.md": "Agent Sandbox", "examples/aio-sandbox/README.md": "AIO 沙箱", "examples/chrome/README.md": "Chrome", "examples/claude-code/README.md": "Claude Code", "examples/code-interpreter/README.md": "代码解释器", "examples/codex-cli/README.md": "Codex CLI", "examples/desktop/README.md": "桌面环境(VNC)", "examples/gemini-cli/README.md": "Gemini CLI", "examples/google-adk/README.md": "Google ADK", "examples/host-volume-mount/README.md": "宿主机目录挂载", "examples/langgraph/README.md": "LangGraph", "examples/playwright/README.md": "Playwright", "examples/README.md": "示例总览", "examples/rl-training/README.md": "强化学习训练", "examples/vscode/README.md": "VS Code", "server/README.md": "Server", "server/DEVELOPMENT.md": "Server 开发指南", "components/ingress/README.md": "Ingress", "components/ingress/DEVELOPMENT.md": "Ingress 开发指南", "components/egress/README.md": "Egress Sidecar", "components/execd/README.md": "execd", "components/execd/DEVELOPMENT.md": "execd 开发指南", "sandboxes/code-interpreter/README.md": "代码解释器运行时", "kubernetes/README.md": "Kubernetes 控制器", "kubernetes/examples/task-executor/README.md": "Task Executor", "kubernetes/examples/controller/README.md": "Controller 示例", "oseps/README.md": "OSEP 总览", }; function ensureDir(dirPath) { fs.mkdirSync(dirPath, { recursive: true }); } function rmIfExists(targetPath) { if (fs.existsSync(targetPath)) { fs.rmSync(targetPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 80 }); } } function walkMarkdownFiles(absDirPath, acc = []) { const entries = fs.readdirSync(absDirPath, { withFileTypes: true }); for (const entry of entries) { if (ignoredDirNames.has(entry.name)) { continue; } const absPath = path.join(absDirPath, entry.name); if (entry.isDirectory()) { walkMarkdownFiles(absPath, acc); continue; } if (!entry.isFile()) { continue; } if (entry.name.endsWith(".md")) { acc.push(absPath); } } return acc; } function shouldIgnoreRepoPath(repoRelPath) { const normalized = repoRelPath.replaceAll("\\", "/"); const denylistFragments = [ "/.venv/", "/venv/", "/node_modules/", "/docs/.vitepress/", "/docs/generated/", "/.pytest_cache/", "/__pycache__/", "/dist/", "/build/", "/target/", "/bin/", ]; return denylistFragments.some((fragment) => normalized.includes(fragment)); } function toRepoRelative(absPath) { return path.relative(repoRoot, absPath).replaceAll(path.sep, "/"); } function readHeadingTitle(absPath, fallbackTitle) { if (!fs.existsSync(absPath)) { return fallbackTitle; } const content = fs.readFileSync(absPath, "utf8"); const lines = content.split(/\r?\n/); let inFence = false; for (const line of lines) { const trimmed = line.trimStart(); if (trimmed.startsWith("```")) { inFence = !inFence; continue; } if (inFence) { continue; } const matched = trimmed.match(/^#{1,3}\s+(.+)$/); if (matched) { return matched[1].trim(); } } return fallbackTitle; } function normalizeTitleWhitespace(title) { return title.replace(/\s+/g, " ").trim(); } function shortenOsepTitle(repoRelPath, title, locale = "en") { const match = repoRelPath.match(/^oseps\/(0\d{3})-(.+)\.md$/i); if (!match) { return title; } const number = match[1]; const slug = match[2].toLowerCase(); if (locale === "zh") { if (slug.includes("fqdn") && slug.includes("egress")) { return `OSEP-${number}: FQDN 出口访问控制`; } if (slug.includes("agent-sandbox") || slug.includes("kubernetes-sigs")) { return `OSEP-${number}: Kubernetes Agent Sandbox 支持`; } if (slug.includes("volume")) { return `OSEP-${number}: Volume 与 VolumeBinding 支持`; } } if (slug.includes("fqdn") && slug.includes("egress")) { return `OSEP-${number}: FQDN Egress Control`; } if (slug.includes("agent-sandbox") || slug.includes("kubernetes-sigs")) { return `OSEP-${number}: Agent Sandbox on Kubernetes`; } if (slug.includes("volume")) { return `OSEP-${number}: Volume & VolumeBinding Support`; } const readable = slug .split("-") .map((part) => (part.length <= 3 ? part.toUpperCase() : part.charAt(0).toUpperCase() + part.slice(1))) .join(" "); return `OSEP-${number}: ${readable}`; } function shortenTitleByRule(title) { let next = normalizeTitleWhitespace(title); next = next.replace(/^Alibaba\s+/i, ""); next = next.replace(/^OpenSandbox\s+/i, ""); next = next.replace(/\bJavaScript\/TypeScript\b/g, "JS"); next = next.replace(/\bJava\/Kotlin\b/g, "Kotlin"); next = next.replace(/\s+Example$/i, ""); next = next.replace(/\s+SDK for /i, " "); return normalizeTitleWhitespace(next); } function shortenTitleByRuleZh(title) { let next = normalizeTitleWhitespace(title); next = next.replace(/^Alibaba\s+/i, ""); next = next.replace(/^OpenSandbox\s+/i, ""); next = next.replace(/\bJavaScript\/TypeScript\b/g, "JS"); next = next.replace(/\bJava\/Kotlin\b/g, "Kotlin"); next = next.replace(/\s+Example$/i, " 示例"); next = next.replace(/\s+SDK for /i, " "); return normalizeTitleWhitespace(next); } function getShortTitle(repoRelPath, currentTitle, locale = "en") { if (locale === "zh" && shortTitleByPathZh[repoRelPath]) { return shortTitleByPathZh[repoRelPath]; } if (locale !== "zh" && shortTitleByPath[repoRelPath]) { return shortTitleByPath[repoRelPath]; } if (/^oseps\/0\d{3}-.+\.md$/i.test(repoRelPath)) { return shortenOsepTitle(repoRelPath, currentTitle, locale); } if (locale === "zh") { return shortenTitleByRuleZh(currentTitle); } return shortenTitleByRule(currentTitle); } function toYamlString(value) { return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; } function normalizeSlugFromPath(relPath) { const normalized = relPath.replaceAll("\\", "/"); const dirName = path.posix.dirname(normalized); const baseName = path.posix.basename(normalized); const lowerBase = baseName.toLowerCase(); if (lowerBase === "readme.md" || zhReadmePattern.test(baseName)) { return dirName === "." ? "overview/home" : `${dirName}/readme`; } if (lowerBase === "development.md") { return `${dirName}/development`; } return normalized.replace(/\.md$/i, ""); } function resolveZhCandidate(repoRelPath, readmeCandidatesByDir) { const dir = path.posix.dirname(repoRelPath); const candidates = readmeCandidatesByDir.get(dir) ?? []; for (const candidate of candidates) { if (candidate.toLowerCase() !== "readme.md") { return `${dir}/${candidate}`; } } return null; } function buildGeneratedAssetPath(locale, routeSlug, resolvedRepoPath) { const normalized = resolvedRepoPath.replaceAll("\\", "/"); if (!normalized.startsWith("docs/assets/")) { return null; } const generatedDir = path.posix.dirname(`generated/${locale}/${routeSlug}.md`); const assetPath = normalized.replace(/^docs\//, ""); let relativePath = path.posix.relative(generatedDir, assetPath); if (!relativePath || relativePath === "") { relativePath = "./"; } if (!relativePath.startsWith(".") && !relativePath.startsWith("/")) { relativePath = `./${relativePath}`; } return relativePath; } function normalizeLinkTarget(target, sourceDirRel, isImage, routeSlug, locale) { if ( target.startsWith("http://") || target.startsWith("https://") || target.startsWith("mailto:") || target.startsWith("#") || target.startsWith("data:") || target.startsWith("/") ) { return target; } const [rawPath, hashFragment] = target.split("#"); const resolvedPath = path.posix.normalize(path.posix.join(sourceDirRel, rawPath)); const localAssetPath = isImage ? buildGeneratedAssetPath(locale, routeSlug, resolvedPath) : null; if (localAssetPath) { if (hashFragment) { return `${localAssetPath}#${hashFragment}`; } return localAssetPath; } const urlBase = isImage ? `${rawBaseUrl}/${resolvedPath}` : fs.existsSync(path.join(repoRoot, resolvedPath)) && fs.statSync(path.join(repoRoot, resolvedPath)).isDirectory() ? `${treeBaseUrl}/${resolvedPath}` : `${blobBaseUrl}/${resolvedPath}`; if (hashFragment) { return `${urlBase}#${hashFragment}`; } return urlBase; } function rewriteRelativeLinks(markdown, sourceRelPath, routeSlug, locale) { const sourceDirRel = path.posix.dirname(sourceRelPath); const withMarkdownLinks = markdown.replace( /(!?)\[([^\]]*?)\]\(([^)]+)\)/g, (_match, imageMark, text, linkValue) => { const trimmed = linkValue.trim(); if (!trimmed) { return _match; } const firstSpace = trimmed.search(/\s/); const target = firstSpace === -1 ? trimmed : trimmed.slice(0, firstSpace); const trailing = firstSpace === -1 ? "" : trimmed.slice(firstSpace); const rewrittenTarget = normalizeLinkTarget(target, sourceDirRel, imageMark === "!", routeSlug, locale); return `${imageMark}[${text}](${rewrittenTarget}${trailing})`; }, ); return withMarkdownLinks.replace( /]*?)src=(["'])([^"']+)\2([^>]*)>/gi, (matched, before, quote, src, after) => { const rewritten = normalizeLinkTarget(src, sourceDirRel, true, routeSlug, locale); return ``; }, ); } function renderPageSource({ locale, title, sourceRelPath, routeSlug, passthrough = false }) { const sourceAbsPath = path.join(repoRoot, sourceRelPath); const sourceMarkdown = fs.readFileSync(sourceAbsPath, "utf8"); const displayTitle = title || readHeadingTitle(sourceAbsPath, path.posix.basename(sourceRelPath, ".md")); let body = sourceMarkdown; if (!passthrough) { body = rewriteRelativeLinks(sourceMarkdown, sourceRelPath, routeSlug, locale); } const sourceUrl = `${blobBaseUrl}/${sourceRelPath}`; const sourceNotice = locale === "zh" ? `> 此页内容来自仓库源文件:[\`${sourceRelPath}\`](${sourceUrl})` : `> This page is sourced from: [\`${sourceRelPath}\`](${sourceUrl})`; return `---\ntitle: ${toYamlString(displayTitle)}\n---\n\n${body}\n\n---\n\n${sourceNotice}\n`; } function prettifyPathTitle(repoRelPath) { const dirPath = path.posix.dirname(repoRelPath); if (dirPath === "." || dirPath === "docs") { return "Overview"; } return dirPath .split("/") .map((part) => part .replaceAll("-", " ") .replaceAll("_", " ") .replace(/\b\w/g, (ch) => ch.toUpperCase()), ) .join(" / "); } function collectAutoEntries() { const readmeCandidatesByDir = new Map(); const entries = []; for (const section of sectionDefinitions) { for (const scanRoot of section.scanRoots) { const absScanRoot = path.join(repoRoot, scanRoot); if (!fs.existsSync(absScanRoot)) { continue; } const files = walkMarkdownFiles(absScanRoot); for (const absPath of files) { const repoRelPath = toRepoRelative(absPath); if (shouldIgnoreRepoPath(repoRelPath)) { continue; } const fileName = path.posix.basename(repoRelPath); const dirName = path.posix.dirname(repoRelPath); if (zhReadmePattern.test(fileName)) { const arr = readmeCandidatesByDir.get(dirName) ?? []; arr.push(fileName); readmeCandidatesByDir.set(dirName, arr); } } } } for (const section of sectionDefinitions) { for (const scanRoot of section.scanRoots) { const absScanRoot = path.join(repoRoot, scanRoot); if (!fs.existsSync(absScanRoot)) { continue; } const files = walkMarkdownFiles(absScanRoot); for (const absPath of files) { const repoRelPath = toRepoRelative(absPath); if (shouldIgnoreRepoPath(repoRelPath)) { continue; } const fileName = path.posix.basename(repoRelPath); if (zhReadmePattern.test(fileName) && !standardReadmePattern.test(fileName)) { continue; } const isReadme = standardReadmePattern.test(fileName); const isDevelopment = fileName === "DEVELOPMENT.md"; const isOsepDoc = section.id === "community" && /^0\d{3}-.+\.md$/i.test(fileName); if (!isReadme && !(section.includeDevelopment && isDevelopment) && !isOsepDoc) { continue; } const zhCandidate = isReadme ? resolveZhCandidate(repoRelPath, readmeCandidatesByDir) : null; const entryKey = `auto:${section.id}:${repoRelPath}`; const slug = normalizeSlugFromPath(repoRelPath); const titleFallback = isDevelopment ? `${prettifyPathTitle(repoRelPath)} Development` : prettifyPathTitle(repoRelPath); entries.push({ key: entryKey, sectionId: section.id, slug, enPath: repoRelPath, zhPath: zhCandidate, titleEn: getShortTitle(repoRelPath, readHeadingTitle(absPath, titleFallback), "en"), titleZh: getShortTitle( repoRelPath, readHeadingTitle( zhCandidate ? path.join(repoRoot, zhCandidate) : absPath, readHeadingTitle(absPath, titleFallback), ), "zh", ), }); } } } const unique = new Map(); for (const item of entries) { if (!unique.has(item.key)) { unique.set(item.key, item); } } return [...unique.values()].sort((a, b) => a.slug.localeCompare(b.slug)); } function buildEntries() { const autoEntries = collectAutoEntries(); const all = [...manualEntries, ...autoEntries]; const uniqueBySlug = new Map(); for (const item of all) { if (uniqueBySlug.has(item.slug)) { continue; } uniqueBySlug.set(item.slug, item); } return [...uniqueBySlug.values()]; } function toSidebarItems(entries, locale) { return entries .map((entry) => ({ text: locale === "zh" ? entry.titleZh || entry.titleEn : entry.titleEn, link: locale === "zh" ? `/zh/${entry.slug}` : `/${entry.slug}`, })) .sort((a, b) => a.link.localeCompare(b.link)); } function buildOverviewSidebar(entries, locale) { const overviewEntries = entries.filter((entry) => entry.sectionId === "overview"); const slugOrder = ["overview/home", "overview/architecture"]; const items = overviewEntries .sort((a, b) => { const ai = slugOrder.indexOf(a.slug); const bi = slugOrder.indexOf(b.slug); if (ai === -1 && bi === -1) return a.slug.localeCompare(b.slug); if (ai === -1) return 1; if (bi === -1) return -1; return ai - bi; }) .map((entry) => ({ text: locale === "zh" ? entry.titleZh || entry.titleEn : entry.titleEn, link: locale === "zh" ? `/zh/${entry.slug}` : `/${entry.slug}`, })); if (items.length === 0) { return []; } return [{ text: locale === "zh" ? "Overview" : "Overview", items }]; } function buildModulesSidebar(entries, locale) { const modules = entries.filter((entry) => entry.sectionId === "modules"); const byPrefix = new Map(); for (const entry of modules) { const prefix = entry.slug.split("/")[0]; const arr = byPrefix.get(prefix) ?? []; arr.push(entry); byPrefix.set(prefix, arr); } const order = ["sdks", "specs", "design", "server", "components", "sandboxes", "kubernetes"]; const blocks = []; for (const prefix of order) { const groupEntries = byPrefix.get(prefix); if (!groupEntries || groupEntries.length === 0) { continue; } blocks.push({ text: moduleGroupLabels[locale][prefix], items: toSidebarItems(groupEntries, locale), }); } return blocks; } function buildExamplesSidebar(entries, locale) { const items = toSidebarItems(entries.filter((entry) => entry.sectionId === "examples"), locale); if (items.length === 0) { return []; } return [{ text: locale === "zh" ? "示例" : "Examples", items }]; } function buildCommunitySidebar(entries, locale) { const blocks = []; const communityEntries = entries.filter( (entry) => entry.sectionId === "community" && entry.slug.startsWith("community/"), ); if (communityEntries.length > 0) { blocks.push({ text: communityGroupLabels[locale].community, items: toSidebarItems(communityEntries, locale), }); } const osepReadmeEntries = entries.filter((entry) => entry.sectionId === "community" && entry.slug === "oseps/readme"); const osepDocEntries = entries.filter( (entry) => entry.sectionId === "community" && entry.slug.startsWith("oseps/") && entry.slug !== "oseps/readme", ); const sortedOsepDocs = osepDocEntries.sort((a, b) => a.slug.localeCompare(b.slug)); const osepItems = [...toSidebarItems(osepReadmeEntries, locale), ...toSidebarItems(sortedOsepDocs, locale)]; if (osepItems.length > 0) { blocks.push({ text: communityGroupLabels[locale].oseps, items: osepItems, }); } return blocks; } function buildSidebarByPath(entries, locale) { const prefix = locale === "zh" ? "/zh" : ""; const overviewSidebar = buildOverviewSidebar(entries, locale); const modulesSidebar = buildModulesSidebar(entries, locale); const examplesSidebar = buildExamplesSidebar(entries, locale); const communitySidebar = buildCommunitySidebar(entries, locale); const sidebar = { [`${prefix}/`]: overviewSidebar, [`${prefix}/overview/`]: overviewSidebar, [`${prefix}/examples/`]: examplesSidebar, [`${prefix}/community/`]: communitySidebar, [`${prefix}/oseps/`]: communitySidebar, }; for (const modulesPrefix of ["server", "components", "sandboxes", "kubernetes", "specs", "sdks", "design"]) { sidebar[`${prefix}/${modulesPrefix}/`] = modulesSidebar; } return sidebar; } function writeGeneratedPages(entries) { rmIfExists(generatedRoot); ensureDir(path.join(generatedRoot, "en")); ensureDir(path.join(generatedRoot, "zh")); const rewrites = {}; const pages = []; for (const entry of entries) { const enSourcePath = entry.enPath; const zhSourcePath = entry.zhPath || entry.enPath; const enGeneratedRel = `generated/en/${entry.slug}.md`; const zhGeneratedRel = `generated/zh/${entry.slug}.md`; const enGeneratedAbs = path.join(docsRoot, enGeneratedRel); const zhGeneratedAbs = path.join(docsRoot, zhGeneratedRel); ensureDir(path.dirname(enGeneratedAbs)); ensureDir(path.dirname(zhGeneratedAbs)); fs.writeFileSync( enGeneratedAbs, renderPageSource({ locale: "en", title: entry.titleEn, sourceRelPath: enSourcePath, routeSlug: entry.slug, passthrough: entry.passthrough === true, }), "utf8", ); fs.writeFileSync( zhGeneratedAbs, renderPageSource({ locale: "zh", title: entry.titleZh || entry.titleEn, sourceRelPath: zhSourcePath, routeSlug: entry.slug, passthrough: entry.passthrough === true, }), "utf8", ); rewrites[enGeneratedRel] = `${entry.slug}.md`; rewrites[zhGeneratedRel] = `zh/${entry.slug}.md`; pages.push({ key: entry.key, slug: entry.slug, en: enSourcePath, zh: zhSourcePath, }); } return { rewrites, pages }; } export function buildManifest() { const entries = buildEntries(); const { rewrites, pages } = writeGeneratedPages(entries); const manifest = { generatedAt: new Date().toISOString(), pages, nav: { en: [ { text: "Overview", link: "/overview/home" }, { text: "Project", link: "/sdks/sandbox/python/readme" }, { text: "Examples", link: "/examples/readme" }, { text: "Community", link: "/community/contributing" }, ], zh: [ { text: "Overview", link: "/zh/overview/home" }, { text: "Project", link: "/zh/sdks/sandbox/python/readme" }, { text: "Examples", link: "/zh/examples/readme" }, { text: "Community", link: "/zh/community/contributing" }, ], }, sidebar: { en: buildSidebarByPath(entries, "en"), zh: buildSidebarByPath(entries, "zh"), }, rewrites, }; ensureDir(path.dirname(manifestPath)); fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); return manifest; } export function loadManifest() { try { if (!fs.existsSync(manifestPath)) { return buildManifest(); } const data = JSON.parse(fs.readFileSync(manifestPath, "utf8")); if (!data || !data.generatedAt || !data.nav || !data.sidebar || !data.rewrites) { return buildManifest(); } return buildManifest(); } catch (_error) { return buildManifest(); } } if (process.argv[1] === fileURLToPath(import.meta.url)) { const manifest = buildManifest(); // Keep logging terse for CI output. console.log(`docs manifest generated (${manifest.pages.length} pages)`); } ================================================ FILE: docs/.vitepress/theme/index.ts ================================================ import DefaultTheme from "vitepress/theme"; import "./styles.css"; export default DefaultTheme; ================================================ FILE: docs/.vitepress/theme/styles.css ================================================ :root { --vp-c-brand-1: #2563eb; --vp-c-brand-2: #1d4ed8; --vp-c-brand-3: #1e40af; } .VPFeature { border: 1px solid var(--vp-c-divider); border-radius: 14px; } .vp-doc blockquote { border-left: 3px solid var(--vp-c-brand-1); } /* Keep README badge rows inline in VitePress docs pages */ .vp-doc p[align="center"] { text-align: center; } .vp-doc p[align="center"] a { display: inline-flex; align-items: center; text-decoration: none; margin: 2px 4px; } .vp-doc p[align="center"] a img { display: inline-block; margin: 0; vertical-align: middle; } .scenario-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; margin: 16px 0 6px; } .vp-doc .scenario-card { display: block; border: 1px solid var(--vp-c-divider); border-radius: 12px; padding: 14px; text-decoration: none; color: inherit; background: var(--vp-c-bg-soft); transition: border-color 0.2s ease, transform 0.2s ease; } .vp-doc .scenario-card:hover { border-color: var(--vp-c-brand-1); transform: translateY(-1px); text-decoration: none; } .vp-doc .scenario-card h3 { margin: 0 0 8px; font-size: 16px; text-decoration: none; } .vp-doc .scenario-card p { margin: 0; font-size: 14px; line-height: 1.5; color: var(--vp-c-text-2); text-decoration: none; } ================================================ FILE: docs/README.md ================================================ # OpenSandbox Docs Site This directory hosts the VitePress site for OpenSandbox. ## Local development ```bash nvm use 22 cd docs pnpm install pnpm docs:dev ``` ## Build ```bash nvm use 22 cd docs pnpm install pnpm docs:build ``` ## Notes - Site content is generated from repository README and docs markdown files. - Run `pnpm docs:sync` to regenerate the manifest and routed pages. - Run `pnpm docs:spec` to regenerate `docs/public/api/spec-inline.js` from `specs/sandbox-lifecycle.yml`. ================================================ FILE: docs/README_zh.md ================================================ 中文 | [English](../README.md) OpenSandbox 是一个面向 AI 应用场景设计的「通用沙箱平台」,为LLM相关的能力(命令执行、文件操作、代码执行、浏览器操作、Agent 运行等)提供 **多语言 SDK、沙箱接口协议和沙箱运行时**。 OpenSandbox 已进入 [CNCF Landscape](https://landscape.cncf.io/?item=orchestration-management--scheduling-orchestration--opensandbox)。 ## 核心特性 - **多语言 SDK**:提供 Python、Java/Kotlin、JavaScript/TypeScript、C#/.NET 等语言的客户端 SDK,Go SDK 仍在规划中。 - **沙箱协议**:定义了沙箱生命周期管理 API 和沙箱执行 API。你可以通过这些沙箱协议扩展自己的沙箱运行时。 - **沙箱运行时**:沙箱全生命周期管理,支持 Docker 和[自研高性能 Kubernetes 运行时](../kubernetes),实现本地运行、企业级大规模分布式沙箱调度。 - **沙箱环境**:内置 Command、Filesystem、Code Interpreter 实现。并提供 Coding Agent(Claude Code 等)、浏览器自动化(Chrome、Playwright)和桌面环境(VNC、VS Code)等示例。 - **网络策略**:提供统一的 [Ingress Gateway](../components/ingress) 实现,并支持多种路由策略;提供单实例级别的沙箱[出口网络限制](../components/egress)。 - **强隔离安全**:支持 gVisor、Kata Containers 和 Firecracker 微虚拟机等安全容器运行时,为沙箱工作负载与宿主机之间提供增强的安全隔离。详见 [安全容器运行时指南](secure-container.md)。 ## 使用示例 ### 沙箱基础操作 环境要求: - Docker(本地运行必需) - Python 3.10+(本地 runtime 和快速开始) #### 1. 安装并配置 Server ```bash uv pip install opensandbox-server opensandbox-server init-config ~/.sandbox.toml --example docker-zh ``` > 如果需要开发或使用源码编译,可通过clone仓库进行开发。 > > ```bash > git clone https://github.com/alibaba/OpenSandbox.git > cd OpenSandbox/server > uv sync > cp example.config.toml ~/.sandbox.toml # Copy configuration file > uv run python -m src.main # Start the service > ``` #### 2. 启动沙箱 Server ```bash opensandbox-server # Show help opensandbox-server -h ``` #### 3. 创建代码解释器,并在沙箱中执行命令 安装 Code Interpreter SDK ```bash uv pip install opensandbox-code-interpreter ``` 创建沙箱并执行命令 ```python import asyncio from datetime import timedelta from code_interpreter import CodeInterpreter, SupportedLanguage from opensandbox import Sandbox from opensandbox.models import WriteEntry async def main() -> None: # 1. Create a sandbox sandbox = await Sandbox.create( "sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.2", entrypoint= ["/opt/opensandbox/code-interpreter.sh"], env={"PYTHON_VERSION": "3.11"}, timeout=timedelta(minutes=10), ) async with sandbox: # 2. Execute a shell command execution = await sandbox.commands.run("echo 'Hello OpenSandbox!'") print(execution.logs.stdout[0].text) # 3. Write a file await sandbox.files.write_files([ WriteEntry(path="/tmp/hello.txt", data="Hello World", mode=644) ]) # 4. Read a file content = await sandbox.files.read_file("/tmp/hello.txt") print(f"Content: {content}") # Content: Hello World # 5. Create a code interpreter interpreter = await CodeInterpreter.create(sandbox) # 6. 执行 Python 代码(单次执行:直接传 language) result = await interpreter.codes.run( """ import sys print(sys.version) result = 2 + 2 result """, language=SupportedLanguage.PYTHON, ) print(result.result[0].text) # 4 print(result.logs.stdout[0].text) # 3.11.14 # 7. Cleanup the sandbox await sandbox.kill() if __name__ == "__main__": asyncio.run(main()) ``` ### 更多示例 OpenSandbox 提供了丰富的示例来演示不同场景下的沙箱使用方式。所有示例代码位于 `examples/` 目录下。 #### 🎯 基础示例 - **[code-interpreter](../examples/code-interpreter/README.md)** - Code Interpreter SDK 的端到端沙箱流程示例。 - **[aio-sandbox](../examples/aio-sandbox/README.md)** - 使用 OpenSandbox SDK 与 agent-sandbox 的一体化沙箱示例。 - **[agent-sandbox](../examples/agent-sandbox/README.md)** - 通过 kubernetes-sigs/agent-sandbox 在 Kubernetes 上运行 OpenSandbox。 #### 🤖 Coding Agent 集成 在 OpenSandbox 中,集成各类 Coding Agent,包括 Claude Code、Google Gemini、OpenAI Codex、Kimi CLI 等。 - **[claude-code](../examples/claude-code/README.md)** - 在 OpenSandbox 中运行 Claude Code。 - **[gemini-cli](../examples/gemini-cli/README.md)** - 在 OpenSandbox 中运行 Google Gemini CLI。 - **[codex-cli](../examples/codex-cli/README.md)** - 在 OpenSandbox 中运行 OpenAI Codex CLI。 - **[kimi-cli](../examples/kimi-cli/README.md)** - 在 OpenSandbox 中运行 [Kimi CLI](https://github.com/MoonshotAI/kimi-cli)(Moonshot AI)。 - **[langgraph](../examples/langgraph/README.md)** - 基于 LangGraph 状态机编排沙箱任务与回退重试。 - **[google-adk](../examples/google-adk/README.md)** - 使用 Google ADK 通过 OpenSandbox 工具读写文件并执行命令。 - **[nullclaw](../examples/nullclaw/README.md)** - 在沙箱中启动 Nullclaw Gateway。 - **[openclaw](../examples/openclaw/README_zh.md)** - 在沙箱中启动 OpenClaw Gateway。 #### 🌐 浏览器与桌面环境 - **[chrome](../examples/chrome/README.md)** - 带 VNC 与 DevTools 的无头 Chromium,用于自动化/调试。 - **[playwright](../examples/playwright/README.md)** - Playwright + Chromium 无头抓取与测试示例。 - **[desktop](../examples/desktop/README.md)** - 通过 VNC 访问的完整桌面环境沙箱。 - **[vscode](../examples/vscode/README.md)** - 在沙箱中运行 code-server(VS Code Web)进行远程开发。 #### 🧠 机器学习与训练 - **[rl-training](../examples/rl-training/README.md)** - 在沙箱中运行 DQN CartPole 训练,输出 checkpoint 与训练汇总。 更多详细信息请参考 [examples](../examples/README.md) 和各示例目录下的 README 文件。 ## 项目结构 | 目录 | 说明 | |------|---------------------------------------------------| | [`sdks/`](../sdks/) | 多语言 SDK(Python、Java/Kotlin、TypeScript/JavaScript、C#/.NET) | | [`specs/`](../specs/) | OpenAPI 与生命周期规范 | | [`server/`](../server/README_zh.md) | Python FastAPI 沙箱生命周期服务,并集成多种运行时实现 | | [`kubernetes/`](../kubernetes/README-ZH.md) | Kubernetes 部署与示例 | | [`components/execd/`](../components/execd/README_zh.md) | 沙箱执行守护进程,负责命令和文件操作 | | [`components/ingress/`](../components/ingress/README.md) | 沙箱流量入口代理 | | [`components/egress/`](../components/egress/README.md) | 沙箱网络 Egress 访问控制 | | [`sandboxes/`](../sandboxes/) | 沙箱运行时实现与镜像(如 code-interpreter) | | [`examples/`](../examples/README.md) | 集成示例和使用案例 | | [`oseps/`](../oseps/README.md) | OpenSandbox Enhancement Proposals | | [`docs/`](../docs/) | 架构和设计文档 | | [`tests/`](../tests/) | 跨组件端到端测试 | | [`scripts/`](../scripts/) | 开发和维护脚本 | 详细架构请参阅 [docs/architecture.md](architecture.md)。 ## 文档 - [docs/architecture.md](architecture.md) – 整体架构 & 设计理念 - [oseps/README.md](../oseps/README.md) – OpenSandbox 增强提案 (OSEPs) - SDK - Sandbox 基础 SDK([Java\Kotlin SDK](../sdks/sandbox/kotlin/README_zh.md)、[Python SDK](../sdks/sandbox/python/README_zh.md)、[JavaScript/TypeScript SDK](../sdks/sandbox/javascript/README_zh.md)、[C#/.NET SDK](../sdks/sandbox/csharp/README_zh.md))- 包含沙箱生命周期、命令执行、文件操作 - Code Interpreter SDK([Java\Kotlin SDK](../sdks/code-interpreter/kotlin/README_zh.md) 、[Python SDK](../sdks/code-interpreter/python/README_zh.md)、[JavaScript/TypeScript SDK](../sdks/code-interpreter/javascript/README_zh.md)、[C#/.NET SDK](../sdks/code-interpreter/csharp/README_zh.md))- 代码解释器 - [specs/README.md](../specs/README_zh.md) - 包含沙箱生命周期 API 和沙箱执行 API 的 OpenAPI 定义 - [server/README.md](../server/README_zh.md) - 包含沙箱 Server 的启动和配置,支持 Docker 与 Kubernetes Runtime ## 许可证 本项目采用 [Apache 2.0 License](../LICENSE) 开源。 你可以在遵守许可条款的前提下,将 OpenSandbox 用于个人或商业项目。 ## 路线图 [2026.03] ### SDK - **沙箱客户端连接池** - 客户端沙箱连接池管理,提供预配置的沙箱实例,以毫秒级速度获取沙箱环境。 - **Go SDK** - Go 客户端 SDK,用于沙箱生命周期管理、命令执行和文件操作。 ### Sandbox Runtime - **持久化存储** - 沙箱的持久化存储挂载(参见 [Proposal 0003](../oseps/0003-volume-and-volumebinding-support.md))。 - **本地轻量级沙箱** - 为运行在 PC 上的 AI 工具提供轻量级沙箱。 - **安全容器** - 为在容器内运行的 AI Agent 提供安全沙箱。 ### Deployment - **部署指南** - 自托管 Kubernetes 集群的部署指南。 ## 联系与讨论 - Issue:通过 GitHub Issues 提交 bug、功能请求或设计讨论 - 钉钉群:加入 [OpenSandbox 技术交流群](https://qr.dingtalk.com/action/joingroup?code=v1,k1,A4Bgl5q1I1eNU/r33D18YFNrMY108aFF38V+r19RJOM=&_dt_no_comment=1&origin=11) 欢迎一起把 OpenSandbox 打造成 AI 场景下的通用沙箱基础设施。 ## Star History [![Star History Chart](https://api.star-history.com/svg?repos=alibaba/OpenSandbox&type=date&legend=top-left)](https://www.star-history.com/#alibaba/OpenSandbox&type=date&legend=top-left) ================================================ FILE: docs/RELEASE_NOTE_TEMPLATE.md ================================================ # [component' name] [version] ## What's New Some Docs if needed ### ✨ Features - Feature-1 (#123) - Feature-2 (#456) ### 🐛 Bug Fixes - Bug-2 (#456) ### ⚠️ Breaking Changes - xxx (#789) ### 📦 Misc - workflow update (#789) - deps update (#789) - tests update (#789) ## 👥 Contributors Thanks to these contributors ❤️ - @alice - @bob ================================================ FILE: docs/architecture.md ================================================ # OpenSandbox Architecture OpenSandbox is a universal sandbox platform designed for AI application scenarios, providing a complete solution with multi-language SDKs, standardized sandbox protocols, and flexible runtime implementations. This document describes the overall architecture and design philosophy of OpenSandbox. ## Architecture Overview ![OpenSandbox Architecture](assets/architecture.svg) The OpenSandbox architecture consists of four main layers: 1. **SDKs Layer** - Client libraries for interacting with sandboxes 2. **Specs Layer** - OpenAPI specifications defining the protocols 3. **Runtime Layer** - Server implementations managing sandbox lifecycle 4. **Sandbox Instances Layer** - Running sandbox containers with injected execution daemons ## 1. OpenSandbox SDKs The SDK layer provides high-level abstractions for developers to interact with sandboxes. It handles communication with both the Sandbox Lifecycle API and the Sandbox Execution API. ### Core SDK Components #### 1.1 Sandbox The `Sandbox` class is the primary entry point for managing sandbox lifecycle: - **Create**: Provision new sandbox instances from container images - **Manage**: Monitor sandbox state, renew expiration, retrieve endpoints - **Destroy**: Terminate sandbox instances when no longer needed **Key Features:** - Async/await support for non-blocking operations - Automatic state polling for provisioning progress - Resource quota management (CPU, memory, GPU) - Metadata and environment variable injection - TTL-based automatic expiration with renewal #### 1.2 Filesystem The `Filesystem` component provides comprehensive file operations within sandboxes: - **CRUD Operations**: Create, read, update, and delete files and directories - **Bulk Operations**: Upload/download multiple files efficiently - **Search**: Glob-based file searching with pattern matching - **Permissions**: Manage file ownership, group, and mode (chmod) - **Metadata**: Retrieve file info including size, timestamps, permissions **Use Cases:** - Uploading code files and dependencies - Downloading execution results and artifacts - Managing workspace directories - Searching for files by pattern #### 1.3 Commands The `Commands` component enables shell command execution within sandboxes: - **Foreground Execution**: Run commands synchronously with real-time output streaming - **Background Execution**: Launch long-running processes in detached mode - **Stream Support**: Capture stdout/stderr via Server-Sent Events (SSE) - **Process Control**: Interrupt running commands via context cancellation - **Working Directory**: Specify custom working directory for command execution **Use Cases:** - Running build commands (e.g., `npm install`, `pip install`) - Executing system utilities (e.g., `git`, `docker`) - Starting web servers or services - Running test suites #### 1.4 CodeInterpreter The `CodeInterpreter` component provides stateful code execution across multiple programming languages: - **Multi-Language Support**: Python, Java, JavaScript, TypeScript, Go, Bash - **Session Management**: Maintain execution state across multiple code blocks - **Jupyter Integration**: Built on Jupyter kernel protocol for robust execution - **Result Streaming**: Real-time output via SSE with execution counts - **Error Handling**: Structured error responses with tracebacks **Key Features:** - Variable persistence across executions within same session - Display data in multiple MIME types (text, HTML, images) - Execution interruption support - Execution timing and performance metrics **Use Cases:** - Interactive coding environments (e.g., Jupyter notebooks) - AI code generation and execution - Data analysis and visualization - Educational coding platforms ### SDK Language Support OpenSandbox provides SDKs in multiple languages: - **Python SDK** (`sdks/sandbox/python`, `sdks/code-interpreter/python`) - **Java/Kotlin SDK** (`sdks/sandbox/kotlin`, `sdks/code-interpreter/kotlin`) - **TypeScript SDK** (Roadmap) All SDKs follow the same design patterns and provide consistent APIs across languages. ## 2. OpenSandbox Specs The Specs layer defines two core OpenAPI specifications that establish the contract between SDKs and runtime implementations. ### 2.1 Sandbox Lifecycle Spec **File**: `specs/sandbox-lifecycle.yml` The Lifecycle Spec defines the API for managing sandbox instances throughout their lifecycle. #### Core Operations | Operation | Endpoint | Description | |-----------|----------|-------------| | **Create** | `POST /sandboxes` | Create a new sandbox from a container image | | **List** | `GET /sandboxes` | List sandboxes with filtering and pagination | | **Get** | `GET /sandboxes/{id}` | Retrieve sandbox details and status | | **Delete** | `DELETE /sandboxes/{id}` | Terminate a sandbox | | **Pause** | `POST /sandboxes/{id}/pause` | Pause a running sandbox | | **Resume** | `POST /sandboxes/{id}/resume` | Resume a paused sandbox | | **Renew** | `POST /sandboxes/{id}/renew-expiration` | Extend sandbox TTL | | **Endpoint** | `GET /sandboxes/{id}/endpoints/{port}` | Get public URL for a port | ### 2.2 Sandbox Execution Spec **File**: `specs/execd-api.yaml` The Execution Spec defines the API for interacting with running sandbox instances. This API is implemented by the `execd` daemon injected into each sandbox. #### API Categories **Health** - `GET /ping` - Health check **Code Interpreting** - `POST /code/context` - Create execution context - `POST /code` - Execute code with streaming output - `DELETE /code` - Interrupt code execution **Command Execution** - `POST /command` - Execute shell command - `DELETE /command` - Interrupt command **Filesystem** - `GET /files/info` - Get file metadata - `DELETE /files` - Remove files - `POST /files/permissions` - Change permissions - `POST /files/mv` - Rename/move files - `GET /files/search` - Search files by glob pattern - `POST /files/replace` - Replace file content - `POST /files/upload` - Upload files - `GET /files/download` - Download files - `POST /directories` - Create directories - `DELETE /directories` - Remove directories **Metrics** - `GET /metrics` - Get system metrics snapshot - `GET /metrics/watch` - Stream metrics via SSE ## 3. OpenSandbox Runtime The Runtime layer implements the Sandbox Lifecycle Spec and manages the orchestration of sandbox containers. ### 3.1 Server Architecture **Location**: `server/` The OpenSandbox server is a FastAPI-based service providing: - **Lifecycle Management**: Create, monitor, pause, resume, and terminate sandboxes - **Pluggable Runtimes**: Docker (production-ready), Kubernetes (production-ready) - **Async Provisioning**: Background creation to reduce latency - **Automatic Expiration**: Configurable TTL with renewal support - **Access Control**: API key authentication - **Observability**: Unified status tracking with transition logging ### 3.2 Runtime Implementations #### Docker Runtime (Ready) **Features:** - Direct Docker API integration - Two networking modes: - **Host Mode**: Containers share host network (single instance) - **Bridge Mode**: Isolated networking with HTTP routing - Container lifecycle management - Resource quota enforcement - Private registry authentication - Volume mounting for execd injection - Automatic cleanup on expiration **Key Responsibilities:** 1. Pull container images (with auth support) 2. Create containers with resource limits 3. Inject execd binary and start script 4. Monitor container state 5. Handle pause/resume operations 6. Clean up terminated containers #### Kubernetes Runtime (Ready) **Features:** - Built-in **[BatchSandbox](https://github.com/alibaba/OpenSandbox/tree/main/kubernetes)** runtime with sandbox pooling, high-throughput batch creation, and heterogeneous task orchestration; also compatible with **[SIG agent-sandbox](https://github.com/kubernetes-sigs/agent-sandbox)** as an alternative runtime - Support for different secure container runtimes (e.g., kata-containers, gVisor) - Helm-based deployment for controller and server, see [documentation](https://github.com/alibaba/OpenSandbox/blob/main/kubernetes/charts/opensandbox/README.md) **Planned Features:** - Unified network storage mounting (ossfs, NAS, custom PVC) in both pooled and non-pooled modes - Pause/resume support #### Custom Runtime The pluggable architecture allows implementing custom runtimes by: 1. Implementing the Lifecycle Spec APIs 2. Managing sandbox provisioning and cleanup 3. Injecting execd into sandbox instances 4. Reporting sandbox state transitions ### 3.3 Networking and Routing #### Sandbox Router **Purpose**: Provides HTTP/HTTPS load balancing to sandbox instance ports. **Features:** - Dynamic endpoint generation based on sandbox ID and port - Supports both domain-based and wildcard routing - Reverse proxy to sandbox container ports - Automatic cleanup when sandbox terminates **Endpoint Format**: `{domain}/sandboxes/{sandboxId}/port/{port}` **Use Cases:** - Accessing web applications running in sandboxes - Connecting to development servers (e.g., VS Code Server) - Exposing APIs and services - VNC and remote desktop access ## 4. Sandbox Instances Sandbox instances are running containers that host user workloads with an injected execution daemon. ### 4.1 Container Structure Each sandbox instance consists of: 1. **Base Container**: User-specified image (e.g., `ubuntu:22.04`, `python:3.11`) 2. **execd Daemon**: Injected execution agent implementing the Execution Spec 3. **Entrypoint Process**: User-defined main process ### 4.2 execd - Execution Daemon **Location**: `components/execd/` execd is a Go-based HTTP daemon built on the Beego framework. #### Core Responsibilities 1. **Code Execution**: Manage Jupyter kernel sessions for multi-language code execution 2. **Command Execution**: Run shell commands with output streaming 3. **File Operations**: Provide filesystem API for remote file management 4. **Metrics Collection**: Monitor and report CPU, memory usage #### Architecture **Technology Stack:** - **Language**: Go 1.24+ - **Web Framework**: Beego - **Jupyter Integration**: WebSocket-based Jupyter protocol client - **Streaming**: Server-Sent Events (SSE) **Package Structure:** - `pkg/flag/` - Configuration and CLI flags - `pkg/web/` - HTTP layer (controllers, models, router) - `pkg/runtime/` - Execution dispatcher - `pkg/jupyter/` - Jupyter kernel client - `pkg/util/` - Utilities and helpers #### Jupyter Integration execd integrates with Jupyter Server running inside the container: 1. **Session Management**: Create and maintain kernel sessions 2. **WebSocket Communication**: Real-time bidirectional communication 3. **Message Protocol**: Jupyter message spec implementation 4. **Stream Parsing**: Parse execution results, outputs, errors **Supported Kernels:** - Python (IPython) - Java (IJava) - JavaScript (IJavaScript) - TypeScript (ITypeScript) - Go (gophernotes) - Bash ### 4.3 Injection Mechanism The execd daemon is injected into sandbox containers during creation: **Docker Runtime Injection Process:** 1. **Pull execd Image**: Retrieve the execd container image 2. **Extract Binary**: Copy execd binary from image to temporary location 3. **Volume Mount**: Mount execd binary and startup script into target container 4. **Entrypoint Override**: Modify container entrypoint to start execd first 5. **User Process Launch**: execd forks and executes the user's entrypoint **Startup Sequence:** ```bash # Container starts with modified entrypoint /opt/opensandbox/start.sh ↓ # Start Jupyter Server jupyter notebook --port=54321 --no-browser --ip=0.0.0.0 ↓ # Start execd daemon /opt/opensandbox/execd --jupyter-host=http://127.0.0.1:54321 --port=44772 ↓ # Execute user entrypoint exec "${USER_ENTRYPOINT[@]}" ``` **Benefits:** - Transparent to user code - No image modification required - Dynamic injection at runtime - Works with any base image ## 5. Communication Flow ### 5.1 Sandbox Creation Flow ``` User/SDK │ │ 1. POST /sandboxes (image, entrypoint, resources) ▼ Server (Lifecycle API) │ │ 2. Pull container image │ 3. Inject execd binary │ 4. Create container with entrypoint override │ 5. Start container ▼ Sandbox Instance │ │ 6. Start execd daemon │ 7. Start Jupyter Server │ 8. Execute user entrypoint ▼ Running (State) ``` ### 5.2 Code Execution Flow ``` User/SDK │ │ 1. Create sandbox │ 2. Get execd endpoint ▼ CodeInterpreter SDK │ │ 3. POST /code/context (create session) │ 4. POST /code (execute code) ▼ execd (Execution API) │ │ 5. Route to Jupyter runtime ▼ Jupyter Runtime │ │ 6. WebSocket to Jupyter Server │ 7. Send execute_request ▼ Jupyter Kernel (Python/Java/etc.) │ │ 8. Execute code │ 9. Stream output events ▼ execd │ │ 10. Convert to SSE events │ 11. Stream to client ▼ CodeInterpreter SDK │ │ 12. Parse events │ 13. Return result to user ▼ User/Application ``` ### 5.3 File Operations Flow ``` User/SDK │ │ 1. Upload files ▼ Filesystem SDK │ │ 2. POST /files/upload (multipart) ▼ execd (Execution API) │ │ 3. Write to filesystem │ 4. Set permissions ▼ Sandbox Container Filesystem ``` ## 6. Design Principles ### 6.1 Protocol-First Design - All interactions defined by OpenAPI specifications - Clear contracts between components - Enables polyglot implementations - Supports custom runtime implementations ### 6.2 Separation of Concerns - **SDK**: Client-side abstraction and convenience - **Specs**: Protocol definition and documentation - **Runtime**: Sandbox orchestration and lifecycle - **execd**: In-sandbox execution and operations ### 6.3 Extensibility - Pluggable runtime implementations - Custom sandbox images - Multiple SDK languages - Additional Jupyter kernels ### 6.4 Security - API key authentication for lifecycle operations - Token-based authentication for execution operations - Isolated sandbox environments - Resource quota enforcement - Network isolation options ### 6.5 Observability - Structured state transitions - Real-time metrics streaming - Comprehensive logging - Health check endpoints ## 7. Use Cases ### 7.1 AI Code Generation and Execution AI models (like Claude, GPT-4, Gemini) generate code that needs to be executed safely: - **Isolation**: Run untrusted AI-generated code in sandboxes - **Multi-Language**: Support various programming languages - **Iteration**: Maintain state across multiple code generations - **Feedback**: Capture execution results and errors for AI refinement **Examples**: [claude-code](../examples/claude-code/), [gemini-cli](../examples/gemini-cli/), [codex-cli](../examples/codex-cli/) ### 7.2 Interactive Coding Environments Build web-based coding platforms and notebooks: - **Code Execution**: Run code in isolated environments - **File Management**: Upload/download project files - **Terminal Access**: Execute shell commands - **Collaboration**: Share sandbox instances **Examples**: [code-interpreter](../examples/code-interpreter/) ### 7.3 Browser Automation and Testing Automate web browsers for testing and scraping: - **Headless Browsers**: Chrome, Playwright - **Remote Debugging**: DevTools protocol - **VNC Access**: Visual debugging - **Network Isolation**: Controlled environment **Examples**: [chrome](../examples/chrome/), [playwright](../examples/playwright/) ### 7.4 Remote Development Environments Provide cloud-based development workspaces: - **VS Code Server**: Full IDE in browser - **Desktop Environments**: VNC-based desktops - **Tool Pre-installation**: Language runtimes, build tools - **Port Forwarding**: Access development servers **Examples**: [vscode](../examples/vscode/), [desktop](../examples/desktop/) ### 7.5 Continuous Integration and Testing Run build and test pipelines in isolated environments: - **Reproducible Builds**: Consistent container images - **Parallel Execution**: Multiple sandbox instances - **Artifact Collection**: Download build outputs - **Resource Limits**: Prevent resource exhaustion ## 8. Conclusion OpenSandbox provides a complete, production-ready platform for building AI-powered applications that require safe code execution, file management, and command execution in isolated environments. The architecture is designed to be: - **Universal**: Works with any container image - **Extensible**: Pluggable runtimes and custom implementations - **Developer-Friendly**: Multi-language SDKs with consistent APIs - **Production-Ready**: Robust lifecycle management and observability - **Secure**: Isolated environments with access control The protocol-first design ensures that all components can evolve independently while maintaining compatibility. Whether you're building AI coding assistants, interactive notebooks, or remote development environments, OpenSandbox provides the foundation you need. ## 9. References - [Contributing Guide](contributing.md) - [Sandbox Lifecycle Spec](../specs/sandbox-lifecycle.yml) - [Sandbox Execution Spec](../specs/execd-api.yaml) - [Server Documentation](../server/README.md) - [execd Documentation](../components/execd/README.md) - [Python SDK](../sdks/sandbox/python/README.md) - [Java/Kotlin SDK](../sdks/sandbox/kotlin/README.md) - [Examples](../examples/README.md) ================================================ FILE: docs/index.md ================================================ --- layout: home hero: name: OpenSandbox text: Universal Sandbox Infrastructure for AI Applications tagline: Securely run commands, filesystems, code interpreters, browsers, and developer tools in isolated runtime environments. actions: - theme: brand text: Quick Start link: /overview/home - theme: alt text: Explore Architecture link: /overview/architecture features: - title: Sandbox Lifecycle and Runtime Management details: Provision, monitor, renew, and terminate sandbox instances with Docker and Kubernetes-oriented runtime capabilities. - title: Multi-Language SDKs and Unified APIs details: Build with Python, Java/Kotlin, and JavaScript SDKs on top of standardized lifecycle and execution protocols. - title: Powerful In-Sandbox Execution details: Execute shell commands, manage files, run multi-language code interpreters, expose ports, and stream logs/metrics. - title: Built for Real AI Workloads details: Supports coding agents, browser automation, remote development, AI code execution, and RL training scenarios. --- ## Typical Scenarios OpenSandbox is now listed in the [CNCF Landscape](https://landscape.cncf.io/?item=orchestration-management--scheduling-orchestration--opensandbox). Explore all scenario references in [Examples](./examples/readme). ================================================ FILE: docs/manual-cleanup-refactor-guide.md ================================================ # Manual Cleanup Refactor Guide ## Background GitHub issue: `alibaba/OpenSandbox#442` Issue summary: - Support non-expiring sandboxes - Let callers manage cleanup explicitly - Keep existing TTL-based behavior for current users - Work across Docker and Kubernetes runtimes where supported Current implementation does not support this. TTL is a hard requirement in: - API request/response models - Docker runtime scheduling and restore logic - Kubernetes workload creation and renew flows This document captures the recommended refactor direction before implementation starts. ## Refactor Goal Introduce a manual cleanup mode without adding a new top-level mode field for now. Chosen semantic: - `timeout` present: sandbox uses TTL behavior - `timeout` omitted or `null`: sandbox uses manual cleanup behavior Non-goals for this refactor: - Do not support magic values like `timeout=0` or `timeout=-1` - Do not redesign the lifecycle API beyond what is required for manual cleanup - Do not overload `renew_expiration` to switch a sandbox from manual mode back to TTL mode ## Compatibility and Rollout This refactor is compatible through a controlled upgrade path, not through strict protocol backward compatibility. Important compatibility fact: - Once manual cleanup is enabled in an environment, lifecycle responses may contain `expiresAt=null` - Lifecycle responses may also serialize other nullable fields explicitly as `null` instead of omitting them - Older SDKs that assume `expiresAt` is always a timestamp may fail when they call `create`, `get`, or `list` - Older schema-generated clients may also fail if they assume fields such as `metadata`, `status.reason`, `status.message`, or `status.lastTransitionAt` are always omitted or always non-null - Existing TTL-based callers are unaffected as long as they do not encounter manual-cleanup sandboxes Recommended rollout order: 1. Upgrade all SDKs/clients that read lifecycle API responses 2. Upgrade the server 3. Only then start creating sandboxes with `timeout` omitted or `null` Operational rule: - Do not create manual-cleanup sandboxes in a shared environment until all readers of the lifecycle API have been upgraded This should be called out explicitly in release notes and upgrade documentation. ## Why This Approach Compared with adding `expirationMode`, using `timeout: Optional[int]` is the smallest compatible change that still maps cleanly to the feature request. Advantages: - Smaller API and SDK surface change - Easier migration from the current TTL-only model - Preserves current behavior for existing clients that already send `timeout` Tradeoffs: - Mode becomes implicit rather than explicit - `timeout == null` can mean either deliberate manual mode or missing input - Future expansion beyond `ttl/manual` may require a second API refactor For the current scope, these tradeoffs are acceptable. ## Current State ### API layer TTL is currently mandatory. Relevant files: - `server/src/api/schema.py` - `specs/sandbox-lifecycle.yml` Current constraints: - `CreateSandboxRequest.timeout` is required and bounded to `60-86400` - `CreateSandboxResponse.expiresAt` is required - `Sandbox.expiresAt` is required - `RenewSandboxExpirationRequest.expiresAt` is required and assumes the sandbox already has TTL semantics ### Docker runtime Relevant file: - `server/src/services/docker.py` Current behavior: - Creation always computes `expires_at = created_at + timeout` - Creation always schedules expiration via in-process timer - Existing sandboxes are restored from the expiration label on server startup - Sandbox read/list responses always expose `expiresAt` - `renew_expiration()` only supports extending TTL ### Kubernetes runtime Relevant files: - `server/src/services/k8s/kubernetes_service.py` - `server/src/services/k8s/batchsandbox_provider.py` - `server/src/services/k8s/agent_sandbox_provider.py` Current behavior: - Creation always computes `expires_at = created_at + timeout` - BatchSandbox writes `spec.expireTime` - agent-sandbox writes `spec.shutdownTime` - `renew_expiration()` patches those fields - Sandbox read/list responses expose `expiresAt` ## Target API Semantics ### Create request `CreateSandboxRequest.timeout` should become optional. Rules: - `timeout` omitted or `null` means manual cleanup mode - `timeout` present means TTL mode - If present, `timeout` must still satisfy `60 <= timeout <= 86400` - `timeout=0` and `timeout<0` remain invalid Suggested request examples: TTL mode: ```json { "image": { "uri": "python:3.11" }, "timeout": 3600, "resourceLimits": {}, "entrypoint": ["sleep", "infinity"] } ``` Manual cleanup mode: ```json { "image": { "uri": "python:3.11" }, "resourceLimits": {}, "entrypoint": ["sleep", "infinity"] } ``` ### Response models `expiresAt` should become nullable in: - `CreateSandboxResponse` - `Sandbox` Rules: - TTL sandbox: `expiresAt` contains an RFC 3339 timestamp - Manual sandbox: `expiresAt` is `null` ### Renew expiration API Do not use `renew_expiration` as a mode switch. Recommended behavior: - TTL sandbox: renew works as it does today - Manual sandbox: renew fails clearly Recommended response: - `409 Conflict` preferred - `400 Bad Request` acceptable if existing error handling makes that much simpler Recommended error message: - `"Sandbox does not have automatic expiration enabled."` ## Implementation Strategy ## 1. API and schema updates Files to update: - `server/src/api/schema.py` - `specs/sandbox-lifecycle.yml` Required changes: - Make `CreateSandboxRequest.timeout` optional - Make `CreateSandboxResponse.expiresAt` optional - Make `Sandbox.expiresAt` optional - Update field descriptions to document manual cleanup behavior - Update request/response examples in the OpenAPI spec Recommended validation rule: - No custom mode field - Validation only enforces bounds when `timeout` is not `None` ## 2. Docker runtime refactor File to update: - `server/src/services/docker.py` ### Target behavior For manual sandboxes: - No expiration timestamp is computed - No expiration label is written - A dedicated runtime marker should be written (for example `opensandbox.io/manual-cleanup=true`) - No expiration timer is scheduled - Sandbox survives server restart without restoration warnings - Read/list responses return `expiresAt=None` ### Concrete refactor points #### Creation context Current logic: - `_prepare_creation_context()` always returns a concrete `expires_at` Target logic: - Return `expires_at: Optional[datetime]` - `None` when `request.timeout is None` #### Label building Current logic: - Expiration label is assumed to exist Target logic: - Only write `SANDBOX_EXPIRES_AT_LABEL` when `expires_at is not None` - Write a dedicated manual-cleanup label/annotation when `expires_at is None` #### Provisioning Current logic: - `_provision_sandbox()` always schedules expiration Target logic: - Only call `_schedule_expiration()` when `expires_at is not None` #### Sandbox reconstruction Current logic: - `_container_to_sandbox()` falls back to a concrete `expires_at` Target logic: - Manual sandbox should produce `expiresAt=None` - Avoid fallback behavior that fabricates an expiration timestamp from `created_at` #### Restore path Current logic: - `_restore_existing_sandboxes()` warns when a sandbox is missing the expiration label Target logic: - Missing expiration label should only be treated as valid when the manual-cleanup marker is present - Continue warning on sandboxes that have neither an expiration label nor a manual-cleanup marker - Only restore timers for TTL sandboxes that actually carry expiration metadata #### Renew path Current logic: - `renew_expiration()` assumes every sandbox has TTL enabled Target logic: - Reject renewal if the manual-cleanup marker is present - Continue treating "missing expiration metadata without manual marker" as malformed state rather than silently converting it to manual mode ## 3. Kubernetes service refactor Files to update: - `server/src/services/k8s/kubernetes_service.py` - `server/src/services/k8s/workload_provider.py` - `server/src/services/k8s/batchsandbox_provider.py` - `server/src/services/k8s/agent_sandbox_provider.py` ### Key risk Kubernetes support depends on the underlying CRDs. Open question: - Can BatchSandbox omit `spec.expireTime`? - Can agent-sandbox omit `spec.shutdownTime`? This must be confirmed before claiming end-to-end support. ### Recommended capability design Add a provider capability check: - `supports_manual_cleanup() -> bool` Persist the chosen mode on workload metadata as well: - TTL sandbox: keep expiration field populated - Manual sandbox: omit expiration field and write a provider-neutral marker (label or annotation) Rationale: - Docker can support manual cleanup immediately - Kubernetes providers may differ based on CRD semantics - The server should fail clearly when the selected provider cannot represent a non-expiring sandbox ### Service-layer behavior In `KubernetesSandboxService.create_sandbox()`: - Compute `expires_at: Optional[datetime]` - If `request.timeout is None` and provider does not support manual cleanup, fail early with a clear message Suggested message: - `"Manual cleanup mode is not supported by the current Kubernetes workload provider."` ### BatchSandbox provider behavior If supported by the CRD: - Make `expires_at` optional in provider interfaces - Omit `spec.expireTime` when `expires_at is None` - `get_expiration()` should return `None` when the field is absent - `update_expiration()` should reject manual sandboxes instead of silently enabling TTL If not supported by the CRD: - Return `False` from `supports_manual_cleanup()` - Keep current `expireTime` behavior unchanged ### agent-sandbox provider behavior If supported by the CRD: - Make `expires_at` optional in provider interfaces - Omit `spec.shutdownTime` when `expires_at is None` - `get_expiration()` should return `None` when the field is absent - `update_expiration()` should reject manual sandboxes If not supported by the CRD: - Return `False` from `supports_manual_cleanup()` - Keep current `shutdownTime` behavior unchanged ## 4. Interface changes Files likely affected: - `server/src/services/sandbox_service.py` - `server/src/services/k8s/workload_provider.py` Required updates: - Any method signature currently assuming `expires_at: datetime` should be reviewed - Provider creation/update/get-expiration flows should allow `Optional[datetime]` where needed - Abstract service docs should describe manual cleanup semantics ## Error Handling Guidance Recommended failure cases: ### Unsupported runtime/provider Case: - User omits `timeout` - Provider cannot represent non-expiring sandbox Response: - HTTP 400 Message: - `"Manual cleanup mode is not supported by the current runtime/provider."` ### Renew called for manual sandbox Response: - HTTP 409 preferred Message: - `"Sandbox does not have automatic expiration enabled."` ### Invalid timeout values Keep current behavior: - Reject `timeout=0` - Reject negative values - Reject values above max bound ## Compatibility Plan This refactor should preserve backward compatibility for current users. Expected compatibility behavior: - Existing clients sending `timeout` continue to work unchanged - Existing responses for TTL sandboxes remain unchanged - New manual-cleanup behavior is opt-in via omission of `timeout` Compatibility caveat: - Any generated SDKs may need regeneration because `timeout` and `expiresAt` types change from required to optional - Generated SDKs should also tolerate explicit `null` values in optional lifecycle fields, not only missing fields - Cross-SDK request shapes do not need to be byte-for-byte identical if language constraints differ. In particular, the C# SDK may use an explicit `ManualCleanup` flag instead of `timeout=null` so it can keep "unset means use default TTL" distinct from "explicitly request manual cleanup". ## Testing Plan ### API/schema tests Files likely affected: - `server/tests/test_schema.py` - route tests covering create/get/list/renew Add coverage for: - Create request without `timeout` - Create request with valid `timeout` - Reject `timeout=0` - Create response with `expiresAt=null` - Sandbox model with `expiresAt=null` ### Docker tests File likely affected: - `server/tests/test_docker_service.py` Add coverage for: - Manual sandbox creation does not schedule expiration - Manual sandbox creation does not write expiration label - Manual sandbox get/list returns `expiresAt=None` - Server restart restore path ignores manual sandboxes without warning - Renew expiration on manual sandbox fails clearly - TTL sandbox behavior remains unchanged ### Kubernetes service tests Files likely affected: - `server/tests/k8s/test_kubernetes_service.py` - `server/tests/k8s/test_batchsandbox_provider.py` - `server/tests/k8s/test_agent_sandbox_provider.py` Add coverage for: - Manual mode rejected when provider capability is false - Manual mode omits expiration fields when provider capability is true - Manual mode writes the runtime marker when provider capability is true - `get_expiration()` returns `None` when expiration field is absent - Renew expiration fails for manual sandboxes - TTL sandbox behavior remains unchanged ### Spec/SDK validation Follow-up checks: - Regenerate or validate OpenAPI docs if needed - Verify generated SDKs handle optional `timeout` and nullable `expiresAt` ## Suggested Implementation Order 1. Update schema models in `server/src/api/schema.py` 2. Update OpenAPI spec in `specs/sandbox-lifecycle.yml` 3. Refactor Docker runtime to support `expires_at: Optional[datetime]` 4. Add Kubernetes provider capability plumbing 5. Implement Kubernetes manual mode only where confirmed supported 6. Add and update tests 7. Regenerate SDK/spec artifacts if required by repo workflow ## Open Questions Before Coding These should be resolved early in the branch: 1. Does BatchSandbox allow `spec.expireTime` to be omitted? 2. Does agent-sandbox allow `spec.shutdownTime` to be omitted? 3. Should renew-on-manual return `400` or `409`? 4. Should list/get expose any explicit hint that a sandbox is manual, or is `expiresAt=null` sufficient? Recommended implementation default for questions 1 and 2 until confirmed: - Return `False` from `supports_manual_cleanup()` for both Kubernetes providers - Enable Kubernetes manual mode only after CRD behavior is verified by tests or upstream documentation Recommended answer for question 4: - `expiresAt=null` is sufficient for the first iteration ## Summary The smallest practical refactor is: - Make `timeout` optional - Treat missing `timeout` as manual cleanup mode - Make `expiresAt` nullable - Support manual mode in Docker immediately - Gate Kubernetes support behind provider capability and CRD validation - Keep `renew_expiration()` TTL-only This preserves current behavior while creating a clear path to non-expiring sandboxes with limited API churn. ================================================ FILE: docs/package.json ================================================ { "name": "opensandbox-docs", "private": true, "packageManager": "pnpm@9.15.0", "scripts": { "docs:sync": "node .vitepress/scripts/docs-manifest.mjs", "docs:spec": "node ../scripts/spec-doc/generate-spec.js --output docs/public/api/spec-inline.js", "docs:dev": "pnpm docs:sync && pnpm docs:spec && vitepress dev", "docs:build": "pnpm docs:sync && pnpm docs:spec && vitepress build", "docs:preview": "vitepress preview" }, "devDependencies": { "vitepress": "^1.6.4" } } ================================================ FILE: docs/secure-container.md ================================================ # Secure Container Runtime Guide This guide explains how to use secure container runtimes with OpenSandbox to provide hardware-level isolation for executing untrusted AI-generated code. ## Table of Contents - [Overview](#overview) - [Server Configuration](#server-configuration) - [Docker Mode](#docker-mode) - [Kubernetes Mode](#kubernetes-mode) - [User Guide](#user-guide) - [Administrator Guide](#administrator-guide) - [Troubleshooting and Best Practices](#troubleshooting-and-best-practices) --- ## Overview ### What are Secure Container Runtimes? Secure container runtimes provide stronger isolation than the standard runc runtime used by Docker and containerd. They add additional security layers through different mechanisms: | Runtime | Isolation Mechanism | Startup Overhead | Memory Overhead | Best For | |---------|---------------------|------------------|-----------------|----------| | **runc** (default) | Process-level cgroups | ~0ms | Minimal | Trusted workloads, local development | | **gVisor** | User-space kernel (syscall interception) | ~10-50ms | ~50MB | General workloads with low overhead | | **Kata (QEMU)** | Full VM with QEMU hypervisor | ~500ms | ~20-50MB | Maximum compatibility and isolation | | **Kata (Firecracker)** | MicroVM with Firecracker hypervisor | ~125ms | ~5MB | High density, minimal footprint | | **Kata (CLH)** | Cloud Hypervisor | ~200ms | ~10-20MB | Balanced performance and isolation | ### Why Use Secure Runtimes? OpenSandbox is designed to execute untrusted code generated by AI models (Claude, GPT-4, Gemini, etc.). Secure runtimes provide: 1. **Container Escape Protection**: Prevents malicious code from breaking out of the container 2. **Kernel-Level Isolation**: Each sandbox gets its own kernel context 3. **Multi-Tenant Safety**: Different users' sandboxes are strongly isolated 4. **Compliance**: Meets security requirements for regulated industries ### Supported Runtime Types OpenSandbox supports the following secure runtime types through server-level configuration: - `"gvisor"` - Google gVisor with runsc - `"kata"` - Kata Containers with QEMU hypervisor (default) - `"firecracker"` - Kata Containers with Firecracker hypervisor - `""` (empty) - Standard runc (default, no secure runtime) ### Key Design Principle **Server-Level Configuration**: The secure runtime is configured once at the server level by administrators. All sandboxes on that server transparently use the configured runtime. SDK users and API callers require **no code changes**. --- ## Server Configuration Secure runtimes are configured through the `~/.sandbox.toml` configuration file. The server validates the configured runtime at startup and will refuse to start if the runtime is unavailable. ### Configuration File Edit `~/.sandbox.toml`: ```toml [runtime] type = "docker" # or "kubernetes" execd_image = "opensandbox/execd:latest" # Secure container runtime configuration # When enabled, ALL sandboxes on this server use the specified runtime [secure_runtime] # Runtime type: "", "gvisor", "kata", "firecracker" type = "" # Docker mode: OCI runtime name (e.g., "runsc" for gVisor, "kata-runtime" for Kata) # Required when runtime.type = "docker" and type is not empty docker_runtime = "runsc" # Kubernetes mode: RuntimeClass name (e.g., "gvisor", "kata-qemu", "kata-fc") # Required when runtime.type = "kubernetes" and type is not empty k8s_runtime_class = "gvisor" ``` ### Configuration Examples #### Example 1: gVisor on Docker ```toml [runtime] type = "docker" execd_image = "opensandbox/execd:latest" [secure_runtime] type = "gvisor" docker_runtime = "runsc" k8s_runtime_class = "gvisor" ``` #### Example 2: Kata Containers on Kubernetes ```toml [runtime] type = "kubernetes" execd_image = "opensandbox/execd:latest" [secure_runtime] type = "kata" docker_runtime = "kata-runtime" k8s_runtime_class = "kata-qemu" ``` #### Example 3: Kata + Firecracker on Kubernetes ```toml [runtime] type = "kubernetes" execd_image = "opensandbox/execd:latest" [secure_runtime] type = "firecracker" docker_runtime = "" # Not supported in Docker mode k8s_runtime_class = "kata-fc" ``` ### Startup Validation When the server starts, it automatically validates that the configured secure runtime is available: ```bash $ opensandbox-server INFO Validating secure runtime for Docker backend INFO Docker OCI runtime 'runsc' is available: {...} INFO Application startup complete. ``` If the runtime is not available, the server will refuse to start with a clear error message: ``` ERROR Configured Docker runtime 'runsc' is not available. Available runtimes: runc. Please install and configure it in /etc/docker/daemon.json. ``` --- ## Docker Mode Docker mode is fully supported for secure container runtimes. ### Prerequisites - Docker daemon installed and running - Secure runtime installed on the host ### gVisor Setup for Docker #### Step 1: Install gVisor runsc For Docker mode, you only need to install the **runsc** OCI runtime: ```bash # Ubuntu/Debian curl -fsSL https://gvisor.dev/archive.key | sudo gpg --dearmor -o /usr/share/keyrings/gvisor-archive-keyring.gpg echo "deb [signed-by=/usr/share/keyrings/gvisor-archive-keyring.gpg] https://storage.googleapis.com/gvisor/releases release main" | \ sudo tee /etc/apt/sources.list.d/gvisor.list sudo apt-get update && sudo apt-get install -y runsc # Verify installation runsc --version ``` > **Note**: For Docker mode, only `runsc` is required. The `containerd-shim-runsc-v1` is only needed for Kubernetes/containerd. > > **Reference**: See [gVisor Installation Guide](https://gvisor.dev/docs/user_guide/install/) for other distributions and installation methods. #### Step 2: Configure Docker daemon Use the `runsc install` command to automatically configure Docker daemon: ```bash sudo runsc install ``` Or manually edit `/etc/docker/daemon.json`: ```json { "runtimes": { "runsc": { "path": "/usr/bin/runsc", "runtimeArgs": [ "--platform=systrap", "--network=host" ] } } } ``` Restart Docker: ```bash sudo systemctl restart docker ``` > **Reference**: See [gVisor Docker Quick Start](https://gvisor.dev/docs/user_guide/quick_start/docker/) for more details. #### Step 3: Configure OpenSandbox Server Edit `~/.sandbox.toml`: ```toml [runtime] type = "docker" execd_image = "opensandbox/execd:latest" [secure_runtime] type = "gvisor" docker_runtime = "runsc" ``` #### Step 4: Start Server and Verify ```bash opensandbox-server ``` Create a test sandbox: ```bash curl -X POST http://localhost:8080/v1/sandboxes \ -H "Content-Type: application/json" \ -d '{ "image": {"uri": "python:3.11"}, "timeout": 3600, "resourceLimits": {"cpu": "500m", "memory": "512Mi"}, "entrypoint": ["python", "-u", "-c", "import time\nwhile True: print('hello from gVisor!'); time.sleep(1)"], "metadata": { "name": "gvisor-docker-sandbox" } }' ``` Verify the runtime: ```bash docker ps --format "{{.ID}}\t{{.Image}}\t{{.Names}}" docker inspect | grep -A2 Runtime # Expected output: # "Runtime": "runsc", ``` ### Kata Containers Setup for Docker #### System Requirements Kata Containers requires hardware virtualization support. Verify your system meets the following requirements: **Hardware Virtualization Support:** ```bash # Check if CPU supports hardware virtualization (VT-x for Intel, AMD-V for AMD) lscpu | grep Virtualization # Expected output: Virtualization: VT-x (Intel) or AMD-V (AMD) # Alternatively on Intel grep -E --color=auto 'vmx|svm' /proc/cpuinfo # Expected: vmx (Intel) or svm (AMD) flags present ``` **KVM Module:** ```bash # Check if KVM module is loaded lsmod | grep kvm # Expected: kvm_intel (Intel) or kvm_amd (AMD) # If not loaded, load KVM module sudo modprobe kvm_intel # For Intel # or sudo modprobe kvm_amd # For AMD ``` **Kernel Requirements:** - Linux kernel 5.10 or later recommended - KVM enabled in kernel config **Docker Requirements:** - Docker 20.10 or later - `/etc/docker/daemon.json` configured for Kata runtime #### Installation Download and install Kata Containers static binaries from GitHub releases: ```bash # Find the latest release at https://github.com/kata-containers/kata-containers/releases KATA_VERSION="3.27.0" wget https://github.com/kata-containers/kata-containers/releases/download/${KATA_VERSION}/kata-static-${KATA_VERSION}-amd64.tar.zst # Extract to root directory - Kata will be installed in /opt/kata zstd -d kata-static-${KATA_VERSION}-amd64.tar.zst tar -xvf kata-static-${KATA_VERSION}-amd64.tar -C / # Create symbolic links for PATH access sudo ln -sf /opt/kata/bin/kata-runtime /usr/local/bin/kata-runtime sudo ln -sf /opt/kata/bin/containerd-shim-kata-v2 /usr/local/bin/containerd-shim-kata-v2 # Verify installation kata-runtime --version ``` #### Configure Docker Daemon Edit `/etc/docker/daemon.json` to register Kata as a runtime: ```json { "default-runtime": "runc", "runtimes": { "kata": { "runtimeType": "io.containerd.kata.v2" } } } ``` Restart Docker to apply changes: ```bash sudo systemctl restart docker # Verify Kata is available in Docker docker info | grep -A5 Runtimes # Expected output should include "io.containerd.runc.v2 kata" ``` #### Configure OpenSandbox Server Edit `~/.sandbox.toml`: ```toml [runtime] type = "docker" execd_image = "opensandbox/execd:latest" [secure_runtime] type = "kata" docker_runtime = "kata" ``` #### Verify Installation **Test with OpenSandbox API** Create a sandbox and verify it's running in a VM by checking the kernel: ```bash # Create a test sandbox curl --location 'http://127.0.0.1:8080/v1/sandboxes' \ --header 'Content-Type: application/json' \ --data '{ "image": {"uri": "ubuntu:latest"}, "timeout": 3600, "resourceLimits": {"cpu": "500m", "memory": "512Mi"}, "entrypoint": ["/bin/bash", "-c", "while true; do uname -a; sleep 1; done"], "metadata": { "name": "kata-sandbox" } }' ``` Check the container's kernel to verify VM isolation: ```bash # Get the container ID docker ps | grep kata-sandbox # Check the kernel inside the container (should be different from host) docker exec uname -a # Expected output: Linux 5.10.x-generic #x86_64 ... (Kata VM kernel) # Compare with host kernel uname -a # Host kernel might be different version or have different hostname ``` **Key Indicators of Kata VM:** - Container runs in a separate kernel with different hostname - Kernel version is typically `5.10.x` (Kata's guest kernel) - Host process list shows `qemu-system-x86_64` or similar hypervisor process --- ## Kubernetes Mode Kubernetes mode supports secure runtimes through RuntimeClass resources. ### Prerequisites - Kubernetes cluster with containerd runtime - Secure runtime installed on all nodes - RuntimeClass CRDs created ### gVisor Setup for Kubernetes #### Step 1: Install gVisor Components on All Nodes For Kubernetes with containerd, you need to install **two** components: 1. **runsc** - the gVisor OCI runtime 2. **containerd-shim-runsc-v1** - the containerd shim for gVisor ```bash # On each node - Ubuntu/Debian curl -fsSL https://gvisor.dev/archive.key | sudo gpg --dearmor -o /usr/share/keyrings/gvisor-archive-keyring.gpg echo "deb [signed-by=/usr/share/keyrings/gvisor-archive-keyring.gpg] https://storage.googleapis.com/gvisor/releases release main" | \ sudo tee /etc/apt/sources.list.d/gvisor.list sudo apt-get update # Install both gVisor components sudo apt-get install -y runsc containerd-shim-runsc-v1 # Verify installation runsc --version containerd-shim-runsc-v1 --version ``` > **Reference**: See [gVisor Installation Guide](https://gvisor.dev/docs/user_guide/containerd/configuration/) for complete installation instructions and other distributions. #### Step 2: Configure containerd Edit `/etc/containerd/config.toml`: ```toml [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runsc] runtime_type = "io.containerd.runsc.v1" [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runsc.options] TypeUrl = "io.containerd.runsc.v1.options" ConfigPath = "/etc/containerd/runsc.toml" ``` ```bash sudo tee /etc/containerd/runsc.toml > /dev/null <<'EOF' [runsc] platform = "ptrace" EOF ``` Restart containerd: ```bash sudo systemctl restart containerd ``` #### Step 3: Create RuntimeClass CRD ```yaml # gvisor-runtimeclass.yaml apiVersion: node.k8s.io/v1 kind: RuntimeClass metadata: name: gvisor handler: runsc scheduling: nodeSelector: kubernetes.io/arch: amd64 ``` ```bash kubectl apply -f gvisor-runtimeclass.yaml ``` #### Step 4: Configure OpenSandbox Server Edit `~/.sandbox.toml`: ```toml [runtime] type = "kubernetes" execd_image = "opensandbox/execd:latest" [secure_runtime] type = "gvisor" k8s_runtime_class = "gvisor" ``` #### Step 5: Verify Installation ```bash # Test the RuntimeClass kubectl run test-gvisor --restart=Never --image=hello-world --runtime-class=gvisor kubectl logs test-gvisor kubectl delete pod test-gvisor ``` ### Kata Containers Setup for Kubernetes #### Step 1: Install Kata Containers Follow the [official Kata Containers installation guide](https://github.com/kata-containers/kata-containers/blob/main/tools/packaging/kata-deploy/helm-chart/README.md). Quick installation using Helm: ```bash # Install kata-deploy which will set up Kata Containers via DaemonSet helm install kata-deploy "oci://ghcr.io/kata-containers/kata-deploy-charts/kata-deploy" --version "3.27.0" --namespace kube-system --create-namespace # Wait for kata-deploy pods to be ready kubectl wait --for=condition=ready pod -l name=kata-deploy -n kube-system --timeout=300s ``` > **Note**: The `kata-deploy` DaemonSet will automatically configure containerd on all nodes. Manual containerd configuration is not required when using kata-deploy. #### Step 2: Verify Installation Check that Kata Containers is installed and RuntimeClasses are created: ```bash # Check RuntimeClasses kubectl get runtimeclass # Expected output: # NAME HANDLER AGE # kata kata-qemu 10m # kata-qemu kata-qemu 10m # kata-clh kata-clh 10m # kata-fc kata-fc 10m # Test Kata with a simple pod kubectl run test-kata --restart=Never --image=hello-world --runtime-class=kata-qemu kubectl logs test-kata kubectl delete pod test-kata ``` ### Creating Pools for Different Runtimes (Optional) When using Pool CRDs for pre-warmed sandboxes, create separate pools for each runtime type: ```yaml # gvisor-pool.yaml apiVersion: sandbox.opensandbox.io/v1alpha1 kind: Pool metadata: name: gvisor-pool labels: runtime: gvisor spec: template: spec: runtimeClassName: gvisor containers: - name: sandbox-container image: opensandbox/code-interpreter:v1.0.2 capacitySpec: bufferMax: 10 bufferMin: 2 poolMax: 20 poolMin: 5 ``` --- ## User Guide This section is for AI application developers using OpenSandbox. ### No Code Changes Required **Important**: The secure runtime is configured at the server level. Your code does not need to change. Simply create a sandbox using the OpenSandbox Lifecycle API - the server automatically applies the configured secure runtime: **Create a test sandbox:** ```bash curl -X POST http://localhost:8080/v1/sandboxes \ -H "Content-Type: application/json" \ -d '{ "image": {"uri": "python:3.11"}, "timeout": 3600, "resourceLimits": {"cpu": "500m", "memory": "512Mi"}, "entrypoint": ["python", "-u", "-c", "import time\nwhile True: print(\"hello from secure sandbox!\"); time.sleep(1)"], "metadata": { "name": "my-secure-sandbox" } }' ``` **Response:** ```json { "id": "550e8400-e29b-41d4-a716-446655440000", "status": "running" } ``` The sandbox will automatically use the secure runtime configured on the server (gVisor, Kata, or runc). ### How It Works 1. **Administrator** configures the secure runtime in `~/.sandbox.toml` 2. **Server** validates the runtime at startup 3. **Server** automatically injects the runtime into each sandbox: - Docker mode: Adds `runtime` to HostConfig - Kubernetes mode: Adds `runtimeClassName` to Pod spec 4. **User** creates sandboxes via API - no runtime parameter needed ### Verifying Runtime Isolation After creating a sandbox, verify the runtime being used: **Docker mode:** ```bash docker ps --format "{{.ID}}\t{{.Image}}\t{{.Names}}" docker inspect | grep -A2 Runtime # Expected output for gVisor: # "Runtime": "runsc", ``` **Kubernetes mode:** ```bash kubectl get pod -o jsonpath='{.spec.runtimeClassName}' # Expected output for gVisor: # gvisor ``` --- ## Administrator Guide This section is for platform operators and SREs managing secure runtime infrastructure. ### Prerequisites Secure runtimes must be installed and configured on your infrastructure **before** configuring OpenSandbox. OpenSandbox does not install runtimes automatically. ### Installation Summary | Runtime | Docker | Kubernetes | |---------|--------|------------| | gVisor | Install runsc → Configure daemon.json | Install runsc → Configure containerd → Create RuntimeClass | | Kata (QEMU) | Install kata-runtime → Configure daemon.json | Install Kata → Configure containerd → Create RuntimeClass | | Kata (Firecracker) | Not supported | Install Kata → Configure containerd → Create RuntimeClass | ### Configuration Validation The server validates secure runtime configuration at startup: 1. **Docker mode**: Checks if the runtime exists in Docker daemon's runtime list 2. **Kubernetes mode**: Checks if the RuntimeClass exists in the cluster If validation fails, the server refuses to start with a clear error message. ### Security Best Practices 1. **Default to gVisor**: Provides good security with acceptable performance for most workloads 2. **Use Kata for Untrusted Code**: Maximum isolation for completely unknown code 3. **Regular Updates**: Keep runtimes updated for security patches 4. **Test Compatibility**: Validate your workloads with the chosen runtime before production 5. **Monitor Resources**: Secure runtimes have higher memory overhead ### Runtime Selection Guidelines | Use Case | Recommended Runtime | Reasoning | |----------|---------------------|-----------| | Development/Testing | runc (default) | Fastest startup, lowest overhead | | Production AI Code Execution | gVisor | Good balance of security and performance | | High-Security Requirements | Kata (QEMU) | Maximum isolation, full compatibility | | High-Density Multi-Tenant | Kata (Firecracker) | Minimal memory overhead per sandbox | | Untrusted Network Code | gVisor or Kata | Syscall filtering prevents network attacks | --- ## Troubleshooting and Best Practices ### Common Issues #### 1. Runtime Not Found (Docker) **Error**: `Configured Docker runtime 'runsc' is not available.` **Solution**: Ensure the runtime is configured in `/etc/docker/daemon.json` and Docker has been restarted: ```bash sudo systemctl restart docker docker info | grep -A5 Runtimes ``` #### 2. RuntimeClass Not Found (Kubernetes) **Error**: `RuntimeClass 'gvisor' does not exist.` **Solution**: Create the RuntimeClass CRD: ```bash kubectl get runtimeclass kubectl apply -f gvisor-runtimeclass.yaml ``` #### 3. Syscall Compatibility Issues **Error**: Container exits with code 1, no logs **Cause**: gVisor doesn't implement all syscalls. Some applications may not be compatible. **Solution**: Check the [gVisor compatibility guide](https://gvisor.dev/docs/user_guide/compatibility/). Try using Kata (QEMU) which has better compatibility. #### 4. Pod Stuck in ContainerCreating **Cause**: RuntimeClass handler not configured on the node. **Solution**: Verify containerd configuration: ```bash # On the node sudo containerd config dump sudo systemctl restart containerd ``` ### Compatibility Matrix | Feature | runc | gVisor | Kata (QEMU) | Kata (CLH) | Kata (FC) | |---------|------|--------|-------------|------------|-----------| | Syscall Compatibility | Full | Partial | Full | Full | Limited | | GPU Support | Yes | No | Yes | Yes | No | | IPv6 | Yes | Yes | Yes | Yes | Yes | | Privileged Mode | Yes | No | Yes | Yes | No | | Docker Volume | Yes | Yes | Yes | Yes | Yes | | Systemd | Yes | No | Yes | Yes | No | ### Getting Help - **Documentation**: [OpenSandbox GitHub](https://github.com/alibaba/OpenSandbox) - **Issues**: Report bugs via [GitHub Issues](https://github.com/alibaba/OpenSandbox/issues) - **Design Document**: See [OSEP-0004](/oseps/0004-secure-container-runtime) for complete design details ================================================ FILE: docs/single_host_network.md ================================================ # Single-host network in OpenSandbox Detailed routing for a single-host deployment: how execd’s proxy gives every sandbox access to HTTP and WebSocket ports through one exposed host port. ![Single-host sandbox routing](assets/single_host_network.png) ## Single-host routing model - Every sandbox container starts `execd` listening on container port `44772`. `execd` bundles a lightweight reverse proxy that intercepts requests with the `/proxy/{port}` prefix and forwards them to `127.0.0.1:{port}` inside the same container. - The Docker runtime binds only the host side of the execd proxy port (labeled `opensandbox.io/embedding-proxy-port`). Callers use `get_endpoint(..., port=X)` to receive `{public_host}:{host_proxy_port}/proxy/{X}`, and execd transparently routes the request back to the sandbox service on port `X`. - Because the proxy preserves `Upgrade`, `Connection`, and other HTTP headers, HTTP, Server-Sent Events, and WebSocket traffic share the same mapped host port without additional configuration. - With this setup, a single host port per sandbox suffices to reach **all** container ports. You can safely run many sandboxes on one machine without worrying about overlapping host port allocations. - When the caller lives inside the same Docker network (e.g., another container or Kubernetes pod), use `get_endpoint(..., resolve_internal=True)` to bypass the host mapping and return the sandbox IP (e.g., `172.17.0.3:5900`) instead. - The diagram above shows the routing path: host traffic hits the proxy port, execd rewrites the request towards the target container port, and upstream services remain isolated within the sandbox. ## Network modes ### Host network mode (single-host constraints) - Containers share the host network stack (`network_mode=host`) so sandbox ports are directly accessible on the host. - Because each sandbox binds its ports on the host, this mode practically limits you to one sandbox instance per host unless you reserve dedicated ports per sandbox. - `get_endpoint(..., port=X)` returns `{public_host}:{X}` with no `/proxy/` prefix, so the caller needs to know the exact host port and the host must manage firewall rules for each sandbox port. ### Bridge network mode (default for single-host deployments) - Docker places sandboxes on an isolated bridge network, preventing container ports from being reachable without explicit mapping. - For single-host scaling, OpenSandbox maps only execd’s proxy port (`44772`) and, optionally, port `8080`. Any other container port stays private and is reached via the proxy. - The reverse proxy label (`opensandbox.io/embedding-proxy-port`) identifies a host port that fronts `execd`. `get_endpoint(..., port=X)` returns `{public_host}:{host_proxy_port}/proxy/{X}`, so all internal ports can share the same host binding. - Port `8080` may also receive a direct host binding (`opensandbox.io/http-port`), providing a conventional HTTP endpoint without the proxy path when required. - This bridge setup lets a single machine host many sandboxes without port conflicts, because the same host proxy port can multiplex requests for HTTP, SSE, WebSocket, VNC, etc. ## Operational notes - If execd’s proxy port (`44772`) or the optional `8080` host mapping is missing, `get_endpoint` responds with HTTP 500 and a message stating which mapping was unavailable. - Always keep the `/proxy/{port}` prefix (including any additional path or query string) when embedding URLs in browser-based clients or SDKs so that execd can correctly dispatch the request. - This proxy-based approach means additional ports never need to be published on the host, simplifying firewall management and improving security. ================================================ FILE: docs/zh/index.md ================================================ --- layout: home hero: name: OpenSandbox text: 面向 AI 应用的通用沙箱基础设施 tagline: 在隔离运行时中安全执行命令、文件操作、代码解释器、浏览器与开发工具。 actions: - theme: brand text: 快速开始 link: /zh/overview/home - theme: alt text: 查看架构 link: /zh/overview/architecture features: - title: 沙箱全生命周期与运行时管理 details: 支持沙箱实例创建、监控、续期与销毁,覆盖 Docker 与 Kubernetes 场景。 - title: 多语言 SDK 与统一协议 details: 提供 Python、Java/Kotlin、JavaScript SDK,并基于统一的生命周期与执行协议进行开发。 - title: 强大的沙箱内执行能力 details: 支持命令执行、文件系统操作、多语言代码解释、端口暴露以及日志/指标流式获取。 - title: 面向真实 AI 工作负载 details: 适配 Coding Agent、浏览器自动化、远程开发、AI 代码执行与强化学习训练等场景。 --- ## 典型落地场景 OpenSandbox 已进入 [CNCF Landscape](https://landscape.cncf.io/?item=orchestration-management--scheduling-orchestration--opensandbox)。 更多场景请查看 [示例](./examples/readme)。 ================================================ FILE: examples/README.md ================================================ # OpenSandbox Examples Examples for common OpenSandbox use cases. Each subdirectory contains runnable code and documentation. ## Integrations / Sandboxes - 🧰 [**aio-sandbox**](aio-sandbox): All-in-one sandbox setup using OpenSandbox SDK and agent-sandbox - Kubernetes [**agent-sandbox**](agent-sandbox): Create a kubernetes-sigs/agent-sandbox instance and run a command - 🧪 [**code-interpreter**](code-interpreter): Code Interpreter SDK singleton example - 💾 [**host-volume-mount**](host-volume-mount): Mount host directories into sandboxes (read-write, read-only, subpath) - ☁️ [**docker-ossfs-volume-mount**](docker-ossfs-volume-mount): Mount OSSFS volumes in Docker runtime (inline credentials, subpath, sharing) - 🎯 [**rl-training**](rl-training): Reinforcement learning training loop inside a sandbox - Claude [**claude-code**](claude-code): Call Claude (Anthropic) API/CLI within the sandbox - Google Gemini [**gemini-cli**](gemini-cli): Call Google Gemini within the sandbox - OpenAI [**codex-cli**](codex-cli): Call OpenAI/Codex-like models within the sandbox - Kimi [**kimi-cli**](kimi-cli): Call Kimi Code CLI (Moonshot AI) within the sandbox - LangGraph [**langgraph**](langgraph): LangGraph agent orchestrating sandbox lifecycle + tools - Google ADK [**google-adk**](google-adk): Google ADK agent calling OpenSandbox tools - 🦞 [**nullclaw**](nullclaw): Launch a Nullclaw Gateway inside a sandbox - 🦞 [**openclaw**](openclaw): Run an OpenClaw Gateway inside a sandbox - 🖥️ [**desktop**](desktop): Launch VNC desktop (Xvfb + x11vnc) for VNC client connections - Playwright [**playwright**](playwright): Launch headless browser (Playwright + Chromium) to scrape web content - VS Code [**vscode**](vscode): Launch code-server (VS Code Web) to provide browser access - Google Chrome [**chrome**](chrome): Launch headless Chromium with DevTools port exposed for remote debugging ## How to Run - Set basic environment variables (e.g., `export SANDBOX_DOMAIN=...`, `export SANDBOX_API_KEY=...`) - Add provider-specific variables as needed (e.g., `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `KIMI_API_KEY`, etc.; model selection is optional) - Navigate to the example directory and install dependencies: `pip install -r requirements.txt` (or refer to the Dockerfile in the directory) - Then execute: `python main.py` - To run in a container, build and run using the `Dockerfile` in the directory - Summary: First set required environment variables via `export`, then run `python main.py` in the corresponding directory, or build/run the Docker image for that directory. ================================================ FILE: examples/agent-sandbox/README.md ================================================ # Agent-Sandbox Example This example creates a sandbox backed by `kubernetes-sigs/agent-sandbox` and executes `echo hello world` via the OpenSandbox Python SDK. ## Prerequisites - A Kubernetes cluster with the agent-sandbox controller and CRDs installed. - OpenSandbox server configured with Kubernetes runtime and `workload_provider = "agent-sandbox"`. - Sandbox image should include `bash` (default example uses `ubuntu:22.04`). ## Start OpenSandbox server 1. Install the server package and fetch the example config for agent-sandbox: ```shell uv pip install opensandbox-server opensandbox-server init-config ~/.sandbox.toml --example docker ``` 2. Update `~/.sandbox.toml` with the following sections: ```toml [runtime] type = "kubernetes" execd_image = "opensandbox/execd:v1.0.7" [kubernetes] namespace = "default" # kubeconfig_path = "/absolute/path/to/kubeconfig" # optional if running in-cluster workload_provider = "agent-sandbox" [agent_sandbox] shutdown_policy = "Delete" ``` 3. Start the server: ```shell opensandbox-server ``` ## Run the example ```shell uv pip install opensandbox uv run python examples/agent-sandbox/main.py ``` ## Expected output ```text command output: hello world ``` ================================================ FILE: examples/agent-sandbox/main.py ================================================ # Copyright 2025 Alibaba Group Holding Ltd. # # 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. import asyncio import os from datetime import timedelta from opensandbox import Sandbox from opensandbox.config import ConnectionConfig async def main() -> None: domain = os.getenv("SANDBOX_DOMAIN", "localhost:8080") api_key = os.getenv("SANDBOX_API_KEY") image = os.getenv("SANDBOX_IMAGE", "ubuntu:22.04") config = ConnectionConfig( domain=domain, api_key=api_key, request_timeout=timedelta(seconds=60), ) sandbox = await Sandbox.create( image, connection_config=config, timeout=timedelta(minutes=10), ) async with sandbox: execution = await sandbox.commands.run("echo hello world") stdout = execution.logs.stdout[0].text if execution.logs.stdout else "" print(f"command output: {stdout}") await sandbox.kill() if __name__ == "__main__": asyncio.run(main()) ================================================ FILE: examples/aio-sandbox/README.md ================================================ # All-in-One (AIO) Sandbox Example This example demonstrates how to create and access an [All-in-One (AIO) Sandbox](https://github.com/agent-infra/sandbox) via OpenSandbox. ## Start OpenSandbox server [local] You can find the latest version [here](https://github.com/agent-infra/sandbox/pkgs/container/sandbox). You can pre-pull the target image which is used in the example. ### Notes (Docker runtime requirement) The server is configured with `runtime.type = "docker"` by default, so it **must** be able to connect to a running Docker daemon. - **Docker Desktop**: ensure Docker Desktop is running, then verify with `docker version`. - **Colima (macOS)**: start it first (`colima start`) and export the socket before starting the server: ```shell export DOCKER_HOST="unix://${HOME}/.colima/default/docker.sock" ``` ```shell # pre-pull target image docker pull ghcr.io/agent-infra/sandbox:latest ``` Then, start the OpenSandbox server, you can obtain stdout log from terminal. ```shell uv pip install opensandbox-server opensandbox-server init-config ~/.sandbox.toml --example docker opensandbox-server ``` > Note: `opensandbox-server` runs in the foreground and will keep the current terminal session busy. The example code lives in this repository—clone it and, in a new terminal window/tab, `cd` into the project root before running the AIO sandbox creation steps below. If you see errors like `FileNotFoundError: [Errno 2] No such file or directory` from `docker/transport/unixconn.py`, it usually means the Docker unix socket is missing / Docker daemon is not running. ## Create and Access the AIO Sandbox Instance This example uses a fixed configuration for quick start: - OpenSandbox server: `http://localhost:8080` - Image: `ghcr.io/agent-infra/sandbox:latest` - AIO port: `8080` - Timeout: `300s` Install dependencies with uv under project root: ```shell uv pip install opensandbox agent-sandbox==0.0.18 ``` Run the example (it will create a sandbox via OpenSandbox, wait until it's Running, then connect to it via agent-sandbox): ```shell uv run python examples/aio-sandbox/main.py ``` Subsequently, you will instantiate an AIO sandbox, navigate to Google, capture a screenshot, and download it to your local environment. ```text Creating AIO sandbox with image=ghcr.io/agent-infra/sandbox:latest on OpenSandbox server http://localhost:8080... [check] sandbox ready after 7.1s AIO portal endpoint: 127.0.0.1:56123 total 52 drwxr-x--- 10 gem gem 4096 Dec 15 13:22 . drwxr-xr-x 1 root root 4096 Dec 15 13:22 .. -rw-r--r-- 1 gem gem 220 Jan 7 2022 .bash_logout -rw-r--r-- 1 gem gem 27 Dec 15 13:22 .bashrc drwxr-xr-x 5 gem gem 4096 Dec 15 13:22 .cache drwxrwxr-x 6 gem gem 4096 Dec 15 13:22 .config drwxr-xr-x 2 gem gem 4096 Dec 15 13:22 .ipython drwxr-xr-x 4 gem gem 4096 Dec 15 13:22 .jupyter drwxrwxr-x 4 gem gem 4096 Dec 15 13:22 .local drwxr-xr-x 3 gem gem 4096 Dec 15 13:22 .npm drwxrwxr-x 3 gem gem 4096 Dec 15 13:22 .npm-global drwx------ 3 gem gem 4096 Dec 15 13:22 .pki -rw-r--r-- 1 gem gem 807 Jan 7 2022 .profile -rw-rw-r-- 1 gem gem 0 Dec 15 13:22 .Xauthority export TERM=xterm-256color Screenshot saved to sandbox_screenshot.png ``` ## More examples For more examples of using the AIO Sandbox, refer to agents-infra/sandbox [examples](https://github.com/agent-infra/sandbox/tree/main/examples). ## References - [AIO Sandbox](https://github.com/agent-infra/sandbox/tree/main) - [AIO Sandbox Python SDK](https://github.com/agent-infra/sandbox/tree/main/sdk/python) ================================================ FILE: examples/aio-sandbox/main.py ================================================ # Copyright 2025 Alibaba Group Holding Ltd. # # 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. """ Create an AIO sandbox via OpenSandbox SDK, then connect to it with agent-sandbox SDK. This example is intentionally hard-coded for simplicity: - OpenSandbox server: http://localhost:8080 - Image: ghcr.io/agent-infra/sandbox:latest - AIO port: 8080 - Timeout: 300s """ import time from datetime import timedelta import requests from agent_sandbox import Sandbox as AioSandboxClient from opensandbox import SandboxSync from opensandbox.config import ConnectionConfigSync def check_aio_process(sbx: SandboxSync) -> bool: """ Health check: poll aio process at /v1/shell/sessions until it returns 200. Returns: True when ready False on timeout or any exception """ try: endpoint = sbx.get_endpoint(8080) start = time.perf_counter() url = f"http://{endpoint.endpoint}/v1/shell/sessions" for _ in range(150): # max for ~30s try: resp = requests.get(url, timeout=1) if resp.status_code == 200: elapsed = time.perf_counter() - start print(f"[check] sandbox ready after {elapsed:.1f}s") return True except Exception as exc: # print(f"[check] aio sandbox check health failed: {exc}") pass time.sleep(0.2) return False except Exception as exc: print(f"[check] failed: {exc}") return False def main() -> None: server = "http://localhost:8080" image = "ghcr.io/agent-infra/sandbox:latest" timeout_seconds = 300 print(f"Creating AIO sandbox with image={image} on OpenSandbox server {server}...") sandbox = SandboxSync.create( image=image, timeout=timedelta(seconds=timeout_seconds), metadata={"example": "aio-sandbox"}, entrypoint=["/opt/gem/run.sh"], connection_config=ConnectionConfigSync(domain=server), health_check=check_aio_process, ) with sandbox: endpoint = sandbox.get_endpoint(8080) print(f"AIO portal endpoint: {endpoint.endpoint}") client = AioSandboxClient(base_url=f"http://{endpoint.endpoint}") home_dir = client.sandbox.get_context().home_dir result = client.shell.exec_command(command="ls -la", timeout=10) print(result.data.output) content = client.file.read_file(file=f"{home_dir}/.bashrc") print(content.data.content) screenshot_path = "sandbox_screenshot.png" with open(screenshot_path, "wb") as f: for chunk in client.browser.screenshot(): f.write(chunk) print(f"Screenshot saved to {screenshot_path}") # kill sandbox finally sandbox.kill() if __name__ == "__main__": main() ================================================ FILE: examples/chrome/Dockerfile ================================================ # Copyright 2025 Alibaba Group Holding Ltd. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. FROM golang:1.25.4 AS builder WORKDIR /build COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 go build -o /build/entrypoint main.go #---------------------- # Use a base image with a minimal set of packages. FROM debian:13-slim #---------------------- # Install prerequisites, chromium, VNC, and X11 utilities. RUN set -eux; \ apt-get update; \ apt-get install -y --no-install-recommends \ ca-certificates \ wget \ xdg-utils \ chromium \ tigervnc-standalone-server \ x11-utils; \ rm -rf /var/lib/apt/lists/* # Create a non-root user to run Chrome. RUN groupadd -r chrome && useradd -r -g chrome -G audio,video chrome \ && mkdir -p /home/chrome/Downloads && chown -R chrome:chrome /home/chrome # Precreate X11 stuff RUN mkdir -p /tmp/.X11-unix RUN chmod 1777 /tmp/.X11-unix COPY --chmod=a+rx chrome.sh /chrome.sh COPY --from=builder --chmod=a+rx /build/entrypoint /entrypoint WORKDIR /home/chrome USER chrome ENTRYPOINT [ "/entrypoint" ] ================================================ FILE: examples/chrome/README.md ================================================ # Chrome Browser in OpenSandbox This example runs Chrome Browser with OpenSandbox runtime. The image starts a VNC server (`Xtigervnc :1`) and launches Chromium with remote debugging enabled on port `9222`. ## Getting Chrome image You can build the image from source or pull it from Docker Hub. ### Build from source ```shell docker build -t opensandbox/chrome . ``` ### Pull an existing image ```shell docker pull opensandbox/chrome:latest # use acr from china # docker pull sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/chrome:latest ``` ## Start OpenSandbox server Start the OpenSandbox server and tail stdout from the terminal: ```shell uv pip install opensandbox-server opensandbox-server init-config ~/.sandbox.toml --example docker opensandbox-server ``` ## Create and access a Chrome sandbox Build/pull the image above, then create a sandbox with image `opensandbox/chrome:latest` and an entrypoint that keeps it alive (e.g., `["/bin/sh", "-c", "sleep infinity"]`), or reuse `tail -f /dev/null`. Make sure the runtime exposes ports `5901` and `9222` for VNC/DevTools. ```shell uv pip install opensandbox uv run python examples/chrome/main.py ``` Then fetch endpoints for 5901/9222 to connect with a VNC client or DevTools, like: ```text execd daemon running with endpoint='127.0.0.1:48379/proxy/44772' VNC running with endpoint='127.0.0.1:48379/proxy/5901' DevTools running with endpoint='127.0.0.1:48379/proxy/9222'/json ``` ```text [ { "description": "", "devtoolsFrontendUrl": "https://chrome-devtools-frontend.appspot.com/serve_rev/@71a0dbd6672e2ccb6d1008376cbb7acd315cb8d6/inspector.html?ws=127.0.0.1:52302/devtools/page/2215AF60AC345E4BA6D822389CFC743B", "faviconUrl": "https://www.gstatic.com/images/branding/searchlogo/ico/favicon.ico", "id": "2215AF60AC345E4BA6D822389CFC743B", "title": "Google", "type": "page", "url": "https://www.google.com.hk/", "webSocketDebuggerUrl": "ws://127.0.0.1:52302/devtools/page/2215AF60AC345E4BA6D822389CFC743B" } ] ``` Or you can use it by MCP client, more information please refer to: [chrome-devtools-mcp](https://github.com/ChromeDevTools/chrome-devtools-mcp). ## Reference - [chrome-devtools-mcp](https://github.com/ChromeDevTools/chrome-devtools-mcp) ================================================ FILE: examples/chrome/build.sh ================================================ #!/bin/bash # Copyright 2025 Alibaba Group Holding Ltd. # # 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. set -ex TAG=${TAG:-latest} docker buildx rm chrome-builder || true docker buildx create --use --name chrome-builder docker buildx inspect --bootstrap docker buildx ls docker buildx build \ -t opensandbox/chrome:${TAG} \ -t sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/chrome:${TAG} \ --platform linux/amd64,linux/arm64 \ --push \ . ================================================ FILE: examples/chrome/chrome.sh ================================================ #!/bin/bash # Copyright 2025 Alibaba Group Holding Ltd. # # 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. set -euo pipefail # There are a lot of interesting flags we could use here: https://github.com/microsoft/playwright/blob/20023ab33a1dc04db2d5a3f753760eef33339e73/packages/playwright-core/src/server/chromium/chromiumSwitches.ts#L47 flags=() flags+=(--no-sandbox) # We can't use sandbox in a container flags+=(--disable-gpu) # We don't (normally) have a GPU flags+=(--disable-dev-shm-usage) # We don't (normally) have a shared memory filesystem flags+=(--no-default-browser-check) # Avoids hanging with a "set chrome as default browser" dialog flags+=(--no-first-run) # Avoids hanging with a "set chrome as default browser" dialog flags+=(--start-maximized) # We're the only thing running, use the whole screen flags+=(--disable-field-trial-config) # Keeps things consistent and a little faster (?) flags+=(--remote-debugging-port=9222) # Enable remote debugging flags+=(--user-data-dir=/tmp/chrome-data) # DevTools remote debugging requires a non-default data directory. Specify this using --user-data-dir. # Launch Chrome exec chromium "${flags[@]}" "https://www.google.com" ================================================ FILE: examples/chrome/go.mod ================================================ module github.com/alibaba/opensandbox/chrome-box go 1.22 ================================================ FILE: examples/chrome/go.sum ================================================ ================================================ FILE: examples/chrome/main.go ================================================ // Copyright 2025 Alibaba Group Holding Ltd. // // 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. package main import ( "bytes" "context" "fmt" "io" "log" "net/http" "os" "os/exec" "strings" "time" ) func main() { ctx := context.Background() if err := run(ctx); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } } func run(ctx context.Context) error { ctx, cancel := context.WithCancel(ctx) defer cancel() vnc := &VNCServer{} errs := make(chan error, 10) go func() { if err := vnc.Run(ctx); err != nil { log.Println(err, "VNC server exited with error") errs <- fmt.Errorf("VNC server exited with error: %w", err) cancel() } }() if err := vnc.WaitForReady(ctx); err != nil { return fmt.Errorf("failed to wait for VNC server: %w", err) } chrome := &Chrome{} go func() { if err := chrome.Run(ctx); err != nil { log.Println(err, "Chrome exited with error") errs <- fmt.Errorf("Chrome exited with error: %w", err) cancel() } }() if err := chrome.WaitForReady(ctx); err != nil { return fmt.Errorf("failed to wait for Chrome: %w", err) } log.Println("Chrome and VNC server are running") <-ctx.Done() errs <- ctx.Err() // Return the first error (or nil)) return <-errs } type Chrome struct { } func (c *Chrome) Run(ctx context.Context) error { cmd := exec.CommandContext(ctx, "/chrome.sh") cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr var env []string for _, e := range os.Environ() { if strings.HasPrefix(e, "DISPLAY=") { continue } env = append(env, e) } env = append(env, "DISPLAY=:1") cmd.Env = env return cmd.Run() } func (c *Chrome) WaitForReady(ctx context.Context) error { u := "http://localhost:9222/json/version" httpClient := &http.Client{} httpClient.Timeout = 200 * time.Millisecond for { if ctx.Err() != nil { return ctx.Err() } req, err := http.NewRequestWithContext(ctx, "GET", u, nil) if err != nil { return fmt.Errorf("failed to create HTTP request: %w", err) } // Send the HTTP request response, err := httpClient.Do(req) if err != nil { log.Println("Waiting for Chrome to be ready", "url", u, "info", err) time.Sleep(100 * time.Millisecond) continue } defer response.Body.Close() // Check for HTTP 200 OK if response.StatusCode != http.StatusOK { log.Println("Waiting for Chrome to be ready", "url", u, "status", response.Status) time.Sleep(100 * time.Millisecond) continue } b, err := io.ReadAll(response.Body) if err != nil { log.Println("Waiting for Chrome to be ready", "url", u, "info", err) time.Sleep(100 * time.Millisecond) continue } log.Println("Chrome is ready", "url", u, "response", string(b)) break } return nil } type VNCServer struct { } func (v *VNCServer) Run(ctx context.Context) error { cmd := exec.CommandContext(ctx, "Xtigervnc", ":1", "-geometry", "1280x1024") cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr log.Println("Starting VNC server", "command", cmd.String()) if err := cmd.Start(); err != nil { return fmt.Errorf("failed to start VNC server: %w", err) } go func() { <-ctx.Done() if err := cmd.Process.Kill(); err != nil { log.Println(err, "failed to kill VNC server") } }() if err := cmd.Wait(); err != nil { return fmt.Errorf("VNC server exited with error: %w", err) } return nil } func (v *VNCServer) WaitForReady(ctx context.Context) error { for { if ctx.Err() != nil { return ctx.Err() } cmd := exec.CommandContext(ctx, "xdpyinfo", "-display", ":1") var stdout bytes.Buffer cmd.Stdout = &stdout var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { log.Println("Waiting for VNC server to be ready", "info", err) time.Sleep(100 * time.Millisecond) continue } log.Println("VNC is ready") break } return nil } ================================================ FILE: examples/chrome/main.py ================================================ # Copyright 2025 Alibaba Group Holding Ltd. # # 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. import asyncio from datetime import timedelta from opensandbox.sandbox import Sandbox from opensandbox.config import ConnectionConfig from opensandbox.exceptions import SandboxException async def main(): try: sandbox = await Sandbox.create( image="opensandbox/chrome:latest", timeout=timedelta(minutes=5), entrypoint=["/entrypoint"], metadata={"examples.opensandbox.io": "chrome"}, connection_config=ConnectionConfig( domain="localhost:8080" ) ) # Got execd process endpoint execd = await sandbox.get_endpoint(44772) print(f"execd daemon running with {execd.endpoint}") vnc = await sandbox.get_endpoint(5901) print(f"VNC running with {vnc.endpoint}") devtools = await sandbox.get_endpoint(9222) print(f"DevTools running with {devtools.endpoint}/json") except SandboxException as e: # Handle Sandbox specific exceptions print(f"Sandbox Error: [{e.error.code}] {e.error.message}") except Exception as e: print(f"Error: {e}") if __name__ == "__main__": asyncio.run(main()) ================================================ FILE: examples/claude-code/README.md ================================================ # Claude Code Example Access Claude via the `claude-cli` npm package in OpenSandbox. ## Start OpenSandbox server [local] Pre-pull the code-interpreter image (includes Node.js): ```shell docker pull sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.2 # use docker hub # docker pull opensandbox/code-interpreter:v1.0.2 ``` Then start the local OpenSandbox server, stdout logs will be visible in the terminal: ```shell uv pip install opensandbox-server opensandbox-server init-config ~/.sandbox.toml --example docker opensandbox-server ``` ## Create and Access the Claude Sandbox ```shell # Install OpenSandbox package uv pip install opensandbox # Run the example (requires SANDBOX_DOMAIN / SANDBOX_API_KEY / ANTHROPIC_AUTH_TOKEN) uv run python examples/claude-code/main.py ``` The script installs the Claude CLI (`npm i -g @anthropic-ai/claude-code@latest`) at runtime (Node.js is already in the code-interpreter image), then sends a simple request `claude "Compute 1+1=?."`. Auth is passed via `ANTHROPIC_AUTH_TOKEN`, and you can override endpoint/model with `ANTHROPIC_BASE_URL` / `ANTHROPIC_MODEL`. ![Claude Code screenshot](./screenshot.jpg) ## Environment Variables - `SANDBOX_DOMAIN`: Sandbox service address (default: `localhost:8080`) - `SANDBOX_API_KEY`: API key if your server requires authentication (optional for local) - `SANDBOX_IMAGE`: Sandbox image to use (default: `sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.2`) - `ANTHROPIC_AUTH_TOKEN`: Your Anthropic auth token (required) - `ANTHROPIC_BASE_URL`: Anthropic API endpoint (optional; e.g., self-hosted proxy) - `ANTHROPIC_MODEL`: Model name (default: `claude_sonnet4`) ## References - [claude-code](https://www.npmjs.com/package/claude-code) - NPM package for Claude Code CLI ================================================ FILE: examples/claude-code/main.py ================================================ # Copyright 2025 Alibaba Group Holding Ltd. # # 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. import asyncio import os from datetime import timedelta from opensandbox import Sandbox from opensandbox.config import ConnectionConfig def _required_env(name: str) -> str: value = os.getenv(name) if not value: raise RuntimeError(f"{name} is required") return value async def _print_execution_logs(execution) -> None: for msg in execution.logs.stdout: print(f"[stdout] {msg.text}") for msg in execution.logs.stderr: print(f"[stderr] {msg.text}") if execution.error: print(f"[error] {execution.error.name}: {execution.error.value}") async def main() -> None: domain = os.getenv("SANDBOX_DOMAIN", "localhost:8080") api_key = os.getenv("SANDBOX_API_KEY") claude_auth_token = _required_env("ANTHROPIC_AUTH_TOKEN") claude_base_url = os.getenv("ANTHROPIC_BASE_URL") claude_model_name = os.getenv("ANTHROPIC_MODEL", "claude_sonnet4") image = os.getenv( "SANDBOX_IMAGE", "sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.2", ) config = ConnectionConfig( domain=domain, api_key=api_key, request_timeout=timedelta(seconds=60), ) # Inject Claude settings into container environment for CLI access env = { "ANTHROPIC_AUTH_TOKEN": claude_auth_token, "ANTHROPIC_BASE_URL": claude_base_url, "ANTHROPIC_MODEL": claude_model_name, "IS_SANDBOX": "1", } # Drop None values to avoid overriding defaults inside CLI env = {k: v for k, v in env.items() if v is not None} sandbox = await Sandbox.create( image, connection_config=config, env=env, ) async with sandbox: # Install Claude CLI (Node.js is already in the code-interpreter image) install_exec = await sandbox.commands.run( "npm i -g @anthropic-ai/claude-code@latest" ) await _print_execution_logs(install_exec) # Use Claude CLI to send a message run_exec = await sandbox.commands.run( 'claude "Compute 1+1=?."' ) await _print_execution_logs(run_exec) await sandbox.kill() if __name__ == "__main__": asyncio.run(main()) ================================================ FILE: examples/code-interpreter/README.md ================================================ # Code Interpreter Sandbox Complete demonstration of running Python code using the Code Interpreter SDK. ## Getting Code Interpreter image Pull the prebuilt image from a registry: ```shell docker pull sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.2 # use docker hub # docker pull opensandbox/code-interpreter:v1.0.2 ``` ## Start OpenSandbox server [local] Start the local OpenSandbox server: ```shell uv pip install opensandbox-server opensandbox-server init-config ~/.sandbox.toml --example docker opensandbox-server ``` ## Create and access the Code Interpreter Sandbox ```shell # Install OpenSandbox packages uv pip install opensandbox opensandbox-code-interpreter # Run the example (requires SANDBOX_DOMAIN / SANDBOX_API_KEY) uv run python examples/code-interpreter/main.py ``` The script creates a Sandbox + CodeInterpreter, runs a Python code snippet and prints stdout/result, then terminates the remote instance. ## Environment variables - `SANDBOX_DOMAIN`: Sandbox service address (default: `localhost:8080`) - `SANDBOX_API_KEY`: API key if your server requires authentication - `SANDBOX_IMAGE`: Sandbox image to use (default: `sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.2`) ## Example output ```text === Python example === [Python stdout] Hello from Python! [Python result] {'py': '3.14.2', 'sum': 4} === Java example === [Java stdout] Hello from Java! [Java stdout] 2 + 3 = 5 [Java result] 5 === Go example === [Go stdout] Hello from Go! 3 + 4 = 7 === TypeScript example === [TypeScript stdout] Hello from TypeScript! [TypeScript stdout] sum = 6 ``` # Code Interpreter Sandbox from pool ## Start OpenSandbox server [k8s] Install the k8s OpenSandbox operator, and create a pool: ```yaml apiVersion: sandbox.opensandbox.io/v1alpha1 kind: Pool metadata: labels: app.kubernetes.io/name: sandbox-k8s app.kubernetes.io/managed-by: kustomize name: pool-sample namespace: opensandbox spec: template: metadata: labels: app: example spec: volumes: - name: sandbox-storage emptyDir: { } - name: opensandbox-bin emptyDir: { } initContainers: - name: task-executor-installer image: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/task-executor:v0.1.0 command: [ "/bin/sh", "-c" ] args: - | cp /workspace/server /opt/opensandbox/bin/task-executor && chmod +x /opt/opensandbox/bin/task-executor volumeMounts: - name: opensandbox-bin mountPath: /opt/opensandbox/bin - name: execd-installer image: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:v1.0.7 command: [ "/bin/sh", "-c" ] args: - | cp ./execd /opt/opensandbox/bin/execd && cp ./bootstrap.sh /opt/opensandbox/bin/bootstrap.sh && chmod +x /opt/opensandbox/bin/execd && chmod +x /opt/opensandbox/bin/bootstrap.sh volumeMounts: - name: opensandbox-bin mountPath: /opt/opensandbox/bin containers: - name: sandbox image: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.2 command: - "/bin/sh" - "-c" - | /opt/opensandbox/bin/task-executor -listen-addr=0.0.0.0:5758 >/tmp/task-executor.log 2>&1 env: - name: SANDBOX_MAIN_CONTAINER value: main - name: EXECD_ENVS value: /opt/opensandbox/.env - name: EXECD value: /opt/opensandbox/bin/execd volumeMounts: - name: sandbox-storage mountPath: /var/lib/sandbox - name: opensandbox-bin mountPath: /opt/opensandbox/bin tolerations: - operator: "Exists" capacitySpec: bufferMax: 3 bufferMin: 1 poolMax: 5 poolMin: 0 ``` Start the k8s OpenSandbox server: ```shell uv pip install opensandbox-server # replace with your k8s cluster config, kubeconfig etc. opensandbox-server init-config ~/.sandbox.toml --example k8s curl -o ~/batchsandbox-template.yaml https://raw.githubusercontent.com/alibaba/OpenSandbox/main/server/example.batchsandbox-template.yaml opensandbox-server ``` ## Create and access the Code Interpreter Sandbox ```shell # Install OpenSandbox packages uv pip install opensandbox opensandbox-code-interpreter # Run the example (requires SANDBOX_DOMAIN / SANDBOX_API_KEY) uv run python examples/code-interpreter/main_use_pool.py ``` The script creates a Sandbox + CodeInterpreter, runs a Python code snippet and prints stdout/result, then terminates the remote instance. ## Environment variables - `SANDBOX_DOMAIN`: Sandbox service address (default: `localhost:8080`) - `SANDBOX_API_KEY`: API key if your server requires authentication - `SANDBOX_IMAGE`: Sandbox image to use (default: `sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.2`) ## Example output ```text === Verify Environment Variable === [ENV Check] TEST_ENV value: test [ENV Result] 'test' === Java example === [Java stdout] Hello from Java! [Java stdout] 2 + 3 = 5 [Java result] 5 === Go example === [Go stdout] Hello from Go! 3 + 4 = 7 === TypeScript example === [TypeScript stdout] Hello from TypeScript! [TypeScript stdout] sum = 6 ``` ================================================ FILE: examples/code-interpreter/main.py ================================================ # Copyright 2025 Alibaba Group Holding Ltd. # # 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. import asyncio import os from datetime import timedelta from code_interpreter import CodeInterpreter, SupportedLanguage from opensandbox import Sandbox from opensandbox.config import ConnectionConfig async def main() -> None: domain = os.getenv("SANDBOX_DOMAIN", "localhost:8080") api_key = os.getenv("SANDBOX_API_KEY") image = os.getenv( "SANDBOX_IMAGE", "sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.2", ) config = ConnectionConfig( domain=domain, api_key=api_key, request_timeout=timedelta(seconds=60), ) sandbox = await Sandbox.create( image, connection_config=config, entrypoint=["/opt/opensandbox/code-interpreter.sh"] ) async with sandbox: interpreter = await CodeInterpreter.create(sandbox=sandbox) # Python example: show runtime info and return a simple calculation. py_exec = await interpreter.codes.run( "import platform\n" "print('Hello from Python!')\n" "result = {'py': platform.python_version(), 'sum': 2 + 2}\n" "result", language=SupportedLanguage.PYTHON, ) print("\n=== Python example ===") for msg in py_exec.logs.stdout: print(f"[Python stdout] {msg.text}") if py_exec.result: for res in py_exec.result: print(f"[Python result] {res.text}") # Java example: print to stdout and return the final result line. java_exec = await interpreter.codes.run( "System.out.println(\"Hello from Java!\");\n" "int result = 2 + 3;\n" "System.out.println(\"2 + 3 = \" + result);\n" "result", language=SupportedLanguage.JAVA, ) print("\n=== Java example ===") for msg in java_exec.logs.stdout: print(f"[Java stdout] {msg.text}") if java_exec.result: for res in java_exec.result: print(f"[Java result] {res.text}") if java_exec.error: print(f"[Java error] {java_exec.error.name}: {java_exec.error.value}") # Go example: print logs and demonstrate a main function structure. go_exec = await interpreter.codes.run( "package main\n" "import \"fmt\"\n" "func main() {\n" " fmt.Println(\"Hello from Go!\")\n" " sum := 3 + 4\n" " fmt.Println(\"3 + 4 =\", sum)\n" "}", language=SupportedLanguage.GO, ) print("\n=== Go example ===") for msg in go_exec.logs.stdout: print(f"[Go stdout] {msg.text}") if go_exec.error: print(f"[Go error] {go_exec.error.name}: {go_exec.error.value}") # TypeScript example: use typing and sum an array. ts_exec = await interpreter.codes.run( "console.log('Hello from TypeScript!');\n" "const nums: number[] = [1, 2, 3];\n" "console.log('sum =', nums.reduce((a, b) => a + b, 0));", language=SupportedLanguage.TYPESCRIPT, ) print("\n=== TypeScript example ===") for msg in ts_exec.logs.stdout: print(f"[TypeScript stdout] {msg.text}") if ts_exec.error: print(f"[TypeScript error] {ts_exec.error.name}: {ts_exec.error.value}") await sandbox.kill() if __name__ == "__main__": asyncio.run(main()) ================================================ FILE: examples/code-interpreter/main_use_pool.py ================================================ # Copyright 2025 Alibaba Group Holding Ltd. # # 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. import asyncio import os from datetime import timedelta from code_interpreter import CodeInterpreter, SupportedLanguage from opensandbox import Sandbox from opensandbox.config import ConnectionConfig async def main() -> None: domain = os.getenv("SANDBOX_DOMAIN", "localhost:8080") api_key = os.getenv("SANDBOX_API_KEY") image = os.getenv( "SANDBOX_IMAGE", "sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.2", ) config = ConnectionConfig( domain=domain, api_key=api_key, request_timeout=timedelta(seconds=60), ) sandbox = await Sandbox.create( image, connection_config=config, extensions={"poolRef":"pool-sample"}, entrypoint=["/opt/opensandbox/code-interpreter.sh"], env={ "TEST_ENV": "test", }, ) async with sandbox: interpreter = await CodeInterpreter.create(sandbox=sandbox) # Verify environment variable is set print("\n=== Verify Environment Variable ===") env_check = await interpreter.codes.run( "import os\n" "test_env = os.getenv('TEST_ENV', 'NOT_SET')\n" "print(f'TEST_ENV value: {test_env}')\n" "test_env", language=SupportedLanguage.PYTHON, ) for msg in env_check.logs.stdout: print(f"[ENV Check] {msg.text}") if env_check.result: for res in env_check.result: print(f"[ENV Result] {res.text}") # Java example: print to stdout and return the final result line. java_exec = await interpreter.codes.run( "System.out.println(\"Hello from Java!\");\n" "int result = 2 + 3;\n" "System.out.println(\"2 + 3 = \" + result);\n" "result", language=SupportedLanguage.JAVA, ) print("\n=== Java example ===") for msg in java_exec.logs.stdout: print(f"[Java stdout] {msg.text}") if java_exec.result: for res in java_exec.result: print(f"[Java result] {res.text}") if java_exec.error: print(f"[Java error] {java_exec.error.name}: {java_exec.error.value}") # Go example: print logs and demonstrate a main function structure. go_exec = await interpreter.codes.run( "package main\n" "import \"fmt\"\n" "func main() {\n" " fmt.Println(\"Hello from Go!\")\n" " sum := 3 + 4\n" " fmt.Println(\"3 + 4 =\", sum)\n" "}", language=SupportedLanguage.GO, ) print("\n=== Go example ===") for msg in go_exec.logs.stdout: print(f"[Go stdout] {msg.text}") if go_exec.error: print(f"[Go error] {go_exec.error.name}: {go_exec.error.value}") # TypeScript example: use typing and sum an array. ts_exec = await interpreter.codes.run( "console.log('Hello from TypeScript!');\n" "const nums: number[] = [1, 2, 3];\n" "console.log('sum =', nums.reduce((a, b) => a + b, 0));", language=SupportedLanguage.TYPESCRIPT, ) print("\n=== TypeScript example ===") for msg in ts_exec.logs.stdout: print(f"[TypeScript stdout] {msg.text}") if ts_exec.error: print(f"[TypeScript error] {ts_exec.error.name}: {ts_exec.error.value}") await sandbox.kill() if __name__ == "__main__": asyncio.run(main()) ================================================ FILE: examples/codex-cli/README.md ================================================ # Codex/OpenAI CLI Example Use the official `@openai/codex` npm package to call OpenAI/Codex-like models in OpenSandbox. ## Start OpenSandbox server [local] Pre-pull the code-interpreter image (includes Node.js): ```shell docker pull sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.2 # use docker hub # docker pull opensandbox/code-interpreter:v1.0.2 ``` Start the local OpenSandbox server, logs will be visible in the terminal: ```shell uv pip install opensandbox-server opensandbox-server init-config ~/.sandbox.toml --example docker opensandbox-server ``` ## Create and Access the Codex Sandbox ```shell # Install OpenSandbox package uv pip install opensandbox # Run the example (requires SANDBOX_DOMAIN / SANDBOX_API_KEY / OPENAI_API_KEY) uv run python examples/codex-cli/main.py ``` The script installs the Codex CLI (`npm install -g @openai/codex@latest`) at runtime (Node.js is already in the code-interpreter image), then executes a simple request `codex exec "Compute 1+1 and return JSON with keys result and reasoning." --skip-git-repo-check`. Auth is passed via `OPENAI_API_KEY`; you can override endpoint/model with `OPENAI_BASE_URL` / `OPENAI_MODEL`. ## Environment Variables - `SANDBOX_DOMAIN`: Sandbox service address (default: `localhost:8080`) - `SANDBOX_API_KEY`: API key if your server requires authentication (optional for local) - `SANDBOX_IMAGE`: Sandbox image to use (default: `sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.2`) - `OPENAI_API_KEY`: Your OpenAI API key (required) - `OPENAI_BASE_URL`: OpenAI API endpoint (default: `https://api.openai.com/v1`) - `OPENAI_MODEL`: Model to use (default: `gpt-4o-mini`) ## References - [@openai/codex](https://www.npmjs.com/package/@openai/codex) - Official OpenAI Codex CLI ================================================ FILE: examples/codex-cli/main.py ================================================ # Copyright 2025 Alibaba Group Holding Ltd. # # 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. import asyncio import os from datetime import timedelta from opensandbox import Sandbox from opensandbox.config import ConnectionConfig def _required_env(name: str) -> str: value = os.getenv(name) if not value: raise RuntimeError(f"{name} is required") return value async def _print_execution_logs(execution) -> None: for msg in execution.logs.stdout: print(f"[stdout] {msg.text}") for msg in execution.logs.stderr: print(f"[stderr] {msg.text}") if execution.error: print(f"[error] {execution.error.name}: {execution.error.value}") async def main() -> None: domain = os.getenv("SANDBOX_DOMAIN", "localhost:8080") api_key = os.getenv("SANDBOX_API_KEY") openai_api_key = _required_env("OPENAI_API_KEY") openai_base_url = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1") openai_model = os.getenv("OPENAI_MODEL", "gpt-4o-mini") image = os.getenv( "SANDBOX_IMAGE", "sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.2", ) config = ConnectionConfig( domain=domain, api_key=api_key, request_timeout=timedelta(seconds=60), ) # Inject OpenAI settings into container environment for CLI access env = { "OPENAI_API_KEY": openai_api_key, "OPENAI_BASE_URL": openai_base_url, "OPENAI_MODEL": openai_model, } # Drop None values to avoid overriding defaults inside CLI env = {k: v for k, v in env.items() if v is not None} sandbox = await Sandbox.create( image, connection_config=config, env=env, ) async with sandbox: # Install Codex CLI (Node.js is already in the code-interpreter image) install_exec = await sandbox.commands.run( "npm install -g @openai/codex@latest" ) await _print_execution_logs(install_exec) # Use Codex CLI to execute a command run_exec = await sandbox.commands.run( 'codex exec "Compute 1+1=?." --skip-git-repo-check' ) await _print_execution_logs(run_exec) await sandbox.kill() if __name__ == "__main__": asyncio.run(main()) ================================================ FILE: examples/desktop/Dockerfile ================================================ # Copyright 2025 Alibaba Group Holding Ltd. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. FROM ubuntu:22.04 # Default to English; install locale support first, then other deps ENV DEBIAN_FRONTEND=noninteractive \ LANG=en_US.UTF-8 \ LC_ALL=en_US.UTF-8 \ LANGUAGE=en_US:en #---------------------- # Install locales first, then remaining dependencies RUN apt-get update \ && apt-get install -y locales \ && locale-gen en_US.UTF-8 \ && update-locale LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 LANGUAGE=en_US:en \ && apt-get install -y \ python3 \ python3-pip \ python3-websockify \ xvfb \ x11vnc \ xfce4 \ xfce4-terminal \ dbus-x11 \ xterm \ novnc \ fonts-dejavu-core \ net-tools \ ca-certificates \ --no-install-recommends \ && sed -i 's/DEFAULT_LOCALE = null;/DEFAULT_LOCALE = "en";/' /usr/share/novnc/app/localization.js \ && rm -rf /var/lib/apt/lists/* # Precreate X11 stuff RUN mkdir -p /tmp/.X11-unix RUN chmod 1777 /tmp/.X11-unix #---------------------- # Create a non-root user RUN groupadd -r desktop && useradd -r -g desktop -G audio,video desktop \ && mkdir -p /home/desktop && chown -R desktop:desktop /home/desktop #---------------------- # Configure user, etc WORKDIR /home/desktop USER desktop # Default to bash CMD ["bash"] ================================================ FILE: examples/desktop/README.md ================================================ # Desktop(VNC) Example Launch Xvfb + x11vnc + fluxbox in OpenSandbox to provide a VNC-accessible desktop environment. ## Build the Desktop Sandbox Image The Dockerfile in this directory builds a sandbox image with desktop and VNC components pre-installed: ```shell cd examples/desktop docker build -t opensandbox/desktop:latest . ``` This image includes: - Xvfb (virtual framebuffer X server) - x11vnc (VNC server) - XFCE desktop (panel, file manager, terminal) - Non-root user (desktop) for security ## Start OpenSandbox server [local] Pre-pull the desktop image: ```shell docker pull opensandbox/desktop:latest ``` Start the local OpenSandbox server: ```shell uv pip install opensandbox-server opensandbox-server init-config ~/.sandbox.toml --example docker opensandbox-server ``` ## Create and Access the Desktop Sandbox ```shell # Install OpenSandbox package uv pip install opensandbox uv run python examples/desktop/main.py ``` The script starts the desktop stack (Xvfb + XFCE + x11vnc) and also launches noVNC/websockify. It prints: - VNC endpoint (`endpoint.endpoint`) for native VNC clients, password from `VNC_PASSWORD` (default: `opensandbox`) - noVNC URL for browsers (`/vnc.html?host=...&port=...&path=...`) The sandbox stays alive for 5 minutes by default; interrupt sooner with Ctrl+C. Uses the prebuilt desktop image by default. ![Desktop shell](./screenshot_shell.jpg) ![noVNC connect](./screenshot_connect.jpg) ![noVNC password](./screenshot_password.jpg) ![Desktop UI](./screenshot_desktop.jpg) ## Environment Variables - `SANDBOX_DOMAIN`: Sandbox service address (default: `localhost:8080`) - `SANDBOX_API_KEY`: API key if your server requires authentication (optional for local) - `SANDBOX_IMAGE`: Sandbox image to use (default: `opensandbox/desktop:latest`) - `VNC_PASSWORD`: Password for VNC access (default: `opensandbox`) ## References - [noVNC](https://github.com/novnc/noVNC) - [x11vnc](https://github.com/LibVNC/x11vnc) ================================================ FILE: examples/desktop/build.sh ================================================ #!/bin/bash # Copyright 2025 Alibaba Group Holding Ltd. # # 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. set -ex TAG=${TAG:-latest} docker buildx rm desktop-builder || true docker buildx create --use --name desktop-builder docker buildx inspect --bootstrap docker buildx ls docker buildx build \ -t opensandbox/desktop:${TAG} \ -t sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/desktop:${TAG} \ --platform linux/amd64,linux/arm64 \ --push \ . ================================================ FILE: examples/desktop/main.py ================================================ # Copyright 2025 Alibaba Group Holding Ltd. # # 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. import asyncio import os from datetime import timedelta from opensandbox import Sandbox from opensandbox.config import ConnectionConfig from opensandbox.models.execd import RunCommandOpts def _required_env(name: str) -> str: value = os.getenv(name) if not value: raise RuntimeError(f"{name} is required") return value async def _print_logs(label: str, execution) -> None: for msg in execution.logs.stdout: print(f"[{label} stdout] {msg.text}") for msg in execution.logs.stderr: print(f"[{label} stderr] {msg.text}") if execution.error: print(f"[{label} error] {execution.error.name}: {execution.error.value}") async def main() -> None: domain = os.getenv("SANDBOX_DOMAIN", "localhost:8080") api_key = os.getenv("SANDBOX_API_KEY") image = os.getenv( "SANDBOX_IMAGE", "opensandbox/desktop:latest", ) python_version = os.getenv("PYTHON_VERSION", "3.11") vnc_password = os.getenv("VNC_PASSWORD", "opensandbox") config = ConnectionConfig( domain=domain, api_key=api_key, request_timeout=timedelta(seconds=60), ) sandbox = await Sandbox.create( image, connection_config=config, env={ "PYTHON_VERSION": python_version, "VNC_PASSWORD": vnc_password, }, ) async with sandbox: # Desktop and VNC components are pre-installed in the image, just start them # Start virtual display, window manager, and VNC server (in background) xvfb_exec = await sandbox.commands.run( "Xvfb :0 -screen 0 1280x800x24", opts=RunCommandOpts(background=True), ) await _print_logs("xvfb", xvfb_exec) # Start XFCE session (provides panel, file manager, terminal) xfce_exec = await sandbox.commands.run( "DISPLAY=:0 dbus-launch startxfce4", opts=RunCommandOpts(background=True), ) await _print_logs("xfce", xfce_exec) vnc_exec = await sandbox.commands.run( "x11vnc -display :0 " "-passwd \"$VNC_PASSWORD\" " "-forever -shared -rfbport 5900", opts=RunCommandOpts(background=True), ) await _print_logs("x11vnc", vnc_exec) # Start noVNC/websockify to expose VNC over WebSocket/HTTP novnc_exec = await sandbox.commands.run( "/usr/bin/websockify --web=/usr/share/novnc 6080 localhost:5900", opts=RunCommandOpts(background=True), ) await _print_logs("novnc", novnc_exec) endpoint_vnc = await sandbox.get_endpoint(5900) endpoint_novnc = await sandbox.get_endpoint(6080) # Build noVNC URL with host/port/path for routed endpoint, e.g., host:port/proxy/6080 novnc_host_port, novnc_path = endpoint_novnc.endpoint.split("/", 1) novnc_host, novnc_port = novnc_host_port.split(":") novnc_url = ( f"http://{endpoint_novnc.endpoint}/vnc.html" f"?host={novnc_host}&port={novnc_port}&path={novnc_path}" ) print("\nVNC endpoint (native clients):") print(f" {endpoint_vnc.endpoint}") print(f"Password: {vnc_password}") print("\nnoVNC (browser):") print(f" {novnc_url}") print(f"Password: {vnc_password}") print("\nKeeping sandbox alive for 5 minutes. Press Ctrl+C to exit sooner.") try: await asyncio.sleep(300) except KeyboardInterrupt: print("Stopping...") finally: await sandbox.kill() if __name__ == "__main__": asyncio.run(main()) ================================================ FILE: examples/docker-ossfs-volume-mount/README.md ================================================ # Docker OSSFS Volume Mount Example This example demonstrates how to use the new SDK `ossfs` volume model to mount Alibaba Cloud OSS into sandboxes on Docker runtime. ## What this example covers 1. **Basic read-write mount** on an OSSFS backend. 2. **Cross-sandbox sharing** on the same OSSFS backend path. 3. **Two mounts, different OSS prefixes via `subPath`**. ## Prerequisites ### 1) Start OpenSandbox server (Docker runtime) Make sure your server host has: - Linux host OS (OSSFS backend is not supported when OpenSandbox Server runs on Windows) - `ossfs` installed - FUSE support enabled - writable local mount root for OSSFS (default `storage.ossfs_mount_root=/mnt/ossfs`) `storage.ossfs_mount_root` is **optional** if you use the default `/mnt/ossfs`. Even with on-demand mounting, the runtime still needs a deterministic host-side base directory to place dynamic mounts (`//`). Optional config example: ```toml [runtime] type = "docker" [storage] ossfs_mount_root = "/mnt/ossfs" ``` Then start the server: ```bash opensandbox-server ``` ### 2) Install Python SDK ```bash uv pip install opensandbox ``` If your PyPI version does not include OSSFS volume models yet, install from source: ```bash pip install -e sdks/sandbox/python ``` ### 3) Prepare OSS credentials and target path ```bash export SANDBOX_DOMAIN=localhost:8080 export SANDBOX_API_KEY=your-api-key export SANDBOX_IMAGE=ubuntu export OSS_BUCKET=your-bucket export OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com export OSS_ACCESS_KEY_ID=your-ak export OSS_ACCESS_KEY_SECRET=your-sk ``` ## Run ```bash uv run python examples/docker-ossfs-volume-mount/main.py ``` ## Minimal SDK usage snippet ```python from opensandbox import Sandbox from opensandbox.models.sandboxes import OSSFS, Volume sandbox = await Sandbox.create( image="ubuntu", volumes=[ Volume( name="oss-data", ossfs=OSSFS( bucket="your-bucket", endpoint="oss-cn-hangzhou.aliyuncs.com", # version="2.0", # optional, default is "2.0" accessKeyId="your-ak", accessKeySecret="your-sk", ), mountPath="/mnt/data", subPath="train", # optional readOnly=False, # optional ) ], ) ``` ## Notes - Current implementation supports **inline credentials only** (`accessKeyId`/`accessKeySecret`). - Mounting is **on-demand** in Docker runtime (mount-or-reuse), not pre-mounted for all buckets. - `ossfs.version` exists in API/SDK with enum `"1.0" | "2.0"`, and defaults to `"2.0"` when omitted. - Docker runtime now applies **version-specific mount argument encoding**: - `1.0`: mounts via `ossfs ... -o