From ec0dfc9bc0d9615b8ab0ad4d073b21d3b63edbc8 Mon Sep 17 00:00:00 2001 From: yuanhe Date: Sat, 11 Jul 2026 22:39:43 +0800 Subject: [PATCH] fix: enable local sandbox runtime --- apps/web/src/i18n/locales/en-US/admin.json | 4 ++ apps/web/src/i18n/locales/zh-CN/admin.json | 4 ++ apps/web/src/lib/api-runtime.ts | 1 + .../web/src/pages/admin/CreateAgentDialog.tsx | 37 ++++++++++++------- docs/openapi/openapi.yaml | 5 +++ scripts/with-dev-env.sh | 2 + server/cmd/server/main.go | 1 + server/cmd/server/sandbox_docker.go | 10 +++++ server/cmd/server/sandbox_docker_test.go | 18 +++++++++ server/internal/dev/runtime_status.go | 8 +++- server/internal/dev/runtime_status_test.go | 23 +++++++++++- 11 files changed, 98 insertions(+), 15 deletions(-) diff --git a/apps/web/src/i18n/locales/en-US/admin.json b/apps/web/src/i18n/locales/en-US/admin.json index f111cd1..af89953 100644 --- a/apps/web/src/i18n/locales/en-US/admin.json +++ b/apps/web/src/i18n/locales/en-US/admin.json @@ -1400,6 +1400,7 @@ "agentEngine": "Agent engine", "device": "Local device", "sandboxSize": "Sandbox size", + "sandboxImage": "Sandbox image", "workDir": "Working directory (optional)" }, "placeholders": { @@ -1428,6 +1429,9 @@ "standard": "Standard (2 vCPU / 4 GiB)", "xl": "Large (4 vCPU / 8 GiB)" }, + "sandboxImage": { + "hint": "The server pulls this container image when the Agent's sandbox starts for the first time." + }, "emptyModel": { "title": "No usable Model yet", "description": "Go to Models to onboard one. The fields you typed here will be carried over so you can come back.", diff --git a/apps/web/src/i18n/locales/zh-CN/admin.json b/apps/web/src/i18n/locales/zh-CN/admin.json index 00ad8eb..ea0086d 100644 --- a/apps/web/src/i18n/locales/zh-CN/admin.json +++ b/apps/web/src/i18n/locales/zh-CN/admin.json @@ -1400,6 +1400,7 @@ "agentEngine": "Agent 引擎", "device": "本地设备", "sandboxSize": "沙盒规格", + "sandboxImage": "沙盒镜像", "workDir": "工作目录(可选)" }, "placeholders": { @@ -1428,6 +1429,9 @@ "standard": "标准 (2 vCPU / 4 GiB)", "xl": "大规格 (4 vCPU / 8 GiB)" }, + "sandboxImage": { + "hint": "Agent 首次启动沙盒时,服务端会拉取这个容器镜像。" + }, "emptyModel": { "title": "还没有可用 Model", "description": "先去 Models 页接入一个模型;当前已填内容会带过去,配完后可以回到新建 Agent。", diff --git a/apps/web/src/lib/api-runtime.ts b/apps/web/src/lib/api-runtime.ts index 4200e6d..af354dd 100644 --- a/apps/web/src/lib/api-runtime.ts +++ b/apps/web/src/lib/api-runtime.ts @@ -32,6 +32,7 @@ export interface RuntimeStatus { sandbox_agent_count: number profile: RuntimeProfile configured_by?: RuntimeConfiguredBy + sandbox_image?: string } const KEY_RUNTIME_STATUS = (workspaceID: string) => diff --git a/apps/web/src/pages/admin/CreateAgentDialog.tsx b/apps/web/src/pages/admin/CreateAgentDialog.tsx index 455a5dc..e45edb8 100644 --- a/apps/web/src/pages/admin/CreateAgentDialog.tsx +++ b/apps/web/src/pages/admin/CreateAgentDialog.tsx @@ -20,6 +20,7 @@ import { ApiError } from "../../lib/api-client" import { useCapabilitiesQuery, aggregateRequiredCredentials, aggregateRequiredCredentialsByID, useCapabilityVersionsQuery, useAgentCapabilitiesQuery, useEnableAgentCapabilityMutation } from "../../lib/api-capabilities" import { CredentialCheckPanel } from "../../components/admin/CredentialCheckPanel" import { useSecrets } from "../../lib/api-secrets" +import { useRuntimeStatus } from "../../lib/api-runtime" import type { UpdateAgentProfileRequest } from "../../lib/api-agents" import type { AgentInlineNewSecret, @@ -251,6 +252,7 @@ export function CreateAgentDialog({ onOpenChange, onSubmit, }: CreateAgentDialogProps) { + const runtimeStatus = useRuntimeStatus(open ? workspaceID : null) const { t } = useTranslation("admin") const { t: tc } = useTranslation("common") const queryClient = useQueryClient() @@ -1079,20 +1081,29 @@ export function CreateAgentDialog({ )} {connector === "agent_daemon" && executionMode === "sandbox" && ( - - - + + + {runtimeStatus.data?.sandbox_image ? ( + + + {runtimeStatus.data.sandbox_image} + + + ) : null} + )} ) : ( diff --git a/docs/openapi/openapi.yaml b/docs/openapi/openapi.yaml index 790e515..b123939 100644 --- a/docs/openapi/openapi.yaml +++ b/docs/openapi/openapi.yaml @@ -676,6 +676,11 @@ definitions: SandboxAgentCount is the number of agents in this workspace whose declared runtime is 'sandbox'. type: integer + sandbox_image: + description: |- + SandboxImage is the operator-configured container image used for + docker-backed sandbox agents. Empty for non-docker providers. + type: string type: object dev.sandboxConnectivityCheck: properties: diff --git a/scripts/with-dev-env.sh b/scripts/with-dev-env.sh index a84cf6b..7ff8f37 100755 --- a/scripts/with-dev-env.sh +++ b/scripts/with-dev-env.sh @@ -8,5 +8,7 @@ source "$ROOT_DIR/scripts/dev-env.sh" export DATABASE_URL="${DATABASE_URL:-$(parsar_dev_database_url)}" export PARSAR_MIGRATIONS_DIR="${PARSAR_MIGRATIONS_DIR:-$ROOT_DIR/server/migrations}" +export AGENT_DAEMON_SANDBOX_BACKEND="${AGENT_DAEMON_SANDBOX_BACKEND:-docker}" +export AGENT_DAEMON_SANDBOX_DOCKER_IMAGE="${AGENT_DAEMON_SANDBOX_DOCKER_IMAGE:-${PARSAR_SANDBOX_IMAGE:-ghcr.io/minimax-ai-dev/parsar-sandbox:latest}}" exec "$@" diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 1a9406d..f65ab0d 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -596,6 +596,7 @@ func main() { SandboxProber: runtimeStatusProber, Profile: runtimeProfile, ConfiguredByOps: strings.TrimSpace(envLookup("PARSAR_OPENCODE_RUNNER")) != "", + SandboxImage: configuredDockerSandboxImage(envLookup), } log.Bg().Info("runtime status profile configured", "profile", runtimeProfile, diff --git a/server/cmd/server/sandbox_docker.go b/server/cmd/server/sandbox_docker.go index 2938685..cff5cdf 100644 --- a/server/cmd/server/sandbox_docker.go +++ b/server/cmd/server/sandbox_docker.go @@ -164,6 +164,16 @@ func buildDockerAgentDaemonSandboxProvider( return provider } +func configuredDockerSandboxImage(env func(string) string) string { + if env == nil { + env = os.Getenv + } + if strings.TrimSpace(env("AGENT_DAEMON_SANDBOX_BACKEND")) != "docker" { + return "" + } + return strings.TrimSpace(env("AGENT_DAEMON_SANDBOX_DOCKER_IMAGE")) +} + // Built-in sandbox caps used when the operator sets no override. PidsLimit has // no default — a low pids cap breaks parallel builds (`make -j`, `go test ./...`). const ( diff --git a/server/cmd/server/sandbox_docker_test.go b/server/cmd/server/sandbox_docker_test.go index 5c5a186..4b000cc 100644 --- a/server/cmd/server/sandbox_docker_test.go +++ b/server/cmd/server/sandbox_docker_test.go @@ -179,3 +179,21 @@ func TestResolveAgentDaemonPublicWSURLNoRewriteWhenBackendNotDocker(t *testing.T t.Fatalf("resolveAgentDaemonPublicWSURL = %q, want %q", got, want) } } + +func TestConfiguredDockerSandboxImage(t *testing.T) { + env := dockerBackendEnv(map[string]string{ + "AGENT_DAEMON_SANDBOX_BACKEND": "docker", + "AGENT_DAEMON_SANDBOX_DOCKER_IMAGE": "example/sandbox:test", + }) + if got := configuredDockerSandboxImage(env); got != "example/sandbox:test" { + t.Fatalf("configuredDockerSandboxImage() = %q, want example/sandbox:test", got) + } + + env = dockerBackendEnv(map[string]string{ + "AGENT_DAEMON_SANDBOX_BACKEND": "e2b", + "AGENT_DAEMON_SANDBOX_DOCKER_IMAGE": "example/sandbox:test", + }) + if got := configuredDockerSandboxImage(env); got != "" { + t.Fatalf("configuredDockerSandboxImage() = %q for non-docker backend, want empty", got) + } +} diff --git a/server/internal/dev/runtime_status.go b/server/internal/dev/runtime_status.go index b6e0384..a0953b0 100644 --- a/server/internal/dev/runtime_status.go +++ b/server/internal/dev/runtime_status.go @@ -13,8 +13,8 @@ import ( "strings" "time" - "github.com/go-chi/chi/v5" "github.com/MiniMax-AI-Dev/parsar/server/internal/store" + "github.com/go-chi/chi/v5" ) // SandboxLivenessProber tests sandbox provider liveness. Implementations @@ -29,6 +29,7 @@ type RuntimeStatusDeps struct { SandboxProber SandboxLivenessProber Profile string ConfiguredByOps bool + SandboxImage string PingTimeout time.Duration } @@ -65,6 +66,10 @@ type runtimeStatusResponse struct { // ConfiguredBy is "ops" when PARSAR_OPENCODE_RUNNER was set at // server boot. Informational only — admin UI badge. ConfiguredBy string `json:"configured_by,omitempty"` + + // SandboxImage is the operator-configured container image used for + // docker-backed sandbox agents. Empty for non-docker providers. + SandboxImage string `json:"sandbox_image,omitempty"` } // runtimeStatus returns the workspace runtime status. 503 when no @@ -120,6 +125,7 @@ func runtimeStatus(deps RuntimeStatusDeps) http.HandlerFunc { Available: available, SandboxAgentCount: settings.SandboxAgentCount, Profile: profile, + SandboxImage: strings.TrimSpace(deps.SandboxImage), } if masked := strings.TrimSpace(settings.RuntimeCredentialMasked); masked != "" { resp.CredentialMasked = &masked diff --git a/server/internal/dev/runtime_status_test.go b/server/internal/dev/runtime_status_test.go index 996caaa..b83c78b 100644 --- a/server/internal/dev/runtime_status_test.go +++ b/server/internal/dev/runtime_status_test.go @@ -18,8 +18,8 @@ import ( "testing" "time" - "github.com/go-chi/chi/v5" "github.com/MiniMax-AI-Dev/parsar/server/internal/store" + "github.com/go-chi/chi/v5" ) // fakeSandboxProber satisfies SandboxLivenessProber. The default @@ -190,6 +190,27 @@ func TestRuntimeStatusNoCredential(t *testing.T) { } } +func TestRuntimeStatusIncludesSandboxImage(t *testing.T) { + r := newRuntimeStatusTestRouter(RuntimeStatusDeps{ + SettingsStore: fakeRuntimeSettingsStore{settings: store.WorkspaceRuntimeSettingsRead{}}, + SandboxImage: "ghcr.io/example/sandbox:test", + }) + + rec := httptest.NewRecorder() + r.ServeHTTP(rec, runtimeStatusRequest()) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String()) + } + var resp map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if got := resp["sandbox_image"]; got != "ghcr.io/example/sandbox:test" { + t.Errorf("sandbox_image: want configured image, got %v", got) + } +} + func TestRuntimeStatusManagedNoCredentialReachable(t *testing.T) { prober := &fakeSandboxProber{} r := newRuntimeStatusTestRouter(RuntimeStatusDeps{