From 1af9525a6f7d51fbda4cf404f117330f25a3af46 Mon Sep 17 00:00:00 2001 From: Viet Tran Date: Sun, 22 Feb 2026 19:22:28 +0700 Subject: [PATCH] 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. --- cmd/gateway.go | 13 +++-- internal/channels/telegram/commands.go | 33 +++++++++++-- internal/config/config_load.go | 45 +++++++++++++++++ ui/web/src/pages/config/config-page.tsx | 64 ++++++++++++++++++++----- 4 files changed, 138 insertions(+), 17 deletions(-) diff --git a/cmd/gateway.go b/cmd/gateway.go index 2bfe2056..4fca07f4 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -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) diff --git a/internal/channels/telegram/commands.go b/internal/channels/telegram/commands.go index 296a7839..4cc471f4 100644 --- a/internal/channels/telegram/commands.go +++ b/internal/channels/telegram/commands.go @@ -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 } diff --git a/internal/config/config_load.go b/internal/config/config_load.go index 9c263c39..e66d908a 100644 --- a/internal/config/config_load.go +++ b/internal/config/config_load.go @@ -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 diff --git a/ui/web/src/pages/config/config-page.tsx b/ui/web/src/pages/config/config-page.tsx index d2836155..1aed5dd9 100644 --- a/ui/web/src/pages/config/config-page.tsx +++ b/ui/web/src/pages/config/config-page.tsx @@ -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 ( + + +
+
+ {title} + {description} +
+ +
+
+
+ ); +} + /* eslint-disable @typescript-eslint/no-explicit-any */ function ConfigUI({ config, @@ -168,6 +192,8 @@ function ConfigUI({ onPatch: (updates: Record) => Promise; saving: boolean; }) { + const isManaged = (config.database as any)?.mode === "managed"; + return (
onPatch({ gateway: v })} saving={saving} /> - onPatch({ providers: v })} - saving={saving} - /> + {isManaged ? ( + + ) : ( + onPatch({ providers: v })} + saving={saving} + /> + )} onPatch({ agents: v })} @@ -190,11 +224,19 @@ function ConfigUI({ onSave={(v) => onPatch({ tools: v })} saving={saving} /> - onPatch({ channels: v })} - saving={saving} - /> + {isManaged ? ( + + ) : ( + onPatch({ channels: v })} + saving={saving} + /> + )} onPatch({ sessions: v })}