Add sandbox Docker availability check, agent UUID resolver, and managed mode config redirects

Check Docker availability before enabling sandbox and gracefully disable with warning if unavailable. Add resolveAgentUUID helper in Telegram to support both agent keys and direct UUIDs. Expose sandbox config via environment variables for docker-compose overlay. Add compact redirect cards in config UI for Providers and Channels sections when in managed mode, directing users to dedicated management pages.
This commit is contained in:
Viet Tran committed 2026-02-22 19:22:28 +07:00
1 parent 765bec2287
commit 1af9525a6f
4 files changed
+138 -17

No files matched your search

+10 -3
View File
@@ -117,9 +117,16 @@ func runGateway() {
// Sandbox manager (optional — routes tools through Docker containers)
var sandboxMgr sandbox.Manager
if sbCfg := cfg.Agents.Defaults.Sandbox; sbCfg != nil && sbCfg.Mode != "" && sbCfg.Mode != "off" {
resolved := sbCfg.ToSandboxConfig()
sandboxMgr = sandbox.NewDockerManager(resolved)
slog.Info("sandbox enabled", "mode", string(resolved.Mode), "image", resolved.Image, "scope", string(resolved.Scope))
if err := sandbox.CheckDockerAvailable(context.Background()); err != nil {
slog.Warn("sandbox disabled: Docker not available",
"configured_mode", sbCfg.Mode,
"error", err,
)
} else {
resolved := sbCfg.ToSandboxConfig()
sandboxMgr = sandbox.NewDockerManager(resolved)
slog.Info("sandbox enabled", "mode", string(resolved.Mode), "image", resolved.Image, "scope", string(resolved.Scope))
}
}
// Register file tools + exec tool (with sandbox routing via FsBridge if enabled)
+30 -3
View File
@@ -14,6 +14,27 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/bus"
)
// resolveAgentUUID looks up the agent UUID from the channel's agent key.
// Returns uuid.Nil if the agent key is empty or not found.
func (c *Channel) resolveAgentUUID(ctx context.Context) (uuid.UUID, error) {
key := c.AgentID()
if key == "" {
return uuid.Nil, fmt.Errorf("no agent key configured")
}
// Try direct UUID parse first (future-proofing).
if id, err := uuid.Parse(key); err == nil {
return id, nil
}
// Look up by agent key.
agent, err := c.agentStore.GetByKey(ctx, key)
if err != nil {
return uuid.Nil, fmt.Errorf("agent %q not found: %w", key, err)
}
return agent.ID, nil
}
// handleBotCommand checks if the message is a known bot command and handles it.
// Returns true if the message was handled as a command.
func (c *Channel) handleBotCommand(ctx context.Context, message *telego.Message, chatID int64, chatIDStr, localKey, text, senderID string, isGroup, isForum bool, messageThreadID int) bool {
@@ -127,8 +148,9 @@ func (c *Channel) handleWriterCommand(ctx context.Context, message *telego.Messa
return
}
agentID, err := uuid.Parse(c.AgentID())
agentID, err := c.resolveAgentUUID(ctx)
if err != nil {
slog.Debug("writer command: agent resolve failed", "error", err)
send("File writer management is not available (no agent).")
return
}
@@ -150,7 +172,11 @@ func (c *Channel) handleWriterCommand(ctx context.Context, message *telego.Messa
// Extract target user from reply-to message
if message.ReplyToMessage == nil || message.ReplyToMessage.From == nil {
send(fmt.Sprintf("Reply to a message from the user you want to %s as a writer.", action))
verb := "add"
if action == "remove" {
verb = "remove"
}
send(fmt.Sprintf("To %s a writer: find a message from that person, swipe to reply it, then type /%swriter.", verb, verb))
return
}
@@ -206,8 +232,9 @@ func (c *Channel) handleListWriters(ctx context.Context, chatID int64, chatIDStr
return
}
agentID, err := uuid.Parse(c.AgentID())
agentID, err := c.resolveAgentUUID(ctx)
if err != nil {
slog.Debug("list writers: agent resolve failed", "error", err)
send("File writer management is not available (no agent).")
return
}
+45
View File
@@ -167,6 +167,51 @@ func (c *Config) applyEnvOverrides() {
envStr("GOCLAW_TSNET_HOSTNAME", &c.Tailscale.Hostname)
envStr("GOCLAW_TSNET_AUTH_KEY", &c.Tailscale.AuthKey)
envStr("GOCLAW_TSNET_DIR", &c.Tailscale.StateDir)
// Sandbox (for Docker-compose sandbox overlay)
ensureSandbox := func() {
if c.Agents.Defaults.Sandbox == nil {
c.Agents.Defaults.Sandbox = &SandboxConfig{}
}
}
if v := os.Getenv("GOCLAW_SANDBOX_MODE"); v != "" {
ensureSandbox()
c.Agents.Defaults.Sandbox.Mode = v
}
if v := os.Getenv("GOCLAW_SANDBOX_IMAGE"); v != "" {
ensureSandbox()
c.Agents.Defaults.Sandbox.Image = v
}
if v := os.Getenv("GOCLAW_SANDBOX_WORKSPACE_ACCESS"); v != "" {
ensureSandbox()
c.Agents.Defaults.Sandbox.WorkspaceAccess = v
}
if v := os.Getenv("GOCLAW_SANDBOX_SCOPE"); v != "" {
ensureSandbox()
c.Agents.Defaults.Sandbox.Scope = v
}
if v := os.Getenv("GOCLAW_SANDBOX_MEMORY_MB"); v != "" {
ensureSandbox()
if mb, err := strconv.Atoi(v); err == nil && mb > 0 {
c.Agents.Defaults.Sandbox.MemoryMB = mb
}
}
if v := os.Getenv("GOCLAW_SANDBOX_CPUS"); v != "" {
ensureSandbox()
if cpus, err := strconv.ParseFloat(v, 64); err == nil && cpus > 0 {
c.Agents.Defaults.Sandbox.CPUs = cpus
}
}
if v := os.Getenv("GOCLAW_SANDBOX_TIMEOUT_SEC"); v != "" {
ensureSandbox()
if sec, err := strconv.Atoi(v); err == nil && sec > 0 {
c.Agents.Defaults.Sandbox.TimeoutSec = sec
}
}
if v := os.Getenv("GOCLAW_SANDBOX_NETWORK"); v != "" {
ensureSandbox()
c.Agents.Defaults.Sandbox.NetworkEnabled = v == "true" || v == "1"
}
}
// applyContextPruningDefaults auto-enables context pruning when the Anthropic
+53 -11
View File
@@ -1,14 +1,17 @@
import { useState, useEffect } from "react";
import { Settings, Save, RefreshCw, AlertCircle, ShieldAlert } from "lucide-react";
import { Settings, Save, RefreshCw, AlertCircle, ShieldAlert, ArrowRight } from "lucide-react";
import { Link } from "react-router";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { PageHeader } from "@/components/shared/page-header";
import { EmptyState } from "@/components/shared/empty-state";
import { DetailSkeleton } from "@/components/shared/loading-skeleton";
import { useConfig } from "./hooks/use-config";
import { useMinLoading } from "@/hooks/use-min-loading";
import { ROUTES } from "@/lib/constants";
import { GatewaySection } from "./sections/gateway-section";
import { ProvidersSection } from "./sections/providers-section";
import { AgentsDefaultsSection } from "./sections/agents-defaults-section";
@@ -158,6 +161,27 @@ export function ConfigPage() {
);
}
/** Compact redirect card shown in managed mode for sections that have dedicated pages. */
function ManagedRedirect({ title, description, to }: { title: string; description: string; to: string }) {
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</div>
<Button variant="outline" size="sm" className="gap-1.5 shrink-0" asChild>
<Link to={to}>
Manage <ArrowRight className="h-3.5 w-3.5" />
</Link>
</Button>
</div>
</CardHeader>
</Card>
);
}
/* eslint-disable @typescript-eslint/no-explicit-any */
function ConfigUI({
config,
@@ -168,6 +192,8 @@ function ConfigUI({
onPatch: (updates: Record<string, unknown>) => Promise<void>;
saving: boolean;
}) {
const isManaged = (config.database as any)?.mode === "managed";
return (
<div className="space-y-4">
<GatewaySection
@@ -175,11 +201,19 @@ function ConfigUI({
onSave={(v) => onPatch({ gateway: v })}
saving={saving}
/>
<ProvidersSection
data={config.providers as any}
onSave={(v) => onPatch({ providers: v })}
saving={saving}
/>
{isManaged ? (
<ManagedRedirect
title="LLM Providers"
description="Managed via the Providers page in managed mode."
to={ROUTES.PROVIDERS}
/>
) : (
<ProvidersSection
data={config.providers as any}
onSave={(v) => onPatch({ providers: v })}
saving={saving}
/>
)}
<AgentsDefaultsSection
data={config.agents as any}
onSave={(v) => onPatch({ agents: v })}
@@ -190,11 +224,19 @@ function ConfigUI({
onSave={(v) => onPatch({ tools: v })}
saving={saving}
/>
<ChannelsSection
data={config.channels as any}
onSave={(v) => onPatch({ channels: v })}
saving={saving}
/>
{isManaged ? (
<ManagedRedirect
title="Channels"
description="Managed via the Channels page in managed mode."
to={ROUTES.CHANNELS}
/>
) : (
<ChannelsSection
data={config.channels as any}
onSave={(v) => onPatch({ channels: v })}
saving={saving}
/>
)}
<SessionsSection
data={config.sessions as any}
onSave={(v) => onPatch({ sessions: v })}