初始化仓库
This commit is contained in:
+248
@@ -0,0 +1,248 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MergeEnv returns base env with entries from extra overriding same-key entries.
|
||||
// This prevents duplicate keys (e.g. two PATH entries) which cause the override
|
||||
// to be silently ignored on Linux (getenv returns the first match).
|
||||
func MergeEnv(base, extra []string) []string {
|
||||
keys := make(map[string]bool, len(extra))
|
||||
for _, e := range extra {
|
||||
if k, _, ok := strings.Cut(e, "="); ok {
|
||||
keys[k] = true
|
||||
}
|
||||
}
|
||||
merged := make([]string, 0, len(base)+len(extra))
|
||||
for _, e := range base {
|
||||
if k, _, ok := strings.Cut(e, "="); ok && keys[k] {
|
||||
continue
|
||||
}
|
||||
merged = append(merged, e)
|
||||
}
|
||||
return append(merged, extra...)
|
||||
}
|
||||
|
||||
// CheckAllowFrom logs a security warning at startup when allow_from is not
|
||||
// configured (defaults to permit-all). Platforms should call this during init.
|
||||
func CheckAllowFrom(platform, allowFrom string) {
|
||||
if strings.TrimSpace(allowFrom) == "" {
|
||||
slog.Warn("allow_from is not set — all users are permitted. "+
|
||||
"Set allow_from in config to restrict access.",
|
||||
"platform", platform)
|
||||
}
|
||||
}
|
||||
|
||||
// RedactToken replaces a secret token in text with [REDACTED] to prevent
|
||||
// token leakage in logs or error messages.
|
||||
func RedactToken(text, token string) string {
|
||||
if token == "" || text == "" {
|
||||
return text
|
||||
}
|
||||
return strings.ReplaceAll(text, token, "[REDACTED]")
|
||||
}
|
||||
|
||||
// AllowList checks whether a user ID is permitted based on a comma-separated
|
||||
// allow_from string. Returns true if allowFrom is empty or "*" (allow all),
|
||||
// or if the userID is in the list. Comparison is case-insensitive.
|
||||
func AllowList(allowFrom, userID string) bool {
|
||||
allowFrom = strings.TrimSpace(allowFrom)
|
||||
if allowFrom == "" || allowFrom == "*" {
|
||||
return true
|
||||
}
|
||||
for _, id := range strings.Split(allowFrom, ",") {
|
||||
if strings.EqualFold(strings.TrimSpace(id), userID) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ImageAttachment represents an image sent by the user.
|
||||
type ImageAttachment struct {
|
||||
MimeType string // e.g. "image/png", "image/jpeg"
|
||||
Data []byte // raw image bytes
|
||||
FileName string // original filename (optional)
|
||||
}
|
||||
|
||||
// FileAttachment represents a file (PDF, doc, spreadsheet, etc.) sent by the user.
|
||||
type FileAttachment struct {
|
||||
MimeType string // e.g. "application/pdf", "text/plain"
|
||||
Data []byte // raw file bytes
|
||||
FileName string // original filename
|
||||
}
|
||||
|
||||
// SaveFilesToDisk saves file attachments to workDir/.cc-connect/attachments/
|
||||
// and returns the list of absolute file paths. Agents can reference these paths
|
||||
// in their prompts so the CLI can read them with built-in tools.
|
||||
//
|
||||
// The attachment FileName is treated as untrusted user input (it comes from
|
||||
// the IM/HTTP upload metadata) and is sanitized to a basename before being
|
||||
// joined into attachDir. Without this, FileName="../../escape.txt" was
|
||||
// written to workDir/escape.txt — outside the intended attachments
|
||||
// directory.
|
||||
func SaveFilesToDisk(workDir string, files []FileAttachment) []string {
|
||||
if len(files) == 0 {
|
||||
return nil
|
||||
}
|
||||
attachDir := filepath.Join(workDir, ".cc-connect", "attachments")
|
||||
if err := os.MkdirAll(attachDir, 0o755); err != nil {
|
||||
slog.Warn("SaveFilesToDisk: mkdir failed", "dir", attachDir, "error", err)
|
||||
}
|
||||
|
||||
var paths []string
|
||||
for i, f := range files {
|
||||
fname := sanitizeAttachmentFileName(f.FileName)
|
||||
if fname == "" {
|
||||
fname = fmt.Sprintf("file_%d_%d", time.Now().UnixMilli(), i)
|
||||
}
|
||||
fpath := filepath.Join(attachDir, fname)
|
||||
if err := os.WriteFile(fpath, f.Data, 0o644); err != nil {
|
||||
slog.Error("SaveFilesToDisk: write failed", "error", err)
|
||||
continue
|
||||
}
|
||||
paths = append(paths, fpath)
|
||||
slog.Debug("SaveFilesToDisk: file saved", "path", fpath, "name", f.FileName, "mime", f.MimeType, "size", len(f.Data))
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
// sanitizeAttachmentFileName reduces a user-supplied attachment filename to a
|
||||
// safe basename suitable for joining into an attachment directory. It strips
|
||||
// any directory components (both `/` and `\`, the latter so Linux strips
|
||||
// Windows-style paths too) and rejects parent / current-directory references.
|
||||
// Returns "" when the input cannot produce a safe basename, so callers can
|
||||
// fall back to a generated name.
|
||||
func sanitizeAttachmentFileName(name string) string {
|
||||
// Normalize backslashes to forward slashes so filepath.Base on any OS
|
||||
// strips Windows-style separators in attacker-supplied paths too.
|
||||
name = filepath.ToSlash(name)
|
||||
name = strings.ReplaceAll(name, "\\", "/")
|
||||
name = filepath.Base(name)
|
||||
if name == "" || name == "." || name == ".." {
|
||||
return ""
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// AppendFileRefs appends file path references to a prompt string.
|
||||
func AppendFileRefs(prompt string, filePaths []string) string {
|
||||
if len(filePaths) == 0 {
|
||||
return prompt
|
||||
}
|
||||
if prompt == "" {
|
||||
prompt = "Please analyze the attached file(s)."
|
||||
}
|
||||
return prompt + "\n\n(Files saved locally, please read them: " + strings.Join(filePaths, ", ") + ")"
|
||||
}
|
||||
|
||||
// AudioAttachment represents a voice/audio message sent by the user.
|
||||
type AudioAttachment struct {
|
||||
MimeType string // e.g. "audio/amr", "audio/ogg", "audio/mp4"
|
||||
Data []byte // raw audio bytes
|
||||
Format string // short format hint: "amr", "ogg", "m4a", "mp3", "wav", etc.
|
||||
Duration int // duration in seconds (if known)
|
||||
}
|
||||
|
||||
// LocationAttachment represents a geographical location sent by the user.
|
||||
type LocationAttachment struct {
|
||||
Latitude float64 // latitude coordinate
|
||||
Longitude float64 // longitude coordinate
|
||||
HorizontalAccuracy float64 // accuracy radius in meters (optional)
|
||||
LivePeriod int // time period for live location updates in seconds (optional)
|
||||
Heading int // direction of movement in degrees (optional)
|
||||
ProximityAlertRadius int // maximum distance for proximity alerts in meters (optional)
|
||||
}
|
||||
|
||||
// Message represents a unified incoming message from any platform.
|
||||
type Message struct {
|
||||
SessionKey string // unique key for user context, e.g. "feishu:{chatID}:{userID}"
|
||||
Platform string
|
||||
MessageID string // platform message ID for tracing
|
||||
Recalled bool // true for platform message recall/delete events targeting MessageID
|
||||
ChannelID string
|
||||
UserID string
|
||||
UserName string
|
||||
ChatName string // human-readable chat/group name (optional)
|
||||
Content string
|
||||
Images []ImageAttachment // attached images (if any)
|
||||
Files []FileAttachment // attached files (if any)
|
||||
Audio *AudioAttachment // voice message (if any)
|
||||
Location *LocationAttachment // geographical location (if any)
|
||||
ExtraContent string // platform-enriched content (e.g. location text, reply quote) prepended for the agent
|
||||
ChannelKey string // platform-provided channel identifier for workspace binding (optional)
|
||||
ReplyCtx any // platform-specific context needed for replying
|
||||
FromVoice bool // true if message originated from voice transcription
|
||||
ModeOverride string // if set, temporarily override agent permission mode for this message
|
||||
}
|
||||
|
||||
// EventType distinguishes different kinds of agent output.
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
EventText EventType = "text" // intermediate or final text
|
||||
EventToolUse EventType = "tool_use" // tool invocation info
|
||||
EventToolResult EventType = "tool_result" // tool execution result
|
||||
EventResult EventType = "result" // final aggregated result
|
||||
EventError EventType = "error" // error occurred
|
||||
EventPermissionRequest EventType = "permission_request" // agent requests permission via stdio protocol
|
||||
EventThinking EventType = "thinking" // thinking/processing status
|
||||
)
|
||||
|
||||
// UserQuestion represents a structured question from AskUserQuestion.
|
||||
type UserQuestion struct {
|
||||
Question string `json:"question"`
|
||||
Header string `json:"header"`
|
||||
Options []UserQuestionOption `json:"options"`
|
||||
MultiSelect bool `json:"multiSelect"`
|
||||
}
|
||||
|
||||
// UserQuestionOption is one choice in a UserQuestion.
|
||||
type UserQuestionOption struct {
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// Event represents a single piece of agent output streamed back to the engine.
|
||||
type Event struct {
|
||||
Type EventType
|
||||
Content string
|
||||
ToolName string // populated for EventToolUse, EventPermissionRequest
|
||||
ToolInput string // human-readable summary of tool input
|
||||
ToolInputRaw map[string]any // raw tool input (for EventPermissionRequest, used in allow response)
|
||||
ToolResult string // populated for EventToolResult
|
||||
ToolStatus string // optional status for EventToolResult (e.g. completed/failed)
|
||||
ToolExitCode *int // optional exit code for EventToolResult
|
||||
ToolSuccess *bool // optional success flag for EventToolResult
|
||||
SessionID string // agent-managed session ID for conversation continuity
|
||||
RequestID string // unique request ID for EventPermissionRequest
|
||||
Questions []UserQuestion // populated when ToolName == "AskUserQuestion"
|
||||
Done bool
|
||||
Error error
|
||||
InputTokens int // token usage from agent result events
|
||||
OutputTokens int
|
||||
Metadata map[string]any // optional metadata from agent (e.g. compaction_continue)
|
||||
Synthetic bool // true if this is a synthetic/generated message (not from real user)
|
||||
}
|
||||
|
||||
// HistoryEntry is one turn in a conversation.
|
||||
type HistoryEntry struct {
|
||||
Role string `json:"role"` // "user" or "assistant"
|
||||
Content string `json:"content"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// AgentSessionInfo describes one session as reported by the agent backend.
|
||||
type AgentSessionInfo struct {
|
||||
ID string
|
||||
Summary string
|
||||
MessageCount int
|
||||
ModifiedAt time.Time
|
||||
GitBranch string
|
||||
}
|
||||
Reference in New Issue
Block a user