Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/web/src/i18n/locales/en-US/admin.json
Original file line number Diff line number Diff line change
Expand Up @@ -1400,6 +1400,7 @@
"agentEngine": "Agent engine",
"device": "Local device",
"sandboxSize": "Sandbox size",
"sandboxImage": "Sandbox image",
"workDir": "Working directory (optional)"
},
"placeholders": {
Expand Down Expand Up @@ -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.",
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/i18n/locales/zh-CN/admin.json
Original file line number Diff line number Diff line change
Expand Up @@ -1400,6 +1400,7 @@
"agentEngine": "Agent 引擎",
"device": "本地设备",
"sandboxSize": "沙盒规格",
"sandboxImage": "沙盒镜像",
"workDir": "工作目录(可选)"
},
"placeholders": {
Expand Down Expand Up @@ -1428,6 +1429,9 @@
"standard": "标准 (2 vCPU / 4 GiB)",
"xl": "大规格 (4 vCPU / 8 GiB)"
},
"sandboxImage": {
"hint": "Agent 首次启动沙盒时,服务端会拉取这个容器镜像。"
},
"emptyModel": {
"title": "还没有可用 Model",
"description": "先去 Models 页接入一个模型;当前已填内容会带过去,配完后可以回到新建 Agent。",
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/lib/api-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
37 changes: 24 additions & 13 deletions apps/web/src/pages/admin/CreateAgentDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -1079,20 +1081,29 @@ export function CreateAgentDialog({
</Field>
)}
{connector === "agent_daemon" && executionMode === "sandbox" && (
<Field
label={t("agents.form.fields.sandboxSize")}
hint={t("agents.form.sandboxSize.hint")}
>
<select
value={sandboxSize}
onChange={(e) => setSandboxSize(e.target.value === "xl" ? "xl" : "standard")}
disabled={pending}
className="h-9 rounded-md border border-line bg-surface px-3 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-line-strong disabled:cursor-not-allowed disabled:bg-surface-subtle"
<div className="space-y-3">
<Field
label={t("agents.form.fields.sandboxSize")}
hint={t("agents.form.sandboxSize.hint")}
>
<option value="standard">{t("agents.form.sandboxSize.standard")}</option>
<option value="xl">{t("agents.form.sandboxSize.xl")}</option>
</select>
</Field>
<select
value={sandboxSize}
onChange={(e) => setSandboxSize(e.target.value === "xl" ? "xl" : "standard")}
disabled={pending}
className="h-9 rounded-md border border-line bg-surface px-3 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-line-strong disabled:cursor-not-allowed disabled:bg-surface-subtle"
>
<option value="standard">{t("agents.form.sandboxSize.standard")}</option>
<option value="xl">{t("agents.form.sandboxSize.xl")}</option>
</select>
</Field>
{runtimeStatus.data?.sandbox_image ? (
<Field label={t("agents.form.fields.sandboxImage")} hint={t("agents.form.sandboxImage.hint")}>
<code className="block break-all rounded-md border border-line bg-surface-subtle px-3 py-2 text-xs text-fg-muted">
{runtimeStatus.data.sandbox_image}
</code>
</Field>
) : null}
</div>
)}
</>
) : (
Expand Down
5 changes: 5 additions & 0 deletions docs/openapi/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions scripts/with-dev-env.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 "$@"
1 change: 1 addition & 0 deletions server/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions server/cmd/server/sandbox_docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
18 changes: 18 additions & 0 deletions server/cmd/server/sandbox_docker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
8 changes: 7 additions & 1 deletion server/internal/dev/runtime_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -29,6 +29,7 @@ type RuntimeStatusDeps struct {
SandboxProber SandboxLivenessProber
Profile string
ConfiguredByOps bool
SandboxImage string
PingTimeout time.Duration
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
23 changes: 22 additions & 1 deletion server/internal/dev/runtime_status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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{
Expand Down