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} + + + + Manage + + + + + + ); +} + /* 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 })}