Files
2026-06-12 17:51:12 +07:00

420 lines
16 KiB
Go

package agent
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"strings"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
"github.com/nextlevelbuilder/goclaw/internal/workspace"
)
// contextSetupResult holds the outputs of injectContext that are needed by the main loop.
type contextSetupResult struct {
ctx context.Context
resolvedTeamSettings json.RawMessage
}
// injectContext enriches the context with agent, tenant, user, workspace, and tool-level
// values needed by the agent loop and tool execution. Also runs input guard and message
// truncation. Returns error only if input guard blocks the message.
func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetupResult, error) {
// Inject agent UUID + key into context for tool routing
if l.agentUUID != uuid.Nil {
ctx = store.WithAgentID(ctx, l.agentUUID)
}
if l.id != "" {
ctx = store.WithAgentKey(ctx, l.id)
}
// Inject tenant into context for tool-level tenant scoping (spawn, MCP, etc.)
if l.tenantID != uuid.Nil {
ctx = store.WithTenantID(ctx, l.tenantID)
}
// Inject user ID into context for per-user scoping (memory, context files, etc.)
if req.UserID != "" {
ctx = store.WithUserID(ctx, req.UserID)
}
// Resolve merged tenant user identity for credential lookups.
// Keeps UserID unchanged (session/workspace scoping) but sets a separate
// CredentialUserID for SecureCLI, MCP, and other per-user features.
if l.userResolver != nil && req.UserID != "" && store.ExplicitCredentialUserIDFromContext(ctx) == "" {
credUserID := l.resolveCredentialUserID(ctx, *req)
if credUserID != "" && credUserID != req.UserID {
ctx = store.WithCredentialUserID(ctx, credUserID)
}
}
// Inject agent type into context for interceptor routing
if l.agentType != "" {
ctx = store.WithAgentType(ctx, l.agentType)
}
// Inject self-evolve flag for predefined agents that can update SOUL.md
if l.selfEvolve {
ctx = store.WithSelfEvolve(ctx, true)
}
// Inject original sender ID for group file writer permission checks
if req.SenderID != "" {
ctx = store.WithSenderID(ctx, req.SenderID)
}
// Inject sender display name for bootstrap auto-contact
if req.SenderName != "" {
ctx = store.WithSenderName(ctx, req.SenderName)
}
// Inject caller role so RBAC-aware permission checks (CheckFileWriterPermission,
// CheckCronPermission) can bypass per-user grants for authenticated admins
// dispatched from dashboard or other trusted sources (#915).
if req.Role != "" {
ctx = store.WithRole(ctx, req.Role)
}
// Inject global + per-agent builtin tool settings (tier 1+3).
// Media/provider-chain tools read the merged view via BuiltinToolSettingsFromCtx.
if l.builtinToolSettings != nil {
ctx = tools.WithBuiltinToolSettings(ctx, l.builtinToolSettings)
}
// Inject tenant-layer tool settings (tier 2). Merge with per-agent happens
// at read time — per-agent still wins at tool-name level.
if l.tenantToolSettings != nil {
ctx = tools.WithTenantToolSettings(ctx, l.tenantToolSettings)
}
// Inject tenant-specific allowed paths for filesystem tools.
if len(l.tenantAllowedPaths) > 0 {
ctx = tools.WithTenantAllowedPaths(ctx, l.tenantAllowedPaths)
}
// Inject channel type into context for tools (e.g. message tool needs it for Zalo group routing)
if req.ChannelType != "" {
ctx = tools.WithToolChannelType(ctx, req.ChannelType)
}
// Inject per-agent overrides from DB so tools honor per-agent settings.
if l.restrictToWs != nil {
ctx = tools.WithRestrictToWorkspace(ctx, *l.restrictToWs)
}
if l.subagentsCfg != nil {
ctx = tools.WithSubagentConfig(ctx, l.subagentsCfg)
}
// Pass the agent's model and provider so subagents inherit the correct combo.
if l.model != "" {
ctx = tools.WithParentModel(ctx, l.model)
}
if l.provider != nil {
ctx = tools.WithParentProvider(ctx, l.provider.Name())
}
if l.memoryCfg != nil {
ctx = tools.WithMemoryConfig(ctx, l.memoryCfg)
}
var waitToolCfg *config.WaitToolPolicy
if l.agentToolPolicy != nil && l.agentToolPolicy.Wait != nil {
waitToolCfg = l.agentToolPolicy.Wait
ctx = tools.WithWaitToolConfig(ctx, waitToolCfg)
}
if l.sandboxCfg != nil {
ctx = tools.WithSandboxConfig(ctx, l.sandboxCfg)
}
if l.shellDenyGroups != nil {
ctx = store.WithShellDenyGroups(ctx, l.shellDenyGroups)
}
// Workspace scope propagation (delegation origin → workspace tools).
if req.WorkspaceChannel != "" {
ctx = tools.WithWorkspaceChannel(ctx, req.WorkspaceChannel)
}
// WorkspaceChatID drives vault chat_id isolation in isolated teams. Callers
// that don't set it explicitly fall back to req.ChatID — the chat segment
// used for workspace path layering — so the vault filter activates uniformly
// across every RunRequest entry point (WS direct, HTTP, cron, subagent).
effectiveWorkspaceChatID := req.WorkspaceChatID
if effectiveWorkspaceChatID == "" {
effectiveWorkspaceChatID = req.ChatID
}
if effectiveWorkspaceChatID != "" {
ctx = tools.WithWorkspaceChatID(ctx, effectiveWorkspaceChatID)
}
if req.TeamTaskID != "" {
ctx = tools.WithTeamTaskID(ctx, req.TeamTaskID)
}
if req.DelegationID != "" {
ctx = tools.WithDelegationID(ctx, req.DelegationID)
}
// --- Per-user setup: file seeding + workspace resolution ---
// Uses userSetups sync.Map to track both concerns atomically per user.
// Seeding must run before buildMessages→resolveContextFiles reads context files.
// Team sessions skip seeding: members process tasks from leader, not end-user onboarding.
isTeamSession := bootstrap.IsTeamSession(req.SessionKey)
channelMeta := l.buildChannelMeta(req)
setup := l.getOrCreateUserSetup(ctx, req.UserID, req.Channel, isTeamSession, channelMeta)
// Workspace resolution (layered pipeline).
// Layer order: tenant → team → project (future) → user/chat
// Two entry modes: solo agent (base = l.workspace) or team context (base = l.dataDir).
// Result is always a single folder set via WithToolWorkspace.
if l.workspace != "" && req.UserID != "" {
ws := setup.workspace
if ws == "" {
ws = l.workspace
}
// Apply user isolation layer via pipeline.
shared := l.shouldShareWorkspace(req.UserID, req.PeerKind)
if shared {
ctx = store.WithSharedContext(ctx)
}
effectiveWorkspace := tools.ResolveWorkspace(ws,
tools.UserChatLayer(tools.SanitizePathSegment(req.UserID), shared),
)
if l.shouldShareMemory() {
ctx = store.WithSharedMemory(ctx)
}
if l.shouldShareKnowledgeGraph() {
ctx = store.WithSharedKG(ctx)
}
if l.shouldShareSessions() {
ctx = store.WithSharedSessions(ctx)
}
if err := os.MkdirAll(effectiveWorkspace, 0755); err != nil {
// Stale stored workspace (e.g. Docker-era /app/workspace/ on bare-metal host)
// would propagate as cmd.Dir into exec tools, where Linux's clone+chdir+execve
// failure surfaces as a misleading "fork/exec PATH: no such file or directory"
// — same message users would see for a missing binary. Fall back to the system
// default workspace (already created at startup) so tools keep working while
// the warning surfaces the data drift for operators.
slog.Warn("failed to create user workspace directory; falling back to system default",
"workspace", effectiveWorkspace, "fallback", l.workspace, "user", req.UserID, "error", err)
effectiveWorkspace = l.workspace
}
ctx = tools.WithToolWorkspace(ctx, effectiveWorkspace)
} else if l.workspace != "" {
ctx = tools.WithToolWorkspace(ctx, l.workspace)
}
// Team workspace: dispatched task overrides default workspace.
if req.TeamWorkspace != "" {
if err := os.MkdirAll(req.TeamWorkspace, 0755); err != nil {
// See note above on loop_context user workspace fallback. A broken
// req.TeamWorkspace would otherwise become cmd.Dir and surface as
// "fork/exec PATH: no such file or directory" from any tool exec.
slog.Warn("failed to create team workspace directory; keeping previous workspace",
"workspace", req.TeamWorkspace, "error", err)
} else {
ctx = tools.WithToolTeamWorkspace(ctx, req.TeamWorkspace)
ctx = tools.WithToolWorkspace(ctx, req.TeamWorkspace)
}
}
if req.TeamID != "" {
ctx = tools.WithToolTeamID(ctx, req.TeamID)
// Team root for dispatched tasks: resolve the UserChatLayer-stripped root
// so the dispatched agent can still read peer-scoped files in the same team.
if teamUUID, err := uuid.Parse(req.TeamID); err == nil && l.dataDir != "" {
teamRoot := tools.ResolveWorkspace(l.dataDir,
tools.TenantLayer(store.TenantIDFromContext(ctx), store.TenantSlugFromContext(ctx)),
tools.TeamLayer(teamUUID),
)
ctx = tools.WithToolTeamRoot(ctx, teamRoot)
}
}
if req.LeaderAgentID != "" {
ctx = tools.WithLeaderAgentID(ctx, req.LeaderAgentID)
}
// Team workspace: auto-resolve for agents with team membership (not dispatched).
// Lead agents default to team workspace; non-lead members keep own workspace.
var resolvedTeamSettings json.RawMessage
// Dispatched tasks already have TeamWorkspace set but still need team settings
// for TeamIsolated flag. Fetch by explicit TeamID in that branch.
if req.TeamWorkspace != "" && req.TeamID != "" && l.teamStore != nil {
if teamUUID, err := uuid.Parse(req.TeamID); err == nil {
if team, _ := l.teamStore.GetTeam(ctx, teamUUID); team != nil {
resolvedTeamSettings = team.Settings
}
}
}
if req.TeamWorkspace == "" && l.teamStore != nil && l.agentUUID != uuid.Nil {
if team, _ := l.teamStore.GetTeamForAgent(ctx, l.agentUUID); team != nil {
resolvedTeamSettings = team.Settings
wsChat := req.ChatID
if wsChat == "" {
wsChat = req.UserID
}
shared := tools.IsSharedWorkspace(team.Settings)
// Resolve team workspace via layered pipeline: tenant → team → user/chat.
wsDir := tools.ResolveWorkspace(l.dataDir,
tools.TenantLayer(store.TenantIDFromContext(ctx), store.TenantSlugFromContext(ctx)),
tools.TeamLayer(team.ID),
tools.UserChatLayer(wsChat, shared),
)
if err := os.MkdirAll(wsDir, 0750); err != nil {
// See note above on loop_context user workspace fallback. Skip the
// team workspace context-set so downstream tools don't inherit a
// path that would fail with a misleading "fork/exec PATH: ENOENT".
slog.Warn("failed to create team workspace directory; skipping team workspace ctx",
"workspace", wsDir, "error", err)
} else {
ctx = tools.WithToolTeamWorkspace(ctx, wsDir)
}
// Team root (no UserChatLayer): lets any team agent — leader or member —
// read files produced by peers under different chat/user scopes within
// the same team. Writes still default to wsDir above; team root only
// widens the allowed-prefix set for path boundary checks.
teamRoot := tools.ResolveWorkspace(l.dataDir,
tools.TenantLayer(store.TenantIDFromContext(ctx), store.TenantSlugFromContext(ctx)),
tools.TeamLayer(team.ID),
)
ctx = tools.WithToolTeamRoot(ctx, teamRoot)
// Leader keeps personal workspace (set at line 110-132) as default.
// Team workspace accessible via ToolTeamWorkspaceFromCtx for delegation.
if req.TeamID == "" {
ctx = tools.WithToolTeamID(ctx, team.ID.String())
}
}
}
// V3 workspace: resolve once, set immutable context.
{
var teamIDPtr *string
if req.TeamID != "" {
teamIDPtr = &req.TeamID
}
var teamWSConfig *workspace.TeamWorkspaceConfig
if resolvedTeamSettings != nil {
var cfg workspace.TeamWorkspaceConfig
if json.Unmarshal(resolvedTeamSettings, &cfg) == nil {
teamWSConfig = &cfg
}
}
resolver := workspace.NewResolver()
wc, wsErr := resolver.Resolve(ctx, workspace.ResolveParams{
// Filesystem path segment must use agent_key, not UUID — matches
// the v2 path in loop_pipeline_callbacks.go and the session_key
// anchor. See docs/agent-identity-conventions.md.
AgentID: l.id,
AgentType: l.agentType,
UserID: req.UserID,
ChatID: req.ChatID,
TenantID: store.TenantIDFromContext(ctx).String(),
TenantSlug: store.TenantSlugFromContext(ctx),
PeerKind: req.PeerKind,
TeamID: teamIDPtr,
TeamConfig: teamWSConfig,
BaseDir: l.dataDir,
})
if wsErr != nil {
slog.Warn("workspace resolution failed", "err", wsErr)
} else {
ctx = workspace.WithContext(ctx, wc)
}
}
// Persist agent UUID + user ID on the session (for querying/tracing)
if l.agentUUID != uuid.Nil || req.UserID != "" {
l.sessions.SetAgentInfo(ctx, req.SessionKey, l.agentUUID, req.UserID)
}
// Security: scan user message for injection patterns.
// Action is configurable: "log" (info), "warn" (default), "block" (reject message).
if l.inputGuard != nil {
if matches := l.inputGuard.Scan(req.Message); len(matches) > 0 {
matchStr := strings.Join(matches, ",")
switch l.injectionAction {
case "block":
slog.Warn("security.injection_blocked",
"agent", l.id, "user", req.UserID,
"patterns", matchStr, "message_len", len(req.Message),
)
return contextSetupResult{}, fmt.Errorf("message blocked: potential prompt injection detected (%s)", matchStr)
case "log":
slog.Info("security.injection_detected",
"agent", l.id, "user", req.UserID,
"patterns", matchStr, "message_len", len(req.Message),
)
default: // "warn"
slog.Warn("security.injection_detected",
"agent", l.id, "user", req.UserID,
"patterns", matchStr, "message_len", len(req.Message),
)
}
}
}
// Inject agent key into context for tool-level resolution (multiple agents share tool registry)
ctx = tools.WithToolAgentKey(ctx, l.id)
// Inject delivered media tracker so write_file and message tool can coordinate:
// write_file(deliver=true) marks paths, message self-send guard checks before allowing.
ctx = tools.WithDeliveredMedia(ctx, tools.NewDeliveredMedia())
// Security: truncate oversized user messages gracefully (feed truncation notice into LLM)
maxChars := l.maxMessageChars
if maxChars <= 0 {
maxChars = config.DefaultMaxMessageChars
}
if len(req.Message) > maxChars {
originalLen := len(req.Message)
req.Message = req.Message[:maxChars] +
fmt.Sprintf("\n\n[System: Message was truncated from %d to %d characters due to size limit. "+
"Please ask the user to send shorter messages or use the read_file tool for large content.]",
originalLen, maxChars)
slog.Warn("security.message_truncated",
"agent", l.id, "user", req.UserID,
"original_len", originalLen, "truncated_to", maxChars,
)
}
// Build RunContext from all resolved values and inject as single context key.
// This provides a typed, inspectable snapshot of all loop-injected context.
// Individual With* keys above remain for backward compat during transition.
providerName := ""
if l.provider != nil {
providerName = l.provider.Name()
}
// Extract resolved credential user ID (set earlier via WithCredentialUserID, empty if not resolved).
credUserID := store.ExplicitCredentialUserIDFromContext(ctx)
rc := &store.RunContext{
AgentID: l.agentUUID,
AgentKey: l.id,
TenantID: l.tenantID,
UserID: req.UserID,
RunID: req.RunID,
SessionKey: req.SessionKey,
CredentialUserID: credUserID,
AgentType: l.agentType,
SenderID: req.SenderID,
SelfEvolve: l.selfEvolve,
SharedMemory: store.IsSharedMemory(ctx),
SharedKG: store.IsSharedKG(ctx),
SharedSessions: store.IsSharedSessions(ctx),
SharedContext: store.IsSharedContext(ctx),
RestrictToWorkspace: l.restrictToWs != nil && *l.restrictToWs,
BuiltinToolSettings: l.builtinToolSettings,
Channel: req.Channel,
ChannelType: req.ChannelType,
SubagentsCfg: l.subagentsCfg,
ParentModel: l.model,
ParentProvider: providerName,
MemoryCfg: l.memoryCfg,
SandboxCfg: l.sandboxCfg,
WaitToolCfg: waitToolCfg,
ShellDenyGroups: l.shellDenyGroups,
Workspace: tools.ToolWorkspaceFromCtx(ctx),
TeamWorkspace: tools.ToolTeamWorkspaceFromCtx(ctx),
TeamID: tools.ToolTeamIDFromCtx(ctx),
WorkspaceChannel: req.WorkspaceChannel,
WorkspaceChatID: effectiveWorkspaceChatID,
TeamIsolated: resolvedTeamSettings != nil && !tools.IsSharedWorkspace(resolvedTeamSettings),
TeamTaskID: req.TeamTaskID,
LeaderAgentID: tools.LeaderAgentIDFromCtx(ctx),
AgentToolKey: l.id,
TenantAllowedPaths: l.tenantAllowedPaths,
}
ctx = store.WithRunContext(ctx, rc)
return contextSetupResult{
ctx: ctx,
resolvedTeamSettings: resolvedTeamSettings,
}, nil
}