From b5fda34f25dfdbf0bac36a7a8b0ddc66505e9938 Mon Sep 17 00:00:00 2001 From: aishannon Date: Wed, 11 Mar 2026 15:26:54 +0800 Subject: [PATCH 01/28] feat(qq): support parsing and replying to more attachment types 1.Support parsing QQ Channel emoji structures. 2.Support handling incoming voice, image, video, and file messages from QQ Channel. 3.Support replying with local voice, image, video, and file attachments (upload before sending). 4.Prioritize Markdown when replying, with a fallback to plain text if it fails. --- pkg/channels/qq/message.go | 30 +++ pkg/channels/qq/qq.go | 480 +++++++++++++++++++++++++++---------- 2 files changed, 380 insertions(+), 130 deletions(-) create mode 100644 pkg/channels/qq/message.go diff --git a/pkg/channels/qq/message.go b/pkg/channels/qq/message.go new file mode 100644 index 000000000..b23db5fe2 --- /dev/null +++ b/pkg/channels/qq/message.go @@ -0,0 +1,30 @@ +package qq + +import "github.com/tencent-connect/botgo/dto" + +// RichMediaMessage rich media message. +// It is recommended to upload first, then send using message type 7. +type RichMediaMessage struct { + FileType uint64 `json:"file_type,omitempty"` // file type: 1-image, 2-video, 3-voice (currently voice only supports silk format) + URL string `json:"url,omitempty"` // rich media file to send, HTTP or HTTPS link + FileName string `json:"file_name,omitempty"` // file name for files sent via FileData + FileData []byte `json:"file_data,omitempty"` // file binary data for files sent via FileData +} + +// GetEventID event ID +func (msg RichMediaMessage) GetEventID() string { + return "" +} + +// GetSendType message type +func (msg RichMediaMessage) GetSendType() dto.SendType { + return dto.RichMedia +} + +// MessageAttachment attachment definition +type MessageAttachment struct { + URL string `json:"url,omitempty"` + FileName string `json:"filename,omitempty"` + ContentType string `json:"content_type,omitempty"` // voice: audio, image/xxx: image, video/xxx: video + AsrReferText string `json:"asr_refer_text,omitempty"` // ASR reference text +} diff --git a/pkg/channels/qq/qq.go b/pkg/channels/qq/qq.go index 540e3b7af..7bcd12800 100644 --- a/pkg/channels/qq/qq.go +++ b/pkg/channels/qq/qq.go @@ -2,7 +2,15 @@ package qq import ( "context" + "encoding/base64" + "encoding/json" "fmt" + "github.com/sipeed/picoclaw/pkg/media" + "github.com/sipeed/picoclaw/pkg/utils" + "github.com/tidwall/gjson" + "math/rand" + "os" + "path/filepath" "regexp" "strings" "sync" @@ -47,7 +55,7 @@ type QQChannel struct { lastMsgID sync.Map // chatID → string // msg_seq: per-chat atomic counter for multi-part replies. - msgSeqCounters sync.Map // chatID → *atomic.Uint64 + msgSeqCounters sync.Map // chatID → *atomic.Uint32 // Time-based dedup replacing the unbounded map. dedup map[string]time.Time @@ -100,7 +108,7 @@ func (c *QQChannel) Start(ctx context.Context) error { } // initialize OpenAPI client - c.api = botgo.NewOpenAPI(c.config.AppID, c.tokenSource).WithTimeout(5 * time.Second) + c.api = botgo.NewOpenAPI(c.config.AppID, c.tokenSource).WithTimeout(20 * time.Second) // register event handlers intent := event.RegisterHandlers( @@ -175,72 +183,72 @@ func (c *QQChannel) getChatKind(chatID string) string { return "group" } +// Send sends a message to the specified chatID. +// First attempt to send a Markdown message, fallback to plain text if failed. func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { return channels.ErrNotRunning } chatKind := c.getChatKind(msg.ChatID) + textMsg, mdMsg := c.genReplyMsg(ctx, msg, chatKind) - // Build message with content. - msgToCreate := &dto.MessageToCreate{ - Content: msg.Content, + for _, _v := range []dto.MessageToCreate{mdMsg, textMsg} { + var err error + var replyMsgID *dto.Message + if chatKind == "group" { + replyMsgID, err = c.api.PostGroupMessage(ctx, msg.ChatID, _v) + } else { + replyMsgID, err = c.api.PostC2CMessage(ctx, msg.ChatID, _v) + } + if err == nil { + logger.InfoCF("qq", "Sent message", map[string]any{"postrsp ": replyMsgID}) + return nil + } + if err != nil { + logger.ErrorCF("qq", "Failed to send message", map[string]any{ + "chat_id": msg.ChatID, + "chat_kind": chatKind, + "error": err.Error(), + }) + } + } + return nil +} + +func (c *QQChannel) genReplyMsg(ctx context.Context, msg bus.OutboundMessage, chatKind string) (dto.MessageToCreate, + dto.MessageToCreate) { + textMsg := dto.MessageToCreate{ + Content: sanitizeURLs(msg.Content), MsgType: dto.TextMsg, } - // Use Markdown message type if enabled in config. - if c.config.SendMarkdown { - msgToCreate.MsgType = dto.MarkdownMsg - msgToCreate.Markdown = &dto.Markdown{ + mdMsg := dto.MessageToCreate{ + MsgType: dto.MarkdownMsg, + Markdown: &dto.Markdown{ Content: msg.Content, - } - // Clear plain content to avoid sending duplicate text. - msgToCreate.Content = "" + }, } + return textMsg, mdMsg +} + +func (c *QQChannel) getReplyExtInfo(ctx context.Context, chatID string) (replyID string, seq uint32) { // Attach passive reply msg_id and msg_seq if available. - if v, ok := c.lastMsgID.Load(msg.ChatID); ok { + if v, ok := c.lastMsgID.Load(chatID); ok { if msgID, ok := v.(string); ok && msgID != "" { - msgToCreate.MsgID = msgID - - // Increment msg_seq atomically for multi-part replies. - if counterVal, ok := c.msgSeqCounters.Load(msg.ChatID); ok { - if counter, ok := counterVal.(*atomic.Uint64); ok { - seq := counter.Add(1) - msgToCreate.MsgSeq = uint32(seq) - } - } + replyID = msgID } } - - // Sanitize URLs in group messages to avoid QQ's URL blacklist rejection. - if chatKind == "group" { - if msgToCreate.Content != "" { - msgToCreate.Content = sanitizeURLs(msgToCreate.Content) + // Increment msg_seq atomically for multi-part replies. + if counterVal, ok := c.msgSeqCounters.Load(chatID); ok { + if counter, ok := counterVal.(*atomic.Uint32); ok { + seq = counter.Add(1) } - if msgToCreate.Markdown != nil && msgToCreate.Markdown.Content != "" { - msgToCreate.Markdown.Content = sanitizeURLs(msgToCreate.Markdown.Content) - } - } - - // Route to group or C2C. - var err error - if chatKind == "group" { - _, err = c.api.PostGroupMessage(ctx, msg.ChatID, msgToCreate) } else { - _, err = c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate) + seq = rand.Uint32() } - - if err != nil { - logger.ErrorCF("qq", "Failed to send message", map[string]any{ - "chat_id": msg.ChatID, - "chat_kind": chatKind, - "error": err.Error(), - }) - return fmt.Errorf("qq send: %w", channels.ErrTemporary) - } - - return nil + return replyID, seq } // StartTyping implements channels.TypingCapable. @@ -304,85 +312,120 @@ func (c *QQChannel) StartTyping(ctx context.Context, chatID string) (func(), err } // SendMedia implements the channels.MediaSender interface. -// QQ RichMediaMessage requires an HTTP/HTTPS URL — local file paths are not supported. -// If part.Ref is already an http(s) URL it is used directly; otherwise we try -// the media store, and skip with a warning if the resolved path is not an HTTP URL. func (c *QQChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { if !c.IsRunning() { return channels.ErrNotRunning } - - chatKind := c.getChatKind(msg.ChatID) - for _, part := range msg.Parts { - // If the ref is already an HTTP(S) URL, use it directly. - mediaURL := part.Ref - if !isHTTPURL(mediaURL) { - // Try resolving through media store. - store := c.GetMediaStore() - if store == nil { - logger.WarnCF("qq", "QQ media requires HTTP/HTTPS URL, no media store available", map[string]any{ - "ref": part.Ref, - }) - continue - } - - resolved, err := store.Resolve(part.Ref) - if err != nil { - logger.ErrorCF("qq", "Failed to resolve media ref", map[string]any{ - "ref": part.Ref, - "error": err.Error(), - }) - continue - } - - if !isHTTPURL(resolved) { - logger.WarnCF("qq", "QQ media requires HTTP/HTTPS URL, local files not supported", map[string]any{ - "ref": part.Ref, - "resolved": resolved, - }) - continue - } - - mediaURL = resolved - } - - // Map part type to QQ file type: 1=image, 2=video, 3=audio, 4=file. - var fileType uint64 - switch part.Type { - case "image": - fileType = 1 - case "video": - fileType = 2 - case "audio": - fileType = 3 - default: - fileType = 4 // file - } - - richMedia := &dto.RichMediaMessage{ - FileType: fileType, - URL: mediaURL, - SrvSendMsg: true, - } - - var sendErr error - if chatKind == "group" { - _, sendErr = c.api.PostGroupMessage(ctx, msg.ChatID, richMedia) - } else { - _, sendErr = c.api.PostC2CMessage(ctx, msg.ChatID, richMedia) - } - - if sendErr != nil { + if err := c.sendOneMedia(ctx, msg.ChatID, part); err != nil { logger.ErrorCF("qq", "Failed to send media", map[string]any{ - "type": part.Type, - "chat_id": msg.ChatID, - "error": sendErr.Error(), + "part": part, + "error": err.Error(), }) - return fmt.Errorf("qq send media: %w", channels.ErrTemporary) + continue } } + return nil +} +// Upload file and then send it via API +// QQ groups do not support file sending +// When sending local files via QQ, the file size cannot exceed 10M +func (c *QQChannel) sendOneMedia(ctx context.Context, chatID string, part bus.MediaPart) error { + chatKind := c.getChatKind(chatID) + + mediaPath := part.Ref + var meta media.MediaMeta + if !isHTTPURL(mediaPath) { + store := c.GetMediaStore() + if store == nil { + logger.WarnCF("qq", "QQ media requires HTTP/HTTPS URL, no media store available", map[string]any{ + "ref": part.Ref, + }) + return fmt.Errorf("store not available") + } + var resolved string + var err error + resolved, meta, err = store.ResolveWithMeta(part.Ref) + if err != nil { + logger.ErrorCF("qq", "Failed to resolve media ref", map[string]any{ + "ref": part.Ref, + "error": err.Error(), + }) + return fmt.Errorf("store resolve failed") + } + mediaPath = resolved + } + + // Map part type to QQ file type: 1=image, 2=video, 3=audio, 4=file. + var fileType uint64 + switch part.Type { + case "image": + fileType = 1 + case "video": + fileType = 2 + case "audio": + fileType = 3 + default: + fileType = 4 // file + } + + richMedia := &RichMediaMessage{FileType: fileType} + if isHTTPURL(mediaPath) { + richMedia.URL = mediaPath + } else { + fdata, err := os.ReadFile(mediaPath) + if err != nil { + logger.ErrorCF("qq", "Failed to read media file[%v]", map[string]any{"path": mediaPath, + "error": err.Error()}) + return fmt.Errorf("read file failed") + } + richMedia.FileData = fdata + richMedia.FileName = meta.Filename + } + + if (chatKind == "group" && fileType == 4) || len(richMedia.FileData) > 10*1024*1024 { + logger.WarnCF("qq", "File size exceeds 10M, skipping send", map[string]any{ + "filename": richMedia.FileName, "size": len(richMedia.FileData)}) + return nil + } + + var sendErr error + var result *dto.Message + if chatKind == "group" { + result, sendErr = c.api.PostGroupMessage(ctx, chatID, richMedia) + } else { + result, sendErr = c.api.PostC2CMessage(ctx, chatID, richMedia) + } + + if sendErr != nil { + logger.ErrorCF("qq", "Failed to send media", map[string]any{ + "type": part.Type, + "chat_id": chatID, + "error": sendErr.Error(), + }) + return fmt.Errorf("qq send media: %w err:%v", channels.ErrTemporary, sendErr) + } + + msg := dto.MessageToCreate{ + MsgType: dto.RichMediaMsg, + Media: &dto.MediaInfo{FileInfo: result.FileInfo}, + } + msg.MsgID, msg.MsgSeq = c.getReplyExtInfo(ctx, chatID) + + if chatKind == "group" { + result, sendErr = c.api.PostGroupMessage(ctx, chatID, msg) + } else { + result, sendErr = c.api.PostC2CMessage(ctx, chatID, msg) + } + if sendErr != nil { + logger.ErrorCF("qq", "Failed to send media", map[string]any{ + "type": part.Type, + "chat_id": chatID, + "error": sendErr.Error(), + }) + return fmt.Errorf("qq send media: %w err:%v", channels.ErrTemporary, sendErr) + } return nil } @@ -403,10 +446,11 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { return nil } - // extract message content - content := data.Content + scope := channels.BuildMediaScope("qq", senderID, data.ID) + + content, mediaPaths := c.decodeMesasge(context.Background(), event, (*dto.Message)(data), scope) if content == "" { - logger.DebugC("qq", "Received empty message, ignoring") + logger.DebugC("qq", "Received empty C2C message, ignoring") return nil } @@ -420,7 +464,7 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { c.lastMsgID.Store(senderID, data.ID) // Reset msg_seq counter for new inbound message. - c.msgSeqCounters.Store(senderID, new(atomic.Uint64)) + c.msgSeqCounters.Store(senderID, new(atomic.Uint32)) metadata := map[string]string{} @@ -440,7 +484,7 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { senderID, senderID, content, - []string{}, + mediaPaths, metadata, sender, ) @@ -465,14 +509,13 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { logger.WarnC("qq", "Received group message with no sender ID") return nil } + scope := channels.BuildMediaScope("qq", data.GroupID, data.ID) - // extract message content (remove @ bot part) - content := data.Content + content, mediaPaths := c.decodeMesasge(context.Background(), event, (*dto.Message)(data), scope) if content == "" { logger.DebugC("qq", "Received empty group message, ignoring") return nil } - // GroupAT event means bot is always mentioned; apply group trigger filtering respond, cleaned := c.ShouldRespondInGroup(true, content) if !respond { @@ -489,10 +532,6 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { // Store chat routing context using GroupID as chatID. c.chatType.Store(data.GroupID, "group") c.lastMsgID.Store(data.GroupID, data.ID) - - // Reset msg_seq counter for new inbound message. - c.msgSeqCounters.Store(data.GroupID, new(atomic.Uint64)) - metadata := map[string]string{ "group_id": data.GroupID, } @@ -513,7 +552,7 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { senderID, data.GroupID, content, - []string{}, + mediaPaths, metadata, sender, ) @@ -578,6 +617,144 @@ func (c *QQChannel) dedupJanitor() { } } +func (c *QQChannel) decodeMesasge(ctx context.Context, event *dto.WSPayload, data *dto.Message, scope string) (content string, mediaPaths []string) { + + content = parseEmojiText(data.Content) + wavURL, asrReferText := getVoiceInfo(event) + if data.Attachments != nil && len(data.Attachments) > 0 { + var attachments []MessageAttachment + for _, att := range data.Attachments { + if att.ContentType == "voice" && wavURL != "" { + attachments = append(attachments, MessageAttachment{ + ContentType: "voice", + URL: wavURL, + FileName: filepath.Base(wavURL), + AsrReferText: asrReferText, + }) + continue + } else { + attachments = append(attachments, MessageAttachment{ + ContentType: att.ContentType, + URL: att.URL, + FileName: att.FileName, + }) + } + } + processedPaths, attachmentContent := c.processAttachments(ctx, attachments, scope) + if asrReferText != "" { + attachmentContent = fmt.Sprintf("[audio: %v]", asrReferText) + } + mediaPaths = processedPaths + if content != "" { + content += "\n" + } + content += attachmentContent + } + return content, mediaPaths +} + +// processAttachments processes all attachments in a message +func (c *QQChannel) processAttachments(ctx context.Context, attachments []MessageAttachment, scope string) ([]string, string) { + mediaPaths := []string{} + content := "" + + // Helper to register a local file with the media store + storeMedia := func(localPath, filename string) string { + store := c.GetMediaStore() + if store == nil { + logger.ErrorCF("qq", "media store is nil", map[string]any{ + "scope": scope, + }) + return "" + } + ref, err := store.Store(localPath, media.MediaMeta{Filename: filename, Source: "qq"}, scope) + if err != nil { + logger.InfoCF("qq", "Stored media", map[string]any{ + "scope": scope, + "localPath": localPath, + "filename": filename, + }) + return ref + } + logger.ErrorCF("qq", "Stored media", map[string]any{ + "scope": scope, + "localPath": localPath, + "ref": ref, + }) + return localPath + } + + for _, attachment := range attachments { + attachmentType := c.getAttachmentType(attachment) + localPath := c.downloadAttachment(ctx, attachment) + if localPath == "" { + mediaPaths = append(mediaPaths, attachment.URL) + content += appendContent(content, fmt.Sprintf("[%v: %s]", attachment.ContentType, attachment.URL)) + continue + } + ref := storeMedia(localPath, attachment.FileName) + mediaPaths = append(mediaPaths, ref) + if attachmentType == "audio" && attachment.AsrReferText != "" { + content += appendContent(content, fmt.Sprintf("[audio: %s]", attachment.AsrReferText)) + continue + } + content += appendContent(content, fmt.Sprintf("[%v: %s]", attachment.ContentType, ref)) + } + + return mediaPaths, content +} + +// downloadAttachment downloads an attachment from QQ server +func (c *QQChannel) downloadAttachment(ctx context.Context, attachment MessageAttachment) string { + logger.InfoCF("qq", "Downloading attachment", map[string]any{ + "attachment": attachment, + }) + return utils.DownloadFile(attachment.URL, attachment.FileName, utils.DownloadOptions{ + LoggerPrefix: "qq", + }) +} + +// getAttachmentType determines the type of attachment (image, audio, video, file) +func (c *QQChannel) getAttachmentType(attachment MessageAttachment) string { + + if strings.HasPrefix(attachment.ContentType, "image") { + return "image" + } else if strings.HasPrefix(attachment.ContentType, "video") { + return "video" + } else if strings.HasPrefix(attachment.ContentType, "voice") { + return "audio" + } + return "file" +} + +// appendContent safely appends content to existing text +func appendContent(content, suffix string) string { + if content == "" { + return suffix + } + return content + "\n" + suffix +} + +func getVoiceInfo(event *dto.WSPayload) (string, string) { + _raw, err := json.Marshal(event.Data) + if err != nil { + logger.ErrorCF("qq", "Failed to marshal event data", map[string]any{ + "error": err.Error(), + }) + return "", "" + } + // 使用gjson提取voice_wav_url字段 + rawJSON := string(_raw) + + // 首先尝试从attachments数组的第一个元素中提取voice_wav_url + voiceWavURL := gjson.Get(rawJSON, "attachments.0.voice_wav_url").String() + asrReferText := gjson.Get(rawJSON, "attachments.0.asr_refer_text").String() + logger.DebugCF("qq", "Found voice_wav_url in attachments", map[string]any{ + "url": voiceWavURL, "asr_refer_text": asrReferText, + }) + return voiceWavURL, asrReferText +} + // isHTTPURL returns true if s starts with http:// or https://. func isHTTPURL(s string) bool { return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") @@ -621,3 +798,46 @@ func sanitizeURLs(text string) string { return scheme + domain + path }) } + +// parseEmojiText decodes emoji text +func parseEmojiText(content string) string { + content = strings.ReplaceAll(content, `\\`, `\`) + content = strings.ReplaceAll(content, "\\u003c", "<") + content = strings.ReplaceAll(content, "\\u003e", ">") + content = strings.ReplaceAll(content, `\"`, `"`) + + combinedRegexp := regexp.MustCompile(`<[^<]*?ext="([^"]+)"[^<]*?faceType=(\d+)[^<]*?>|<[^<]*?faceType=(\d+)[^<]*?ext="([^"]+)"[^<]*?>`) + + contentParts := combinedRegexp.Split(content, -1) + matches := combinedRegexp.FindAllString(content, -1) + + var result strings.Builder + for i, part := range contentParts { + if strings.TrimSpace(part) != "" { + result.WriteString(part) + } + if i < len(matches) { + match := matches[i] + if strings.Contains(match, "faceType=") { + result.WriteString(processEmoji(match)) + } + } + } + + return result.String() +} + +func processEmoji(match string) string { + extRegexp := regexp.MustCompile(`ext="([^"]+)"`) + extMatch := extRegexp.FindStringSubmatch(match) + + if len(extMatch) > 1 { + ext, err := base64.StdEncoding.DecodeString(extMatch[1]) + if err == nil { + var faceDesc map[string]string + json.Unmarshal(ext, &faceDesc) + return fmt.Sprintf("[表情 %v]", faceDesc["text"]) + } + } + return "" +} From 3d0cee50569f3a44d2a1922cf9258e0af2f34634 Mon Sep 17 00:00:00 2001 From: aishannon Date: Thu, 12 Mar 2026 17:30:14 +0800 Subject: [PATCH 02/28] fix cr --- pkg/channels/qq/qq.go | 71 +++++++++++++++++++++---------------------- 1 file changed, 35 insertions(+), 36 deletions(-) diff --git a/pkg/channels/qq/qq.go b/pkg/channels/qq/qq.go index 7bcd12800..05e2c4f3a 100644 --- a/pkg/channels/qq/qq.go +++ b/pkg/channels/qq/qq.go @@ -5,10 +5,7 @@ import ( "encoding/base64" "encoding/json" "fmt" - "github.com/sipeed/picoclaw/pkg/media" - "github.com/sipeed/picoclaw/pkg/utils" - "github.com/tidwall/gjson" - "math/rand" + "math" "os" "path/filepath" "regexp" @@ -17,6 +14,10 @@ import ( "sync/atomic" "time" + "github.com/sipeed/picoclaw/pkg/media" + "github.com/sipeed/picoclaw/pkg/utils" + "github.com/tidwall/gjson" + "github.com/tencent-connect/botgo" "github.com/tencent-connect/botgo/dto" "github.com/tencent-connect/botgo/event" @@ -36,9 +37,11 @@ const ( dedupInterval = 60 * time.Second dedupMaxSize = 10000 // hard cap on dedup map entries typingResend = 8 * time.Second - typingSeconds = 10 + typingSeconds = 20 ) +var emojiRegexp = regexp.MustCompile(`<[^<]*?ext="([^"]+)"[^<]*?faceType=(\d+)[^<]*?>|<[^<]*?faceType=(\d+)[^<]*?ext="([^"]+)"[^<]*?>`) + type QQChannel struct { *channels.BaseChannel config config.QQConfig @@ -54,8 +57,8 @@ type QQChannel struct { // Passive reply: store last inbound message ID per chat. lastMsgID sync.Map // chatID → string - // msg_seq: per-chat atomic counter for multi-part replies. - msgSeqCounters sync.Map // chatID → *atomic.Uint32 + replySeq atomic.Uint32 + seqLock sync.Mutex // Time-based dedup replacing the unbounded map. dedup map[string]time.Time @@ -192,14 +195,13 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { chatKind := c.getChatKind(msg.ChatID) textMsg, mdMsg := c.genReplyMsg(ctx, msg, chatKind) - - for _, _v := range []dto.MessageToCreate{mdMsg, textMsg} { - var err error + var err error + for _, replyMsg := range []dto.MessageToCreate{mdMsg, textMsg} { var replyMsgID *dto.Message if chatKind == "group" { - replyMsgID, err = c.api.PostGroupMessage(ctx, msg.ChatID, _v) + replyMsgID, err = c.api.PostGroupMessage(ctx, msg.ChatID, replyMsg) } else { - replyMsgID, err = c.api.PostC2CMessage(ctx, msg.ChatID, _v) + replyMsgID, err = c.api.PostC2CMessage(ctx, msg.ChatID, replyMsg) } if err == nil { logger.InfoCF("qq", "Sent message", map[string]any{"postrsp ": replyMsgID}) @@ -213,7 +215,7 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { }) } } - return nil + return err } func (c *QQChannel) genReplyMsg(ctx context.Context, msg bus.OutboundMessage, chatKind string) (dto.MessageToCreate, @@ -240,14 +242,15 @@ func (c *QQChannel) getReplyExtInfo(ctx context.Context, chatID string) (replyID replyID = msgID } } - // Increment msg_seq atomically for multi-part replies. - if counterVal, ok := c.msgSeqCounters.Load(chatID); ok { - if counter, ok := counterVal.(*atomic.Uint32); ok { - seq = counter.Add(1) - } - } else { - seq = rand.Uint32() + + // Attach msg_seq for active reply. + c.seqLock.Lock() + defer c.seqLock.Unlock() + seq = c.replySeq.Add(1) + if seq > math.MaxInt32 { + c.replySeq.Store(1) } + return replyID, seq } @@ -448,7 +451,7 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { scope := channels.BuildMediaScope("qq", senderID, data.ID) - content, mediaPaths := c.decodeMesasge(context.Background(), event, (*dto.Message)(data), scope) + content, mediaPaths := c.decodeMessage(context.Background(), event, (*dto.Message)(data), scope) if content == "" { logger.DebugC("qq", "Received empty C2C message, ignoring") return nil @@ -463,9 +466,6 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { c.chatType.Store(senderID, "direct") c.lastMsgID.Store(senderID, data.ID) - // Reset msg_seq counter for new inbound message. - c.msgSeqCounters.Store(senderID, new(atomic.Uint32)) - metadata := map[string]string{} sender := bus.SenderInfo{ @@ -511,7 +511,7 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { } scope := channels.BuildMediaScope("qq", data.GroupID, data.ID) - content, mediaPaths := c.decodeMesasge(context.Background(), event, (*dto.Message)(data), scope) + content, mediaPaths := c.decodeMessage(context.Background(), event, (*dto.Message)(data), scope) if content == "" { logger.DebugC("qq", "Received empty group message, ignoring") return nil @@ -532,6 +532,7 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { // Store chat routing context using GroupID as chatID. c.chatType.Store(data.GroupID, "group") c.lastMsgID.Store(data.GroupID, data.ID) + metadata := map[string]string{ "group_id": data.GroupID, } @@ -617,7 +618,8 @@ func (c *QQChannel) dedupJanitor() { } } -func (c *QQChannel) decodeMesasge(ctx context.Context, event *dto.WSPayload, data *dto.Message, scope string) (content string, mediaPaths []string) { +func (c *QQChannel) decodeMessage(ctx context.Context, event *dto.WSPayload, data *dto.Message, + scope string) (content string, mediaPaths []string) { content = parseEmojiText(data.Content) wavURL, asrReferText := getVoiceInfo(event) @@ -654,9 +656,8 @@ func (c *QQChannel) decodeMesasge(ctx context.Context, event *dto.WSPayload, dat } // processAttachments processes all attachments in a message -func (c *QQChannel) processAttachments(ctx context.Context, attachments []MessageAttachment, scope string) ([]string, string) { - mediaPaths := []string{} - content := "" +func (c *QQChannel) processAttachments(ctx context.Context, attachments []MessageAttachment, + scope string) (mediaPaths []string, content string) { // Helper to register a local file with the media store storeMedia := func(localPath, filename string) string { @@ -668,7 +669,7 @@ func (c *QQChannel) processAttachments(ctx context.Context, attachments []Messag return "" } ref, err := store.Store(localPath, media.MediaMeta{Filename: filename, Source: "qq"}, scope) - if err != nil { + if err == nil { logger.InfoCF("qq", "Stored media", map[string]any{ "scope": scope, "localPath": localPath, @@ -676,10 +677,10 @@ func (c *QQChannel) processAttachments(ctx context.Context, attachments []Messag }) return ref } - logger.ErrorCF("qq", "Stored media", map[string]any{ + logger.ErrorCF("qq", "Stored media err ", map[string]any{ "scope": scope, "localPath": localPath, - "ref": ref, + "err": err.Error(), }) return localPath } @@ -806,10 +807,8 @@ func parseEmojiText(content string) string { content = strings.ReplaceAll(content, "\\u003e", ">") content = strings.ReplaceAll(content, `\"`, `"`) - combinedRegexp := regexp.MustCompile(`<[^<]*?ext="([^"]+)"[^<]*?faceType=(\d+)[^<]*?>|<[^<]*?faceType=(\d+)[^<]*?ext="([^"]+)"[^<]*?>`) - - contentParts := combinedRegexp.Split(content, -1) - matches := combinedRegexp.FindAllString(content, -1) + contentParts := emojiRegexp.Split(content, -1) + matches := emojiRegexp.FindAllString(content, -1) var result strings.Builder for i, part := range contentParts { From 54f870c2559d2d7b29fb9487744138632c0c874b Mon Sep 17 00:00:00 2001 From: sky5454 Date: Sun, 15 Mar 2026 18:02:26 +0800 Subject: [PATCH 03/28] feat/sec add github's dependabot to scan the lib sec. --- .github/dependabot.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..559a2249e --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,27 @@ +version: 2 + +updates: + + # Go dependencies (entire repo) + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" + labels: + - "dependencies" + - "go" + + # Frontend dependencies + - package-ecosystem: "npm" + directory: "/web/frontend" + schedule: + interval: "weekly" + labels: + - "dependencies" + - "frontend" + + # GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" \ No newline at end of file From 8da51303707dd689e3912129a285a9178f8720d9 Mon Sep 17 00:00:00 2001 From: aishannon Date: Thu, 12 Mar 2026 17:30:14 +0800 Subject: [PATCH 04/28] fix cr --- pkg/channels/qq/qq.go | 79 ++++++++++++++++++++----------------------- 1 file changed, 37 insertions(+), 42 deletions(-) diff --git a/pkg/channels/qq/qq.go b/pkg/channels/qq/qq.go index ebd21d213..05e2c4f3a 100644 --- a/pkg/channels/qq/qq.go +++ b/pkg/channels/qq/qq.go @@ -5,10 +5,7 @@ import ( "encoding/base64" "encoding/json" "fmt" - "github.com/sipeed/picoclaw/pkg/media" - "github.com/sipeed/picoclaw/pkg/utils" - "github.com/tidwall/gjson" - "math/rand" + "math" "os" "path/filepath" "regexp" @@ -17,6 +14,10 @@ import ( "sync/atomic" "time" + "github.com/sipeed/picoclaw/pkg/media" + "github.com/sipeed/picoclaw/pkg/utils" + "github.com/tidwall/gjson" + "github.com/tencent-connect/botgo" "github.com/tencent-connect/botgo/dto" "github.com/tencent-connect/botgo/event" @@ -36,9 +37,11 @@ const ( dedupInterval = 60 * time.Second dedupMaxSize = 10000 // hard cap on dedup map entries typingResend = 8 * time.Second - typingSeconds = 10 + typingSeconds = 20 ) +var emojiRegexp = regexp.MustCompile(`<[^<]*?ext="([^"]+)"[^<]*?faceType=(\d+)[^<]*?>|<[^<]*?faceType=(\d+)[^<]*?ext="([^"]+)"[^<]*?>`) + type QQChannel struct { *channels.BaseChannel config config.QQConfig @@ -54,8 +57,8 @@ type QQChannel struct { // Passive reply: store last inbound message ID per chat. lastMsgID sync.Map // chatID → string - // msg_seq: per-chat atomic counter for multi-part replies. - msgSeqCounters sync.Map // chatID → *atomic.Uint32 + replySeq atomic.Uint32 + seqLock sync.Mutex // Time-based dedup replacing the unbounded map. dedup map[string]time.Time @@ -86,7 +89,6 @@ func (c *QQChannel) Start(ctx context.Context) error { return fmt.Errorf("QQ app_id and app_secret not configured") } - botgo.SetLogger(logger.NewLogger("botgo")) logger.InfoC("qq", "Starting QQ bot (WebSocket mode)") // Reinitialize shutdown signal for clean restart. @@ -193,14 +195,13 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { chatKind := c.getChatKind(msg.ChatID) textMsg, mdMsg := c.genReplyMsg(ctx, msg, chatKind) - - for _, _v := range []dto.MessageToCreate{mdMsg, textMsg} { - var err error + var err error + for _, replyMsg := range []dto.MessageToCreate{mdMsg, textMsg} { var replyMsgID *dto.Message if chatKind == "group" { - replyMsgID, err = c.api.PostGroupMessage(ctx, msg.ChatID, _v) + replyMsgID, err = c.api.PostGroupMessage(ctx, msg.ChatID, replyMsg) } else { - replyMsgID, err = c.api.PostC2CMessage(ctx, msg.ChatID, _v) + replyMsgID, err = c.api.PostC2CMessage(ctx, msg.ChatID, replyMsg) } if err == nil { logger.InfoCF("qq", "Sent message", map[string]any{"postrsp ": replyMsgID}) @@ -214,7 +215,7 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { }) } } - return nil + return err } func (c *QQChannel) genReplyMsg(ctx context.Context, msg bus.OutboundMessage, chatKind string) (dto.MessageToCreate, @@ -241,14 +242,15 @@ func (c *QQChannel) getReplyExtInfo(ctx context.Context, chatID string) (replyID replyID = msgID } } - // Increment msg_seq atomically for multi-part replies. - if counterVal, ok := c.msgSeqCounters.Load(chatID); ok { - if counter, ok := counterVal.(*atomic.Uint32); ok { - seq = counter.Add(1) - } - } else { - seq = rand.Uint32() + + // Attach msg_seq for active reply. + c.seqLock.Lock() + defer c.seqLock.Unlock() + seq = c.replySeq.Add(1) + if seq > math.MaxInt32 { + c.replySeq.Store(1) } + return replyID, seq } @@ -449,7 +451,7 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { scope := channels.BuildMediaScope("qq", senderID, data.ID) - content, mediaPaths := c.decodeMesasge(context.Background(), event, (*dto.Message)(data), scope) + content, mediaPaths := c.decodeMessage(context.Background(), event, (*dto.Message)(data), scope) if content == "" { logger.DebugC("qq", "Received empty C2C message, ignoring") return nil @@ -464,12 +466,7 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { c.chatType.Store(senderID, "direct") c.lastMsgID.Store(senderID, data.ID) - // Reset msg_seq counter for new inbound message. - c.msgSeqCounters.Store(senderID, new(atomic.Uint32)) - - metadata := map[string]string{ - "account_id": senderID, - } + metadata := map[string]string{} sender := bus.SenderInfo{ Platform: "qq", @@ -514,7 +511,7 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { } scope := channels.BuildMediaScope("qq", data.GroupID, data.ID) - content, mediaPaths := c.decodeMesasge(context.Background(), event, (*dto.Message)(data), scope) + content, mediaPaths := c.decodeMessage(context.Background(), event, (*dto.Message)(data), scope) if content == "" { logger.DebugC("qq", "Received empty group message, ignoring") return nil @@ -535,9 +532,9 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { // Store chat routing context using GroupID as chatID. c.chatType.Store(data.GroupID, "group") c.lastMsgID.Store(data.GroupID, data.ID) + metadata := map[string]string{ - "account_id": senderID, - "group_id": data.GroupID, + "group_id": data.GroupID, } sender := bus.SenderInfo{ @@ -621,7 +618,8 @@ func (c *QQChannel) dedupJanitor() { } } -func (c *QQChannel) decodeMesasge(ctx context.Context, event *dto.WSPayload, data *dto.Message, scope string) (content string, mediaPaths []string) { +func (c *QQChannel) decodeMessage(ctx context.Context, event *dto.WSPayload, data *dto.Message, + scope string) (content string, mediaPaths []string) { content = parseEmojiText(data.Content) wavURL, asrReferText := getVoiceInfo(event) @@ -658,9 +656,8 @@ func (c *QQChannel) decodeMesasge(ctx context.Context, event *dto.WSPayload, dat } // processAttachments processes all attachments in a message -func (c *QQChannel) processAttachments(ctx context.Context, attachments []MessageAttachment, scope string) ([]string, string) { - mediaPaths := []string{} - content := "" +func (c *QQChannel) processAttachments(ctx context.Context, attachments []MessageAttachment, + scope string) (mediaPaths []string, content string) { // Helper to register a local file with the media store storeMedia := func(localPath, filename string) string { @@ -672,7 +669,7 @@ func (c *QQChannel) processAttachments(ctx context.Context, attachments []Messag return "" } ref, err := store.Store(localPath, media.MediaMeta{Filename: filename, Source: "qq"}, scope) - if err != nil { + if err == nil { logger.InfoCF("qq", "Stored media", map[string]any{ "scope": scope, "localPath": localPath, @@ -680,10 +677,10 @@ func (c *QQChannel) processAttachments(ctx context.Context, attachments []Messag }) return ref } - logger.ErrorCF("qq", "Stored media", map[string]any{ + logger.ErrorCF("qq", "Stored media err ", map[string]any{ "scope": scope, "localPath": localPath, - "ref": ref, + "err": err.Error(), }) return localPath } @@ -810,10 +807,8 @@ func parseEmojiText(content string) string { content = strings.ReplaceAll(content, "\\u003e", ">") content = strings.ReplaceAll(content, `\"`, `"`) - combinedRegexp := regexp.MustCompile(`<[^<]*?ext="([^"]+)"[^<]*?faceType=(\d+)[^<]*?>|<[^<]*?faceType=(\d+)[^<]*?ext="([^"]+)"[^<]*?>`) - - contentParts := combinedRegexp.Split(content, -1) - matches := combinedRegexp.FindAllString(content, -1) + contentParts := emojiRegexp.Split(content, -1) + matches := emojiRegexp.FindAllString(content, -1) var result strings.Builder for i, part := range contentParts { From f6937f20875800061cc2a1ca95ac5abfa3e30962 Mon Sep 17 00:00:00 2001 From: aishannon Date: Sun, 15 Mar 2026 19:15:13 +0800 Subject: [PATCH 05/28] 1. fix seq reset. 2. remove SendMarkdown config --- pkg/channels/qq/qq.go | 8 +++++--- pkg/config/config.go | 1 - 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/pkg/channels/qq/qq.go b/pkg/channels/qq/qq.go index 05e2c4f3a..e37645855 100644 --- a/pkg/channels/qq/qq.go +++ b/pkg/channels/qq/qq.go @@ -37,7 +37,7 @@ const ( dedupInterval = 60 * time.Second dedupMaxSize = 10000 // hard cap on dedup map entries typingResend = 8 * time.Second - typingSeconds = 20 + typingSeconds = 10 ) var emojiRegexp = regexp.MustCompile(`<[^<]*?ext="([^"]+)"[^<]*?faceType=(\d+)[^<]*?>|<[^<]*?faceType=(\d+)[^<]*?ext="([^"]+)"[^<]*?>`) @@ -57,6 +57,8 @@ type QQChannel struct { // Passive reply: store last inbound message ID per chat. lastMsgID sync.Map // chatID → string + // 消息回复时API时的SEQ,用于回复时去重 + // 被动回复: 同一个chatID+replyMsgID+seq 去重。 主动回复:不限制。 replySeq atomic.Uint32 seqLock sync.Mutex @@ -248,9 +250,9 @@ func (c *QQChannel) getReplyExtInfo(ctx context.Context, chatID string) (replyID defer c.seqLock.Unlock() seq = c.replySeq.Add(1) if seq > math.MaxInt32 { - c.replySeq.Store(1) + c.replySeq.Store(0) + seq = c.replySeq.Add(1) } - return replyID, seq } diff --git a/pkg/config/config.go b/pkg/config/config.go index 190341224..c98e04b82 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -351,7 +351,6 @@ type QQConfig struct { AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` MaxMessageLength int `json:"max_message_length" env:"PICOCLAW_CHANNELS_QQ_MAX_MESSAGE_LENGTH"` - SendMarkdown bool `json:"send_markdown" env:"PICOCLAW_CHANNELS_QQ_SEND_MARKDOWN"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID"` } From 71e2b636d66c9d30250f027085d142d93d4246be Mon Sep 17 00:00:00 2001 From: BitToby <218712309+bittoby@users.noreply.github.com> Date: Mon, 16 Mar 2026 03:58:37 +0200 Subject: [PATCH 06/28] fix: Use secure defaults for Pico channel setup and stop leaking the token in the URL (#1563) * fix: Use secure defaults for Pico channel setup and stop leaking the token in the URL * fix: Derive default allow_origins from the setup request's Origin header instead of hardcoding localhost ports --- pkg/channels/pico/pico.go | 31 ++- web/backend/api/gateway.go | 2 +- web/backend/api/pico.go | 24 +- web/backend/api/pico_test.go | 237 +++++++++++++++++++ web/frontend/src/lib/pico-chat-controller.ts | 5 +- 5 files changed, 281 insertions(+), 18 deletions(-) create mode 100644 web/backend/api/pico_test.go diff --git a/pkg/channels/pico/pico.go b/pkg/channels/pico/pico.go index 8d8b62a67..206e71f92 100644 --- a/pkg/channels/pico/pico.go +++ b/pkg/channels/pico/pico.go @@ -251,7 +251,13 @@ func (c *PicoChannel) handleWebSocket(w http.ResponseWriter, r *http.Request) { return } - conn, err := c.upgrader.Upgrade(w, r, nil) + // Echo the matched subprotocol back so the browser accepts the upgrade. + var responseHeader http.Header + if proto := c.matchedSubprotocol(r); proto != "" { + responseHeader = http.Header{"Sec-WebSocket-Protocol": {proto}} + } + + conn, err := c.upgrader.Upgrade(w, r, responseHeader) if err != nil { logger.ErrorCF("pico", "WebSocket upgrade failed", map[string]any{ "error": err.Error(), @@ -282,8 +288,10 @@ func (c *PicoChannel) handleWebSocket(w http.ResponseWriter, r *http.Request) { go c.readLoop(pc) } -// authenticate checks the Bearer token from the Authorization header. -// Query parameter authentication is only allowed when AllowTokenQuery is explicitly enabled. +// authenticate checks the request for a valid token: +// 1. Authorization: Bearer header +// 2. Sec-WebSocket-Protocol "token." (for browsers that can't set headers) +// 3. Query parameter "token" (only when AllowTokenQuery is on) func (c *PicoChannel) authenticate(r *http.Request) bool { token := c.config.Token if token == "" { @@ -298,6 +306,11 @@ func (c *PicoChannel) authenticate(r *http.Request) bool { } } + // Check Sec-WebSocket-Protocol subprotocol ("token.") + if c.matchedSubprotocol(r) != "" { + return true + } + // Check query parameter only when explicitly allowed if c.config.AllowTokenQuery { if r.URL.Query().Get("token") == token { @@ -308,6 +321,18 @@ func (c *PicoChannel) authenticate(r *http.Request) bool { return false } +// matchedSubprotocol returns the "token." subprotocol that matches +// the configured token, or "" if none do. +func (c *PicoChannel) matchedSubprotocol(r *http.Request) string { + token := c.config.Token + for _, proto := range websocket.Subprotocols(r) { + if after, ok := strings.CutPrefix(proto, "token."); ok && after == token { + return proto + } + } + return "" +} + // readLoop reads messages from a WebSocket connection. func (c *PicoChannel) readLoop(pc *picoConn) { defer func() { diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go index 1813cac92..f50f7609a 100644 --- a/web/backend/api/gateway.go +++ b/web/backend/api/gateway.go @@ -281,7 +281,7 @@ func (h *Handler) startGatewayLocked(initialStatus string) (int, error) { gateway.logs.Reset() // Ensure Pico Channel is configured before starting gateway - if _, err := h.ensurePicoChannel(); err != nil { + if _, err := h.ensurePicoChannel(""); err != nil { log.Printf("Warning: failed to ensure pico channel: %v", err) // Non-fatal: gateway can still start without pico channel } diff --git a/web/backend/api/pico.go b/web/backend/api/pico.go index a4590dcde..2d2201e16 100644 --- a/web/backend/api/pico.go +++ b/web/backend/api/pico.go @@ -65,9 +65,14 @@ func (h *Handler) handleRegenPicoToken(w http.ResponseWriter, r *http.Request) { }) } -// ensurePicoChannel checks if the Pico Channel is properly configured and -// enables it with sensible defaults if not. Returns true if config was changed. -func (h *Handler) ensurePicoChannel() (bool, error) { +// ensurePicoChannel enables the Pico channel with sane defaults if it isn't +// already configured. Returns true when the config was modified. +// +// callerOrigin is the Origin header from the setup request. If non-empty and +// no origins are configured yet, it's written as the allowed origin so the +// WebSocket handshake works for whatever host the caller is on (LAN, custom +// port, etc.). Pass "" when there's no request context. +func (h *Handler) ensurePicoChannel(callerOrigin string) (bool, error) { cfg, err := config.LoadConfig(h.configPath) if err != nil { return false, fmt.Errorf("failed to load config: %w", err) @@ -85,14 +90,9 @@ func (h *Handler) ensurePicoChannel() (bool, error) { changed = true } - if !cfg.Channels.Pico.AllowTokenQuery { - cfg.Channels.Pico.AllowTokenQuery = true - changed = true - } - - // Make sure origins are allowed (frontend might be running on a different port like 5173 during dev) - if len(cfg.Channels.Pico.AllowOrigins) == 0 { - cfg.Channels.Pico.AllowOrigins = []string{"*"} + // Seed origins from the request instead of hardcoding ports. + if len(cfg.Channels.Pico.AllowOrigins) == 0 && callerOrigin != "" { + cfg.Channels.Pico.AllowOrigins = []string{callerOrigin} changed = true } @@ -109,7 +109,7 @@ func (h *Handler) ensurePicoChannel() (bool, error) { // // POST /api/pico/setup func (h *Handler) handlePicoSetup(w http.ResponseWriter, r *http.Request) { - changed, err := h.ensurePicoChannel() + changed, err := h.ensurePicoChannel(r.Header.Get("Origin")) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return diff --git a/web/backend/api/pico_test.go b/web/backend/api/pico_test.go new file mode 100644 index 000000000..46149fa09 --- /dev/null +++ b/web/backend/api/pico_test.go @@ -0,0 +1,237 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestEnsurePicoChannel_FreshConfig(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + changed, err := h.ensurePicoChannel("") + if err != nil { + t.Fatalf("ensurePicoChannel() error = %v", err) + } + if !changed { + t.Fatal("ensurePicoChannel() should report changed on a fresh config") + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + if !cfg.Channels.Pico.Enabled { + t.Error("expected Pico to be enabled after setup") + } + if cfg.Channels.Pico.Token == "" { + t.Error("expected a non-empty token after setup") + } +} + +func TestEnsurePicoChannel_DoesNotEnableTokenQuery(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + if _, err := h.ensurePicoChannel(""); err != nil { + t.Fatalf("ensurePicoChannel() error = %v", err) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + if cfg.Channels.Pico.AllowTokenQuery { + t.Error("setup must not enable allow_token_query by default") + } +} + +func TestEnsurePicoChannel_DoesNotSetWildcardOrigins(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + if _, err := h.ensurePicoChannel("http://localhost:18800"); err != nil { + t.Fatalf("ensurePicoChannel() error = %v", err) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + for _, origin := range cfg.Channels.Pico.AllowOrigins { + if origin == "*" { + t.Error("setup must not set wildcard origin '*'") + } + } +} + +func TestEnsurePicoChannel_NoOriginWithoutCaller(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + if _, err := h.ensurePicoChannel(""); err != nil { + t.Fatalf("ensurePicoChannel() error = %v", err) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + // Without a caller origin, allow_origins stays empty (CheckOrigin + // allows all when the list is empty, so the channel still works). + if len(cfg.Channels.Pico.AllowOrigins) != 0 { + t.Errorf("allow_origins = %v, want empty when no caller origin", cfg.Channels.Pico.AllowOrigins) + } +} + +func TestEnsurePicoChannel_SetsCallerOrigin(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + lanOrigin := "http://192.168.1.9:18800" + if _, err := h.ensurePicoChannel(lanOrigin); err != nil { + t.Fatalf("ensurePicoChannel() error = %v", err) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + if len(cfg.Channels.Pico.AllowOrigins) != 1 || cfg.Channels.Pico.AllowOrigins[0] != lanOrigin { + t.Errorf("allow_origins = %v, want [%s]", cfg.Channels.Pico.AllowOrigins, lanOrigin) + } +} + +func TestEnsurePicoChannel_PreservesUserSettings(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + + // Pre-configure with custom user settings + cfg := config.DefaultConfig() + cfg.Channels.Pico.Enabled = true + cfg.Channels.Pico.Token = "user-custom-token" + cfg.Channels.Pico.AllowTokenQuery = true + cfg.Channels.Pico.AllowOrigins = []string{"https://myapp.example.com"} + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + + changed, err := h.ensurePicoChannel("") + if err != nil { + t.Fatalf("ensurePicoChannel() error = %v", err) + } + if changed { + t.Error("ensurePicoChannel() should not change a fully configured config") + } + + cfg, err = config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + if cfg.Channels.Pico.Token != "user-custom-token" { + t.Errorf("token = %q, want %q", cfg.Channels.Pico.Token, "user-custom-token") + } + if !cfg.Channels.Pico.AllowTokenQuery { + t.Error("user's allow_token_query=true must be preserved") + } + if len(cfg.Channels.Pico.AllowOrigins) != 1 || cfg.Channels.Pico.AllowOrigins[0] != "https://myapp.example.com" { + t.Errorf("allow_origins = %v, want [https://myapp.example.com]", cfg.Channels.Pico.AllowOrigins) + } +} + +func TestEnsurePicoChannel_Idempotent(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + origin := "http://localhost:18800" + + // First call sets things up + if _, err := h.ensurePicoChannel(origin); err != nil { + t.Fatalf("first ensurePicoChannel() error = %v", err) + } + + cfg1, _ := config.LoadConfig(configPath) + token1 := cfg1.Channels.Pico.Token + + // Second call should be a no-op + changed, err := h.ensurePicoChannel(origin) + if err != nil { + t.Fatalf("second ensurePicoChannel() error = %v", err) + } + if changed { + t.Error("second ensurePicoChannel() should not report changed") + } + + cfg2, _ := config.LoadConfig(configPath) + if cfg2.Channels.Pico.Token != token1 { + t.Error("token should not change on subsequent calls") + } +} + +func TestHandlePicoSetup_IncludesRequestOrigin(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + req := httptest.NewRequest("POST", "/api/pico/setup", nil) + req.Header.Set("Origin", "http://10.0.0.5:3000") + rec := httptest.NewRecorder() + + h.handlePicoSetup(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + if len(cfg.Channels.Pico.AllowOrigins) != 1 || cfg.Channels.Pico.AllowOrigins[0] != "http://10.0.0.5:3000" { + t.Errorf("allow_origins = %v, want [http://10.0.0.5:3000]", cfg.Channels.Pico.AllowOrigins) + } +} + +func TestHandlePicoSetup_Response(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + req := httptest.NewRequest("POST", "/api/pico/setup", nil) + rec := httptest.NewRecorder() + + h.handlePicoSetup(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var resp map[string]any + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if resp["token"] == nil || resp["token"] == "" { + t.Error("response should contain a non-empty token") + } + if resp["ws_url"] == nil || resp["ws_url"] == "" { + t.Error("response should contain ws_url") + } + if resp["enabled"] != true { + t.Error("response should have enabled=true") + } + if resp["changed"] != true { + t.Error("response should have changed=true on first setup") + } +} diff --git a/web/frontend/src/lib/pico-chat-controller.ts b/web/frontend/src/lib/pico-chat-controller.ts index be3397bae..0e77d1ad0 100644 --- a/web/frontend/src/lib/pico-chat-controller.ts +++ b/web/frontend/src/lib/pico-chat-controller.ts @@ -165,8 +165,9 @@ export async function connectChat() { console.warn("Could not parse ws_url:", error) } - const url = `${finalWsUrl}?token=${encodeURIComponent(token)}&session_id=${encodeURIComponent(activeSessionIdRef)}` - const socket = new WebSocket(url) + const url = `${finalWsUrl}?session_id=${encodeURIComponent(activeSessionIdRef)}` + // Send token as a subprotocol so it doesn't end up in the URL. + const socket = new WebSocket(url, [`token.${token}`]) if (generation !== connectionGeneration) { socket.close() From 45c01f4d91bd2f1ec7d0945fbca09372571850b9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 10:42:04 +0800 Subject: [PATCH 07/28] chore(deps): bump golang.org/x/oauth2 from 0.35.0 to 0.36.0 (#1596) Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.35.0 to 0.36.0. - [Commits](https://github.com/golang/oauth2/compare/v0.35.0...v0.36.0) --- updated-dependencies: - dependency-name: golang.org/x/oauth2 dependency-version: 0.36.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index f29ef7207..a2ce5c511 100644 --- a/go.mod +++ b/go.mod @@ -27,7 +27,7 @@ require ( github.com/stretchr/testify v1.11.1 github.com/tencent-connect/botgo v0.2.1 go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4 - golang.org/x/oauth2 v0.35.0 + golang.org/x/oauth2 v0.36.0 golang.org/x/time v0.14.0 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index addbab56c..c8ee84e87 100644 --- a/go.sum +++ b/go.sum @@ -270,8 +270,8 @@ golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= -golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= -golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= From dd936302d1223613693c7942364eb4d4d8597b4f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 10:46:54 +0800 Subject: [PATCH 08/28] chore(deps): bump github.com/mymmrac/telego from 1.6.0 to 1.7.0 (#1598) Bumps [github.com/mymmrac/telego](https://github.com/mymmrac/telego) from 1.6.0 to 1.7.0. - [Release notes](https://github.com/mymmrac/telego/releases) - [Commits](https://github.com/mymmrac/telego/compare/v1.6.0...v1.7.0) --- updated-dependencies: - dependency-name: github.com/mymmrac/telego dependency-version: 1.7.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index a2ce5c511..c0e2fa60c 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ require ( github.com/larksuite/oapi-sdk-go/v3 v3.5.3 github.com/mdp/qrterminal/v3 v3.2.1 github.com/modelcontextprotocol/go-sdk v1.3.1 - github.com/mymmrac/telego v1.6.0 + github.com/mymmrac/telego v1.7.0 github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 github.com/openai/openai-go/v3 v3.22.0 github.com/rivo/tview v0.42.0 @@ -87,7 +87,7 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.69.0 // indirect - github.com/valyala/fastjson v1.6.7 // indirect + github.com/valyala/fastjson v1.6.10 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect golang.org/x/arch v0.24.0 // indirect golang.org/x/crypto v0.48.0 // indirect diff --git a/go.sum b/go.sum index c8ee84e87..11be48d87 100644 --- a/go.sum +++ b/go.sum @@ -136,8 +136,8 @@ github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFe github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU= github.com/modelcontextprotocol/go-sdk v1.3.1 h1:TfqtNKOIWN4Z1oqmPAiWDC2Jq7K9OdJaooe0teoXASI= github.com/modelcontextprotocol/go-sdk v1.3.1/go.mod h1:DgVX498dMD8UJlseK1S5i1T4tFz2fkBk4xogC3D15nw= -github.com/mymmrac/telego v1.6.0 h1:Zc8rgyHozvd/7ZgyrigyHdAF9koHYMfilYfyB6wlFC0= -github.com/mymmrac/telego v1.6.0/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y= +github.com/mymmrac/telego v1.7.0 h1:yRO/l00tFGG4nY66ufUKb4ARqv7qx9+LsjQv/b0NEyo= +github.com/mymmrac/telego v1.7.0/go.mod h1:pdLV346EgVuq7Xrh3kMggeBiazeHhsdEoK0RTEOPXRM= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= @@ -216,8 +216,8 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI= github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw= -github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM= -github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= +github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4= +github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s= github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= From e9d240d760bab164ceabf13f58f33d2909a45f37 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 10:47:46 +0800 Subject: [PATCH 09/28] chore(deps): bump github.com/caarlos0/env/v11 from 11.3.1 to 11.4.0 (#1599) Bumps [github.com/caarlos0/env/v11](https://github.com/caarlos0/env) from 11.3.1 to 11.4.0. - [Release notes](https://github.com/caarlos0/env/releases) - [Commits](https://github.com/caarlos0/env/compare/v11.3.1...v11.4.0) --- updated-dependencies: - dependency-name: github.com/caarlos0/env/v11 dependency-version: 11.4.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index c0e2fa60c..e48b3006f 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/adhocore/gronx v1.19.6 github.com/anthropics/anthropic-sdk-go v1.22.1 github.com/bwmarrin/discordgo v0.29.0 - github.com/caarlos0/env/v11 v11.3.1 + github.com/caarlos0/env/v11 v11.4.0 github.com/ergochat/irc-go v0.5.0 github.com/ergochat/readline v0.1.3 github.com/gdamore/tcell/v2 v2.13.8 diff --git a/go.sum b/go.sum index 11be48d87..810bdac62 100644 --- a/go.sum +++ b/go.sum @@ -23,8 +23,8 @@ github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uS github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= -github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA= -github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= +github.com/caarlos0/env/v11 v11.4.0 h1:Kcb6t5kIIr4XkoQC9AF2j+8E1Jsrl3Wz/hhm1LtoGAc= +github.com/caarlos0/env/v11 v11.4.0/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= From 2f40a8c165810a88442630b231d25fbd17937ea3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 10:51:55 +0800 Subject: [PATCH 10/28] chore(deps): bump github.com/anthropics/anthropic-sdk-go (#1601) Bumps [github.com/anthropics/anthropic-sdk-go](https://github.com/anthropics/anthropic-sdk-go) from 1.22.1 to 1.26.0. - [Release notes](https://github.com/anthropics/anthropic-sdk-go/releases) - [Changelog](https://github.com/anthropics/anthropic-sdk-go/blob/main/CHANGELOG.md) - [Commits](https://github.com/anthropics/anthropic-sdk-go/compare/v1.22.1...v1.26.0) --- updated-dependencies: - dependency-name: github.com/anthropics/anthropic-sdk-go dependency-version: 1.26.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index e48b3006f..6f5de1605 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.25.7 require ( github.com/adhocore/gronx v1.19.6 - github.com/anthropics/anthropic-sdk-go v1.22.1 + github.com/anthropics/anthropic-sdk-go v1.26.0 github.com/bwmarrin/discordgo v0.29.0 github.com/caarlos0/env/v11 v11.4.0 github.com/ergochat/irc-go v0.5.0 diff --git a/go.sum b/go.sum index 810bdac62..0fb4be1b8 100644 --- a/go.sum +++ b/go.sum @@ -11,8 +11,8 @@ github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNg github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= -github.com/anthropics/anthropic-sdk-go v1.22.1 h1:xbsc3vJKCX/ELDZSpTNfz9wCgrFsamwFewPb1iI0Xh0= -github.com/anthropics/anthropic-sdk-go v1.22.1/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= +github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY= +github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q= github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs= github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4= github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno= @@ -38,6 +38,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= +github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg= @@ -354,6 +356,7 @@ gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWD gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= From 43eb6fe20c5670fa15ce261174e0c1a2ac1f3c80 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 10:58:18 +0800 Subject: [PATCH 11/28] chore(deps): bump github.com/github/copilot-sdk/go from 0.1.23 to 0.1.32 (#1603) Bumps [github.com/github/copilot-sdk/go](https://github.com/github/copilot-sdk) from 0.1.23 to 0.1.32. - [Release notes](https://github.com/github/copilot-sdk/releases) - [Changelog](https://github.com/github/copilot-sdk/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/copilot-sdk/compare/v0.1.23...v0.1.32) --- updated-dependencies: - dependency-name: github.com/github/copilot-sdk/go dependency-version: 0.1.32 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 6f5de1605..130db73ff 100644 --- a/go.mod +++ b/go.mod @@ -73,7 +73,7 @@ require ( github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect - github.com/github/copilot-sdk/go v0.1.23 + github.com/github/copilot-sdk/go v0.1.32 github.com/go-resty/resty/v2 v2.17.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/jsonschema-go v0.4.2 // indirect diff --git a/go.sum b/go.sum index 0fb4be1b8..a4d8ed3d0 100644 --- a/go.sum +++ b/go.sum @@ -54,8 +54,8 @@ github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uh github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU= github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= -github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw= -github.com/github/copilot-sdk/go v0.1.23/go.mod h1:GdwwBfMbm9AABLEM3x5IZKw4ZfwCYxZ1BgyytmZenQ0= +github.com/github/copilot-sdk/go v0.1.32 h1:wc9SFWwxXhJts6vyzzboPLJqcEJGnHE8rMCAY1RrUgo= +github.com/github/copilot-sdk/go v0.1.32/go.mod h1:qc2iEF7hdO8kzSvbyGvrcGhuk2fzdW4xTtT0+1EH2ts= github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w= github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q= github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4= From b8dfd0befc44233c2193f599797aa0c0a781c687 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 10:58:48 +0800 Subject: [PATCH 12/28] chore(deps): bump jotai from 2.18.0 to 2.18.1 in /web/frontend (#1605) Bumps [jotai](https://github.com/pmndrs/jotai) from 2.18.0 to 2.18.1. - [Release notes](https://github.com/pmndrs/jotai/releases) - [Commits](https://github.com/pmndrs/jotai/compare/v2.18.0...v2.18.1) --- updated-dependencies: - dependency-name: jotai dependency-version: 2.18.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- web/frontend/package.json | 2 +- web/frontend/pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/web/frontend/package.json b/web/frontend/package.json index 373b4d468..8d5b77fb9 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -24,7 +24,7 @@ "dayjs": "^1.11.19", "i18next": "^25.8.14", "i18next-browser-languagedetector": "^8.2.1", - "jotai": "^2.18.0", + "jotai": "^2.18.1", "radix-ui": "^1.4.3", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml index 75acacfa5..a1ea2a512 100644 --- a/web/frontend/pnpm-lock.yaml +++ b/web/frontend/pnpm-lock.yaml @@ -42,8 +42,8 @@ importers: specifier: ^8.2.1 version: 8.2.1 jotai: - specifier: ^2.18.0 - version: 2.18.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4) + specifier: ^2.18.1 + version: 2.18.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4) radix-ui: specifier: ^1.4.3 version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -2669,8 +2669,8 @@ packages: jose@6.1.3: resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} - jotai@2.18.0: - resolution: {integrity: sha512-XI38kGWAvtxAZ+cwHcTgJsd+kJOJGf3OfL4XYaXWZMZ7IIY8e53abpIHvtVn1eAgJ5dlgwlGFnP4psrZ/vZbtA==} + jotai@2.18.1: + resolution: {integrity: sha512-e0NOzK+yRFwHo7DOp0DS0Ycq74KMEAObDWFGmfEL28PD9nLqBTt3/Ug7jf9ca72x0gC9LQZG9zH+0ISICmy3iA==} engines: {node: '>=12.20.0'} peerDependencies: '@babel/core': '>=7.0.0' @@ -6501,7 +6501,7 @@ snapshots: jose@6.1.3: {} - jotai@2.18.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4): + jotai@2.18.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4): optionalDependencies: '@babel/core': 7.29.0 '@babel/template': 7.28.6 From a93bd0132933b6a6251395ca4fa8c6fcf67ab3aa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 11:04:50 +0800 Subject: [PATCH 13/28] chore(deps-dev): bump @vitejs/plugin-react in /web/frontend (#1606) Bumps [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) from 5.1.4 to 5.2.0. - [Release notes](https://github.com/vitejs/vite-plugin-react/releases) - [Changelog](https://github.com/vitejs/vite-plugin-react/blob/plugin-react@5.2.0/packages/plugin-react/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@5.2.0/packages/plugin-react) --- updated-dependencies: - dependency-name: "@vitejs/plugin-react" dependency-version: 5.2.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- web/frontend/package.json | 2 +- web/frontend/pnpm-lock.yaml | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/web/frontend/package.json b/web/frontend/package.json index 8d5b77fb9..189b93fc0 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -48,7 +48,7 @@ "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@typescript-eslint/eslint-plugin": "^8.56.1", - "@vitejs/plugin-react": "^5.1.1", + "@vitejs/plugin-react": "^5.2.0", "eslint": "^9.39.1", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.0.1", diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml index a1ea2a512..2510d06ee 100644 --- a/web/frontend/pnpm-lock.yaml +++ b/web/frontend/pnpm-lock.yaml @@ -109,8 +109,8 @@ importers: specifier: ^8.56.1 version: 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) '@vitejs/plugin-react': - specifier: ^5.1.1 - version: 5.1.4(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) + specifier: ^5.2.0 + version: 5.2.0(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) eslint: specifier: ^9.39.1 version: 9.39.3(jiti@2.6.1) @@ -1790,11 +1790,11 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} - '@vitejs/plugin-react@5.1.4': - resolution: {integrity: sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==} + '@vitejs/plugin-react@5.2.0': + resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: - vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} @@ -5641,7 +5641,7 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))': + '@vitejs/plugin-react@5.2.0(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) From 3bf8a27570112488d1a0f0bdec892e80dee23fb3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 11:05:03 +0800 Subject: [PATCH 14/28] chore(deps): bump react-i18next from 16.5.4 to 16.5.8 in /web/frontend (#1607) Bumps [react-i18next](https://github.com/i18next/react-i18next) from 16.5.4 to 16.5.8. - [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md) - [Commits](https://github.com/i18next/react-i18next/compare/v16.5.4...v16.5.8) --- updated-dependencies: - dependency-name: react-i18next dependency-version: 16.5.8 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- web/frontend/package.json | 2 +- web/frontend/pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/web/frontend/package.json b/web/frontend/package.json index 189b93fc0..d6d13a8fd 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -28,7 +28,7 @@ "radix-ui": "^1.4.3", "react": "^19.2.0", "react-dom": "^19.2.0", - "react-i18next": "^16.5.4", + "react-i18next": "^16.5.8", "react-markdown": "^10.1.0", "react-textarea-autosize": "^8.5.9", "remark-gfm": "^4.0.1", diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml index 2510d06ee..38820d871 100644 --- a/web/frontend/pnpm-lock.yaml +++ b/web/frontend/pnpm-lock.yaml @@ -54,8 +54,8 @@ importers: specifier: ^19.2.0 version: 19.2.4(react@19.2.4) react-i18next: - specifier: ^16.5.4 - version: 16.5.4(i18next@25.8.14(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + specifier: ^16.5.8 + version: 16.5.8(i18next@25.8.14(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.14)(react@19.2.4) @@ -3323,8 +3323,8 @@ packages: peerDependencies: react: ^19.2.4 - react-i18next@16.5.4: - resolution: {integrity: sha512-6yj+dcfMncEC21QPhOTsW8mOSO+pzFmT6uvU7XXdvM/Cp38zJkmTeMeKmTrmCMD5ToT79FmiE/mRWiYWcJYW4g==} + react-i18next@16.5.8: + resolution: {integrity: sha512-2ABeHHlakxVY+LSirD+OiERxFL6+zip0PaHo979bgwzeHg27Sqc82xxXWIrSFmfWX0ZkrvXMHwhsi/NGUf5VQg==} peerDependencies: i18next: '>= 25.6.2' react: '>= 16.8.0' @@ -7310,7 +7310,7 @@ snapshots: react: 19.2.4 scheduler: 0.27.0 - react-i18next@16.5.4(i18next@25.8.14(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3): + react-i18next@16.5.8(i18next@25.8.14(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3): dependencies: '@babel/runtime': 7.28.6 html-parse-stringify: 3.0.1 From 99304d1f8e779294439660a443d5d45e5832cd9a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 11:05:17 +0800 Subject: [PATCH 15/28] chore(deps): bump dayjs from 1.11.19 to 1.11.20 in /web/frontend (#1608) Bumps [dayjs](https://github.com/iamkun/dayjs) from 1.11.19 to 1.11.20. - [Release notes](https://github.com/iamkun/dayjs/releases) - [Changelog](https://github.com/iamkun/dayjs/blob/dev/CHANGELOG.md) - [Commits](https://github.com/iamkun/dayjs/compare/v1.11.19...v1.11.20) --- updated-dependencies: - dependency-name: dayjs dependency-version: 1.11.20 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- web/frontend/package.json | 2 +- web/frontend/pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/web/frontend/package.json b/web/frontend/package.json index d6d13a8fd..6a4719adb 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -21,7 +21,7 @@ "@tanstack/react-router-devtools": "^1.163.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "dayjs": "^1.11.19", + "dayjs": "^1.11.20", "i18next": "^25.8.14", "i18next-browser-languagedetector": "^8.2.1", "jotai": "^2.18.1", diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml index 38820d871..3c6c4c7cf 100644 --- a/web/frontend/pnpm-lock.yaml +++ b/web/frontend/pnpm-lock.yaml @@ -33,8 +33,8 @@ importers: specifier: ^2.1.1 version: 2.1.1 dayjs: - specifier: ^1.11.19 - version: 1.11.19 + specifier: ^1.11.20 + version: 1.11.20 i18next: specifier: ^25.8.14 version: 25.8.14(typescript@5.9.3) @@ -2060,8 +2060,8 @@ packages: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} - dayjs@1.11.19: - resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} + dayjs@1.11.20: + resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} @@ -5894,7 +5894,7 @@ snapshots: data-uri-to-buffer@4.0.1: {} - dayjs@1.11.19: {} + dayjs@1.11.20: {} debug@4.4.3: dependencies: From 4178b2cec5028ab0b86cc0bdd5c0ff0c2ffbca9f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 11:05:31 +0800 Subject: [PATCH 16/28] chore(deps): bump @tanstack/react-router in /web/frontend (#1609) Bumps [@tanstack/react-router](https://github.com/TanStack/router/tree/HEAD/packages/react-router) from 1.163.3 to 1.167.0. - [Release notes](https://github.com/TanStack/router/releases) - [Changelog](https://github.com/TanStack/router/blob/main/packages/react-router/CHANGELOG.md) - [Commits](https://github.com/TanStack/router/commits/@tanstack/react-router@1.167.0/packages/react-router) --- updated-dependencies: - dependency-name: "@tanstack/react-router" dependency-version: 1.167.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- web/frontend/package.json | 2 +- web/frontend/pnpm-lock.yaml | 85 ++++++++++++++++++++++++++----------- 2 files changed, 61 insertions(+), 26 deletions(-) diff --git a/web/frontend/package.json b/web/frontend/package.json index 6a4719adb..f3bae6d6a 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -17,7 +17,7 @@ "@tabler/icons-react": "^3.38.0", "@tailwindcss/vite": "^4.2.1", "@tanstack/react-query": "^5.90.21", - "@tanstack/react-router": "^1.163.3", + "@tanstack/react-router": "^1.167.0", "@tanstack/react-router-devtools": "^1.163.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml index 3c6c4c7cf..ac2168798 100644 --- a/web/frontend/pnpm-lock.yaml +++ b/web/frontend/pnpm-lock.yaml @@ -21,11 +21,11 @@ importers: specifier: ^5.90.21 version: 5.90.21(react@19.2.4) '@tanstack/react-router': - specifier: ^1.163.3 - version: 1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: ^1.167.0 + version: 1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/react-router-devtools': specifier: ^1.163.3 - version: 1.163.3(@tanstack/react-router@1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.163.3)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 1.163.3(@tanstack/react-router@1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.167.0)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -92,7 +92,7 @@ importers: version: 0.5.19(tailwindcss@4.2.1) '@tanstack/router-plugin': specifier: ^1.164.0 - version: 1.164.0(@tanstack/react-router@1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) + version: 1.164.0(@tanstack/react-router@1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) '@trivago/prettier-plugin-sort-imports': specifier: ^6.0.2 version: 6.0.2(prettier@3.8.1) @@ -1587,15 +1587,15 @@ packages: '@tanstack/router-core': optional: true - '@tanstack/react-router@1.163.3': - resolution: {integrity: sha512-hheBbFVb+PbxtrWp8iy6+TTRTbhx3Pn6hKo8Tv/sWlG89ZMcD1xpQWzx8ukHN9K8YWbh5rdzt4kv6u8X4kB28Q==} + '@tanstack/react-router@1.167.0': + resolution: {integrity: sha512-U7CamtXjuC8ixg1c32Rj/4A2OFBnjtMLdbgbyOGHrFHE7ULWS/yhnZLVXff0QSyn6qF92Oecek9mDMHCaTnB2Q==} engines: {node: '>=20.19'} peerDependencies: react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' - '@tanstack/react-store@0.9.1': - resolution: {integrity: sha512-YzJLnRvy5lIEFTLWBAZmcOjK3+2AepnBv/sr6NZmiqJvq7zTQggyK99Gw8fqYdMdHPQWXjz0epFKJXC+9V2xDA==} + '@tanstack/react-store@0.9.2': + resolution: {integrity: sha512-Vt5usJE5sHG/cMechQfmwvwne6ktGCELe89Lmvoxe3LKRoFrhPa8OCKWs0NliG8HTJElEIj7PLtaBQIcux5pAQ==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -1604,6 +1604,10 @@ packages: resolution: {integrity: sha512-jPptiGq/w3nuPzcMC7RNa79aU+b6OjaDzWJnBcV2UAwL4ThJamRS4h42TdhJE+oF5yH9IEnCOGQdfnbw45LbfA==} engines: {node: '>=20.19'} + '@tanstack/router-core@1.167.0': + resolution: {integrity: sha512-pnaaUP+vMQEyL2XjZGe2PXmtzulxvXfGyvEMUs+AEBaNEk77xWA88bl3ujiBRbUxzpK0rxfJf+eSKPdZmBMFdQ==} + engines: {node: '>=20.19'} + '@tanstack/router-devtools-core@1.163.3': resolution: {integrity: sha512-FPi64IP0PT1IkoeyGmsD6JoOVOYAb85VCH0mUbSdD90yV0+1UB6oT+D7K27GXkp7SXMJN3mBEjU5rKnNnmSCIw==} engines: {node: '>=20.19'} @@ -1646,6 +1650,9 @@ packages: '@tanstack/store@0.9.1': resolution: {integrity: sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg==} + '@tanstack/store@0.9.2': + resolution: {integrity: sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA==} + '@tanstack/virtual-file-routes@1.161.4': resolution: {integrity: sha512-42WoRePf8v690qG8yGRe/YOh+oHni9vUaUUfoqlS91U2scd3a5rkLtVsc6b7z60w3RogH0I00vdrC5AaeiZ18w==} engines: {node: '>=20.19'} @@ -2648,8 +2655,8 @@ packages: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} - isbot@5.1.35: - resolution: {integrity: sha512-waFfC72ZNfwLLuJ2iLaoVaqcNo+CAaLR7xCpAn0Y5WfGzkNHv7ZN39Vbi1y+kb+Zs46XHOX3tZNExroFUPX+Kg==} + isbot@5.1.36: + resolution: {integrity: sha512-C/ZtXyJqDPZ7G7JPr06ApWyYoHjYexQbS6hPYD4WYCzpv2Qes6Z+CCEfTX4Owzf+1EJ933PoI2p+B9v7wpGZBQ==} engines: {node: '>=18'} isexe@2.0.0: @@ -3476,10 +3483,20 @@ packages: peerDependencies: seroval: ^1.0 + seroval-plugins@1.5.1: + resolution: {integrity: sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + seroval@1.5.0: resolution: {integrity: sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==} engines: {node: '>=10'} + seroval@1.5.1: + resolution: {integrity: sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==} + engines: {node: '>=10'} + serve-static@2.2.1: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} @@ -5365,31 +5382,31 @@ snapshots: '@tanstack/query-core': 5.90.20 react: 19.2.4 - '@tanstack/react-router-devtools@1.163.3(@tanstack/react-router@1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.163.3)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@tanstack/react-router-devtools@1.163.3(@tanstack/react-router@1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.167.0)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@tanstack/react-router': 1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@tanstack/router-devtools-core': 1.163.3(@tanstack/router-core@1.163.3)(csstype@3.2.3) + '@tanstack/react-router': 1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/router-devtools-core': 1.163.3(@tanstack/router-core@1.167.0)(csstype@3.2.3) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) optionalDependencies: - '@tanstack/router-core': 1.163.3 + '@tanstack/router-core': 1.167.0 transitivePeerDependencies: - csstype - '@tanstack/react-router@1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@tanstack/react-router@1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@tanstack/history': 1.161.4 - '@tanstack/react-store': 0.9.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@tanstack/router-core': 1.163.3 - isbot: 5.1.35 + '@tanstack/react-store': 0.9.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/router-core': 1.167.0 + isbot: 5.1.36 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) tiny-invariant: 1.3.3 tiny-warning: 1.0.3 - '@tanstack/react-store@0.9.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@tanstack/react-store@0.9.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@tanstack/store': 0.9.1 + '@tanstack/store': 0.9.2 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) use-sync-external-store: 1.6.0(react@19.2.4) @@ -5404,9 +5421,19 @@ snapshots: tiny-invariant: 1.3.3 tiny-warning: 1.0.3 - '@tanstack/router-devtools-core@1.163.3(@tanstack/router-core@1.163.3)(csstype@3.2.3)': + '@tanstack/router-core@1.167.0': dependencies: - '@tanstack/router-core': 1.163.3 + '@tanstack/history': 1.161.4 + '@tanstack/store': 0.9.2 + cookie-es: 2.0.0 + seroval: 1.5.1 + seroval-plugins: 1.5.1(seroval@1.5.1) + tiny-invariant: 1.3.3 + tiny-warning: 1.0.3 + + '@tanstack/router-devtools-core@1.163.3(@tanstack/router-core@1.167.0)(csstype@3.2.3)': + dependencies: + '@tanstack/router-core': 1.167.0 clsx: 2.1.1 goober: 2.1.18(csstype@3.2.3) tiny-invariant: 1.3.3 @@ -5426,7 +5453,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.164.0(@tanstack/react-router@1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))': + '@tanstack/router-plugin@1.164.0(@tanstack/react-router@1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) @@ -5442,7 +5469,7 @@ snapshots: unplugin: 2.3.11 zod: 3.25.76 optionalDependencies: - '@tanstack/react-router': 1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/react-router': 1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0) transitivePeerDependencies: - supports-color @@ -5463,6 +5490,8 @@ snapshots: '@tanstack/store@0.9.1': {} + '@tanstack/store@0.9.2': {} + '@tanstack/virtual-file-routes@1.161.4': {} '@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.1)': @@ -6489,7 +6518,7 @@ snapshots: dependencies: is-inside-container: 1.0.0 - isbot@5.1.35: {} + isbot@5.1.36: {} isexe@2.0.0: {} @@ -7517,8 +7546,14 @@ snapshots: dependencies: seroval: 1.5.0 + seroval-plugins@1.5.1(seroval@1.5.1): + dependencies: + seroval: 1.5.1 + seroval@1.5.0: {} + seroval@1.5.1: {} + serve-static@2.2.1: dependencies: encodeurl: 2.0.0 From c8065989b0f04336d94ed8aa815a5280778c7462 Mon Sep 17 00:00:00 2001 From: wenjie Date: Mon, 16 Mar 2026 11:58:06 +0800 Subject: [PATCH 17/28] chore(web): upgrade eslint deps to resolve flatted vulnerability (#1629) --- web/frontend/package.json | 4 ++-- web/frontend/pnpm-lock.yaml | 28 ++++++++++++++-------------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/web/frontend/package.json b/web/frontend/package.json index f3bae6d6a..973586519 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -40,7 +40,7 @@ "wrap-ansi": "^10.0.0" }, "devDependencies": { - "@eslint/js": "^9.39.1", + "@eslint/js": "^9.39.3", "@tailwindcss/typography": "^0.5.19", "@tanstack/router-plugin": "^1.164.0", "@trivago/prettier-plugin-sort-imports": "^6.0.2", @@ -49,7 +49,7 @@ "@types/react-dom": "^19.2.3", "@typescript-eslint/eslint-plugin": "^8.56.1", "@vitejs/plugin-react": "^5.2.0", - "eslint": "^9.39.1", + "eslint": "^9.39.3", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml index ac2168798..20f0a7342 100644 --- a/web/frontend/pnpm-lock.yaml +++ b/web/frontend/pnpm-lock.yaml @@ -85,7 +85,7 @@ importers: version: 10.0.0 devDependencies: '@eslint/js': - specifier: ^9.39.1 + specifier: ^9.39.3 version: 9.39.3 '@tailwindcss/typography': specifier: ^0.5.19 @@ -112,7 +112,7 @@ importers: specifier: ^5.2.0 version: 5.2.0(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) eslint: - specifier: ^9.39.1 + specifier: ^9.39.3 version: 9.39.3(jiti@2.6.1) eslint-config-prettier: specifier: ^10.1.8 @@ -469,8 +469,8 @@ packages: resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint/config-array@0.21.1': - resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/config-helpers@0.4.2': @@ -481,8 +481,8 @@ packages: resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/eslintrc@3.3.4': - resolution: {integrity: sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ==} + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/js@9.39.3': @@ -2362,8 +2362,8 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} - flatted@3.3.3: - resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + flatted@3.4.1: + resolution: {integrity: sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==} formdata-polyfill@4.0.10: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} @@ -4285,7 +4285,7 @@ snapshots: '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.21.1': + '@eslint/config-array@0.21.2': dependencies: '@eslint/object-schema': 2.1.7 debug: 4.4.3 @@ -4301,7 +4301,7 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.4': + '@eslint/eslintrc@3.3.5': dependencies: ajv: 6.14.0 debug: 4.4.3 @@ -6077,10 +6077,10 @@ snapshots: dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.1 + '@eslint/config-array': 0.21.2 '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.4 + '@eslint/eslintrc': 3.3.5 '@eslint/js': 9.39.3 '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.7 @@ -6270,10 +6270,10 @@ snapshots: flat-cache@4.0.1: dependencies: - flatted: 3.3.3 + flatted: 3.4.1 keyv: 4.5.4 - flatted@3.3.3: {} + flatted@3.4.1: {} formdata-polyfill@4.0.10: dependencies: From 2f10b47f59f01a34ef989fd919d84c6212b5f0ad Mon Sep 17 00:00:00 2001 From: sky5454 Date: Mon, 16 Mar 2026 14:06:32 +0800 Subject: [PATCH 18/28] =?UTF-8?q?feat(credential):=20part1=20add=20AES-GCM?= =?UTF-8?q?=20encryption,=20SecureStore,=20and=20onboard=20ke=E2=80=A6=20(?= =?UTF-8?q?#1521)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(credential): add AES-GCM encryption, SecureStore, and onboard keygen - pkg/credential: new package with AES-256-GCM enc:// credential format, HKDF-SHA256 key derivation (passphrase + optional SSH key binding), ErrPassphraseRequired / ErrDecryptionFailed sentinel errors, and PassphraseProvider hook for runtime passphrase injection - pkg/credential/store: lock-free SecureStore via atomic.Pointer[string]; passphrase never written to disk or os.Environ - pkg/credential/keygen: ed25519 SSH key generation helper used by onboard - pkg/config: replace os.Getenv(PassphraseEnvVar) with credential.PassphraseProvider() at all three call sites so that LoadConfig and SaveConfig use whatever passphrase source is active - cmd/picoclaw/onboard: prompt for passphrase with echo-off, generate picoclaw-specific SSH key, re-encrypt existing config on re-onboard - docs/credential_encryption.md: design doc for the enc:// format * fix(credential): address Copilot review comments on PR #1521 - credential.go: decouple ErrPassphraseRequired from env var name; message is now 'enc:// passphrase required' since PassphraseProvider may come from any source, not just os.Environ - credential.go: Resolver resolves symlinks via EvalSymlinks before the isWithinDir containment check, preventing symlink-based path traversal for file:// credential references - store.go: tighten comment to describe only what SecureStore guarantees (in-memory only); remove claims about how callers transport the value - store_test.go: replace the meaningless GetReturnsCopy test (Go strings are immutable, equality across two calls proves nothing) with TestSecureStore_ConcurrentSetGet that exercises atomic.Pointer under 10-goroutine concurrent Set/Get load - config_test.go: update error-message assertion to match new sentinel text - docs/credential_encryption.md: remove reference to non-existent 'picoclaw encrypt' subcommand; describe the onboard flow instead * fix(config): encryptPlaintextAPIKeys: struct-based encryption, fail-fast, remove raw []byte * fix(credential): require SSH private key for encryption/decryption, remove passphrase-only mode * lint: fix credential keygen lint, fix test keygen * onboard: make encryption opt-in via --enc flag Encryption (passphrase prompt + SSH key generation) is now only triggered when the user passes --enc to 'picoclaw onboard'. Without the flag, onboard skips the credential-encryption setup and writes a plain config + workspace templates directly. - Add --enc BoolFlag in NewOnboardCommand() - Pass encrypt bool into onboard() - Guard passphrase prompt, SSH key generation, and related env-var setup behind the encrypt branch - Adjust 'Next steps' output so the passphrase reminder only appears when --enc was used --- cmd/picoclaw/internal/onboard/command.go | 7 +- cmd/picoclaw/internal/onboard/command_test.go | 5 +- cmd/picoclaw/internal/onboard/helpers.go | 133 ++++++- docs/credential_encryption.md | 168 ++++++++ pkg/config/config.go | 72 +++- pkg/config/config_test.go | 365 +++++++++++++++++- pkg/credential/credential.go | 335 ++++++++++++++++ pkg/credential/credential_test.go | 283 ++++++++++++++ pkg/credential/keygen.go | 62 +++ pkg/credential/keygen_test.go | 115 ++++++ pkg/credential/store.go | 44 +++ pkg/credential/store_test.go | 81 ++++ 12 files changed, 1649 insertions(+), 21 deletions(-) create mode 100644 docs/credential_encryption.md create mode 100644 pkg/credential/credential.go create mode 100644 pkg/credential/credential_test.go create mode 100644 pkg/credential/keygen.go create mode 100644 pkg/credential/keygen_test.go create mode 100644 pkg/credential/store.go create mode 100644 pkg/credential/store_test.go diff --git a/cmd/picoclaw/internal/onboard/command.go b/cmd/picoclaw/internal/onboard/command.go index ec1012959..9f8b288c6 100644 --- a/cmd/picoclaw/internal/onboard/command.go +++ b/cmd/picoclaw/internal/onboard/command.go @@ -11,14 +11,19 @@ import ( var embeddedFiles embed.FS func NewOnboardCommand() *cobra.Command { + var encrypt bool + cmd := &cobra.Command{ Use: "onboard", Aliases: []string{"o"}, Short: "Initialize picoclaw configuration and workspace", Run: func(cmd *cobra.Command, args []string) { - onboard() + onboard(encrypt) }, } + cmd.Flags().BoolVar(&encrypt, "enc", false, + "Enable credential encryption (generates SSH key and prompts for passphrase)") + return cmd } diff --git a/cmd/picoclaw/internal/onboard/command_test.go b/cmd/picoclaw/internal/onboard/command_test.go index bc799a079..56936190b 100644 --- a/cmd/picoclaw/internal/onboard/command_test.go +++ b/cmd/picoclaw/internal/onboard/command_test.go @@ -24,6 +24,9 @@ func TestNewOnboardCommand(t *testing.T) { assert.Nil(t, cmd.PersistentPreRun) assert.Nil(t, cmd.PersistentPostRun) - assert.False(t, cmd.HasFlags()) + assert.True(t, cmd.HasFlags()) + encFlag := cmd.Flags().Lookup("enc") + require.NotNil(t, encFlag, "expected --enc flag to be registered") + assert.Equal(t, "false", encFlag.DefValue, "--enc should default to false") assert.False(t, cmd.HasSubCommands()) } diff --git a/cmd/picoclaw/internal/onboard/helpers.go b/cmd/picoclaw/internal/onboard/helpers.go index 4db8bdc8b..6f1d4bdd7 100644 --- a/cmd/picoclaw/internal/onboard/helpers.go +++ b/cmd/picoclaw/internal/onboard/helpers.go @@ -6,25 +6,71 @@ import ( "os" "path/filepath" + "golang.org/x/term" + "github.com/sipeed/picoclaw/cmd/picoclaw/internal" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/credential" ) -func onboard() { +func onboard(encrypt bool) { configPath := internal.GetConfigPath() + configExists := false if _, err := os.Stat(configPath); err == nil { - fmt.Printf("Config already exists at %s\n", configPath) - fmt.Print("Overwrite? (y/n): ") - var response string - fmt.Scanln(&response) - if response != "y" { - fmt.Println("Aborted.") - return + configExists = true + if encrypt { + // Only ask for confirmation when *both* config and SSH key already exist, + // indicating a full re-onboard that would reset the config to defaults. + sshKeyPath, _ := credential.DefaultSSHKeyPath() + if _, err := os.Stat(sshKeyPath); err == nil { + // Both exist — confirm a full reset. + fmt.Printf("Config already exists at %s\n", configPath) + fmt.Print("Overwrite config with defaults? (y/n): ") + var response string + fmt.Scanln(&response) + if response != "y" { + fmt.Println("Aborted.") + return + } + configExists = false // user agreed to reset; treat as fresh + } + // Config exists but SSH key is missing — keep existing config, only add SSH key. } } - cfg := config.DefaultConfig() + var err error + if encrypt { + fmt.Println("\nSet up credential encryption") + fmt.Println("-----------------------------") + passphrase, pErr := promptPassphrase() + if pErr != nil { + fmt.Printf("Error: %v\n", pErr) + os.Exit(1) + } + // Expose the passphrase to credential.PassphraseProvider (which calls + // os.Getenv by default) so that SaveConfig can encrypt api_keys. + // This process is a one-shot CLI tool; the env var is never exposed outside + // the current process and disappears when it exits. + os.Setenv(credential.PassphraseEnvVar, passphrase) + + if err = setupSSHKey(); err != nil { + fmt.Printf("Error generating SSH key: %v\n", err) + os.Exit(1) + } + } + + var cfg *config.Config + if configExists { + // Preserve the existing config; SaveConfig will re-encrypt api_keys with the new passphrase. + cfg, err = config.LoadConfig(configPath) + if err != nil { + fmt.Printf("Error loading existing config: %v\n", err) + os.Exit(1) + } + } else { + cfg = config.DefaultConfig() + } if err := config.SaveConfig(configPath, cfg); err != nil { fmt.Printf("Error saving config: %v\n", err) os.Exit(1) @@ -33,9 +79,17 @@ func onboard() { workspace := cfg.WorkspacePath() createWorkspaceTemplates(workspace) - fmt.Printf("%s picoclaw is ready!\n", internal.Logo) + fmt.Printf("\n%s picoclaw is ready!\n", internal.Logo) fmt.Println("\nNext steps:") - fmt.Println(" 1. Add your API key to", configPath) + if encrypt { + fmt.Println(" 1. Set your encryption passphrase before starting picoclaw:") + fmt.Println(" export PICOCLAW_KEY_PASSPHRASE= # Linux/macOS") + fmt.Println(" set PICOCLAW_KEY_PASSPHRASE= # Windows cmd") + fmt.Println("") + fmt.Println(" 2. Add your API key to", configPath) + } else { + fmt.Println(" 1. Add your API key to", configPath) + } fmt.Println("") fmt.Println(" Recommended:") fmt.Println(" - OpenRouter: https://openrouter.ai/keys (access 100+ models)") @@ -43,7 +97,62 @@ func onboard() { fmt.Println("") fmt.Println(" See README.md for 17+ supported providers.") fmt.Println("") - fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"") + fmt.Println(" 3. Chat: picoclaw agent -m \"Hello!\"") +} + +// promptPassphrase reads the encryption passphrase twice from the terminal +// (with echo disabled) and returns it. Returns an error if the passphrase is +// empty or if the two inputs do not match. +func promptPassphrase() (string, error) { + fmt.Print("Enter passphrase for credential encryption: ") + p1, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println() + if err != nil { + return "", fmt.Errorf("reading passphrase: %w", err) + } + if len(p1) == 0 { + return "", fmt.Errorf("passphrase must not be empty") + } + + fmt.Print("Confirm passphrase: ") + p2, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println() + if err != nil { + return "", fmt.Errorf("reading passphrase confirmation: %w", err) + } + + if string(p1) != string(p2) { + return "", fmt.Errorf("passphrases do not match") + } + return string(p1), nil +} + +// setupSSHKey generates the picoclaw-specific SSH key at ~/.ssh/picoclaw_ed25519.key. +// If the key already exists the user is warned and asked to confirm overwrite. +// Answering anything other than "y" keeps the existing key (not an error). +func setupSSHKey() error { + keyPath, err := credential.DefaultSSHKeyPath() + if err != nil { + return fmt.Errorf("cannot determine SSH key path: %w", err) + } + + if _, err := os.Stat(keyPath); err == nil { + fmt.Printf("\n⚠️ WARNING: %s already exists.\n", keyPath) + fmt.Println(" Overwriting will invalidate any credentials previously encrypted with this key.") + fmt.Print(" Overwrite? (y/n): ") + var response string + fmt.Scanln(&response) + if response != "y" { + fmt.Println("Keeping existing SSH key.") + return nil + } + } + + if err := credential.GenerateSSHKey(keyPath); err != nil { + return err + } + fmt.Printf("SSH key generated: %s\n", keyPath) + return nil } func createWorkspaceTemplates(workspace string) { diff --git a/docs/credential_encryption.md b/docs/credential_encryption.md new file mode 100644 index 000000000..448eaaa10 --- /dev/null +++ b/docs/credential_encryption.md @@ -0,0 +1,168 @@ +# Credential Encryption + +PicoClaw supports encrypting `api_key` values in `model_list` configuration entries. +Encrypted keys are stored as `enc://` strings and decrypted automatically at startup. + +--- + +## Quick Start + +**1. Set your passphrase** + +```bash +export PICOCLAW_KEY_PASSPHRASE="your-passphrase" +``` + +**2. Encrypt an API key** + +Run `picoclaw onboard` — it prompts for your passphrase and generates the SSH key, +then automatically re-encrypts any plaintext `api_key` entries in your config on +the next `SaveConfig` call. The resulting `enc://` value will look like: + +``` +enc://AAAA...base64... +``` + +**3. Paste the output into your config** + +```json +{ + "model_list": [ + { + "model_name": "gpt-4o", + "api_key": "enc://AAAA...base64...", + "base_url": "https://api.openai.com/v1" + } + ] +} +``` + +--- + +## Supported `api_key` Formats + +| Format | Example | Behaviour | +|--------|---------|-----------| +| Plaintext | `sk-abc123` | Used as-is | +| File reference | `file://openai.key` | Content read from the same directory as the config file | +| Encrypted | `enc://` | Decrypted at startup using `PICOCLAW_KEY_PASSPHRASE` | +| Empty | `""` | Passed through unchanged (used with `auth_method: oauth`) | + +--- + +## Cryptographic Design + +### Key Derivation + +Encryption uses **HKDF-SHA256** with an optional SSH private key as a second factor. + +``` +Without SSH key (passphrase only): + + ikm = SHA256(passphrase) + aes_key = HKDF-SHA256(ikm, salt, info="picoclaw-credential-v1", 32 bytes) + + +With SSH key (recommended): + + sshHash = SHA256(ssh_private_key_file_bytes) + ikm = HMAC-SHA256(key=sshHash, message=passphrase) + aes_key = HKDF-SHA256(ikm, salt, info="picoclaw-credential-v1", 32 bytes) +``` + +### Encryption + +``` +AES-256-GCM(key=aes_key, nonce=random[12], plaintext=api_key) +``` + +### Wire Format + +``` +enc:// +``` + +| Field | Size | Description | +|-------|------|-------------| +| `salt` | 16 bytes | Random per encryption; fed into HKDF | +| `nonce` | 12 bytes | Random per encryption; AES-GCM IV | +| `ciphertext` | variable | AES-256-GCM ciphertext + 16-byte authentication tag | + +The GCM authentication tag is appended to the ciphertext automatically. Any tampering causes decryption to fail with an error rather than returning corrupt plaintext. + +### Performance + +| Operation | Time (ARM Cortex-A) | +|-----------|---------------------| +| Key derivation (HKDF) | < 1 ms | +| AES-256-GCM decrypt | < 1 ms | +| **Total startup overhead** | **< 2 ms per key** | + +--- + +## Two-Factor Security with SSH Key + +When a SSH private key is provided, breaking the encryption requires **both**: + +1. The **passphrase** (`PICOCLAW_KEY_PASSPHRASE`) +2. The **SSH private key file** + +This means a leaked config file alone is not sufficient to recover the API key, even if the passphrase is weak. The SSH key contributes 256 bits of entropy (Ed25519) regardless of passphrase strength. + +### Threat Model + +| Attacker Has | Can Decrypt? | +|---|---| +| Config file only | No — needs passphrase + SSH key | +| SSH key only | No — needs passphrase | +| Passphrase only | No — needs SSH key | +| Config file + SSH key + passphrase | Yes — full compromise | + +--- + +## Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `PICOCLAW_KEY_PASSPHRASE` | Yes (for `enc://`) | Passphrase used for key derivation | +| `PICOCLAW_SSH_KEY_PATH` | No | Path to SSH private key. Set to `""` to disable auto-detection and use passphrase-only mode | + +### SSH Key Auto-Detection + +If `PICOCLAW_SSH_KEY_PATH` is not set, PicoClaw looks for the picoclaw-specific key: + +``` +~/.ssh/picoclaw_ed25519.key +``` + +This dedicated file avoids conflicts with the user's existing SSH keys. +Run `picoclaw onboard` to generate it automatically. + +`os.UserHomeDir()` is used for cross-platform home directory resolution (reads `USERPROFILE` on Windows, `HOME` on Unix/macOS). + +To explicitly disable SSH key usage and use passphrase-only mode: + +```bash +export PICOCLAW_SSH_KEY_PATH="" +``` + +--- + +## Migration + +Because the only secret material is `PICOCLAW_KEY_PASSPHRASE` and the SSH private key file, migration is straightforward: + +1. Copy the config file to the new machine. +2. Set `PICOCLAW_KEY_PASSPHRASE` to the same value. +3. Copy the SSH private key file to the same path (or set `PICOCLAW_SSH_KEY_PATH` to its new location). + +No re-encryption is needed. + +--- + +## Security Considerations + +- **Passphrase strength matters in passphrase-only mode.** Without an SSH key, a weak passphrase can be brute-forced offline. Use `PICOCLAW_SSH_KEY_PATH=""` only in environments where no SSH key is available and the passphrase is sufficiently strong (≥ 32 random characters). +- **The SSH key is read-only at runtime.** PicoClaw never writes to or modifies the SSH key file. +- **Plaintext keys remain supported.** Existing configs without `enc://` are unaffected. +- **The `enc://` format is versioned** via the HKDF `info` field (`picoclaw-credential-v1`), allowing future algorithm upgrades without breaking existing encrypted values. diff --git a/pkg/config/config.go b/pkg/config/config.go index 190341224..2937c36e4 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -4,11 +4,13 @@ import ( "encoding/json" "fmt" "os" + "path/filepath" "strings" "sync/atomic" "github.com/caarlos0/env/v11" + "github.com/sipeed/picoclaw/pkg/credential" "github.com/sipeed/picoclaw/pkg/fileutil" ) @@ -837,10 +839,24 @@ func LoadConfig(path string) (*Config, error) { return nil, err } + if passphrase := credential.PassphraseProvider(); passphrase != "" { + for _, m := range cfg.ModelList { + if m.APIKey != "" && !strings.HasPrefix(m.APIKey, "enc://") && !strings.HasPrefix(m.APIKey, "file://") { + fmt.Fprintf(os.Stderr, + "picoclaw: warning: model %q has a plaintext api_key; call SaveConfig to encrypt it\n", + m.ModelName) + } + } + } + if err := env.Parse(cfg); err != nil { return nil, err } + if err := resolveAPIKeys(cfg.ModelList, filepath.Dir(path)); err != nil { + return nil, err + } + // Migrate legacy channel config fields to new unified structures cfg.migrateChannelConfigs() @@ -857,6 +873,48 @@ func LoadConfig(path string) (*Config, error) { return cfg, nil } +// encryptPlaintextAPIKeys returns a copy of models with plaintext api_key values +// encrypted. Returns (nil, nil) when nothing changed (all keys already sealed or +// empty). Returns (nil, error) if any key fails to encrypt — callers must treat +// this as a hard failure to prevent a mixed plaintext/ciphertext state on disk. +// Symmetric counterpart of resolveAPIKeys: both operate purely on []ModelConfig +// and leave JSON marshaling to the caller. +func encryptPlaintextAPIKeys(models []ModelConfig, passphrase string) ([]ModelConfig, error) { + sealed := make([]ModelConfig, len(models)) + copy(sealed, models) + changed := false + for i := range sealed { + m := &sealed[i] + if m.APIKey == "" || strings.HasPrefix(m.APIKey, "enc://") || strings.HasPrefix(m.APIKey, "file://") { + continue + } + encrypted, err := credential.Encrypt(passphrase, "", m.APIKey) + if err != nil { + return nil, fmt.Errorf("cannot seal api_key for model %q: %w", m.ModelName, err) + } + m.APIKey = encrypted + changed = true + } + if !changed { + return nil, nil + } + return sealed, nil +} + +// resolveAPIKeys decrypts or dereferences each api_key in models in-place. +// Supports plaintext (no-op), file:// (read from configDir), and enc:// (AES-GCM decrypt). +func resolveAPIKeys(models []ModelConfig, configDir string) error { + cr := credential.NewResolver(configDir) + for i := range models { + resolved, err := cr.Resolve(models[i].APIKey) + if err != nil { + return fmt.Errorf("model_list[%d] (%s): %w", i, models[i].ModelName, err) + } + models[i].APIKey = resolved + } + return nil +} + func (c *Config) migrateChannelConfigs() { // Discord: mention_only -> group_trigger.mention_only if c.Channels.Discord.MentionOnly && !c.Channels.Discord.GroupTrigger.MentionOnly { @@ -871,12 +929,22 @@ func (c *Config) migrateChannelConfigs() { } func SaveConfig(path string, cfg *Config) error { + if passphrase := credential.PassphraseProvider(); passphrase != "" { + sealed, err := encryptPlaintextAPIKeys(cfg.ModelList, passphrase) + if err != nil { + return err + } + if sealed != nil { + tmp := *cfg + tmp.ModelList = sealed + cfg = &tmp + } + } + data, err := json.MarshalIndent(cfg, "", " ") if err != nil { return err } - - // Use unified atomic write utility with explicit sync for flash storage reliability. return fileutil.WriteFileAtomic(path, data, 0o600) } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index c5bdbf3c3..4c4dd9421 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -7,8 +7,22 @@ import ( "runtime" "strings" "testing" + + "github.com/sipeed/picoclaw/pkg/credential" ) +// mustSetupSSHKey generates a temporary Ed25519 SSH key in t.TempDir() and sets +// PICOCLAW_SSH_KEY_PATH to its path for the duration of the test. This is required +// whenever a test exercises encryption/decryption via credential.Encrypt or SaveConfig. +func mustSetupSSHKey(t *testing.T) { + t.Helper() + keyPath := filepath.Join(t.TempDir(), "picoclaw_ed25519.key") + if err := credential.GenerateSSHKey(keyPath); err != nil { + t.Fatalf("mustSetupSSHKey: %v", err) + } + t.Setenv("PICOCLAW_SSH_KEY_PATH", keyPath) +} + func TestAgentModelConfig_UnmarshalString(t *testing.T) { var m AgentModelConfig if err := json.Unmarshal([]byte(`"gpt-4"`), &m); err != nil { @@ -482,13 +496,19 @@ func TestDefaultConfig_DMScope(t *testing.T) { } func TestDefaultConfig_WorkspacePath_Default(t *testing.T) { - // Unset to ensure we test the default t.Setenv("PICOCLAW_HOME", "") - // Set a known home for consistent test results - t.Setenv("HOME", "/tmp/home") + + var fakeHome string + if runtime.GOOS == "windows" { + fakeHome = `C:\tmp\home` + t.Setenv("USERPROFILE", fakeHome) + } else { + fakeHome = "/tmp/home" + t.Setenv("HOME", fakeHome) + } cfg := DefaultConfig() - want := filepath.Join("/tmp/home", ".picoclaw", "workspace") + want := filepath.Join(fakeHome, ".picoclaw", "workspace") if cfg.Agents.Defaults.Workspace != want { t.Errorf("Default workspace path = %q, want %q", cfg.Agents.Defaults.Workspace, want) @@ -499,7 +519,7 @@ func TestDefaultConfig_WorkspacePath_WithPicoclawHome(t *testing.T) { t.Setenv("PICOCLAW_HOME", "/custom/picoclaw/home") cfg := DefaultConfig() - want := "/custom/picoclaw/home/workspace" + want := filepath.Join("/custom/picoclaw/home", "workspace") if cfg.Agents.Defaults.Workspace != want { t.Errorf("Workspace path with PICOCLAW_HOME = %q, want %q", cfg.Agents.Defaults.Workspace, want) @@ -621,3 +641,338 @@ func TestFlexibleStringSlice_UnmarshalText_EmptySliceConsistency(t *testing.T) { } }) } + +// TestLoadConfig_WarnsForPlaintextAPIKey verifies that LoadConfig resolves a plaintext +// api_key into memory but does NOT rewrite the config file. File writes are the sole +// responsibility of SaveConfig. +func TestLoadConfig_WarnsForPlaintextAPIKey(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + const original = `{"model_list":[{"model_name":"test","model":"openai/gpt-4","api_key":"sk-plaintext"}]}` + if err := os.WriteFile(cfgPath, []byte(original), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase") + t.Setenv("PICOCLAW_SSH_KEY_PATH", "") + + cfg, err := LoadConfig(cfgPath) + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + // In-memory value must be the resolved plaintext. + if cfg.ModelList[0].APIKey != "sk-plaintext" { + t.Errorf("in-memory api_key = %q, want %q", cfg.ModelList[0].APIKey, "sk-plaintext") + } + // The file on disk must remain unchanged — LoadConfig must not write anything. + raw, _ := os.ReadFile(cfgPath) + if string(raw) != original { + t.Errorf("LoadConfig must not modify the config file; got:\n%s", string(raw)) + } +} + +// TestSaveConfig_EncryptsPlaintextAPIKey verifies that SaveConfig writes enc:// ciphertext +// to disk and that a subsequent LoadConfig decrypts it back to the original plaintext. +func TestSaveConfig_EncryptsPlaintextAPIKey(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase") + mustSetupSSHKey(t) + + cfg := DefaultConfig() + cfg.ModelList = []ModelConfig{ + {ModelName: "test", Model: "openai/gpt-4", APIKey: "sk-plaintext"}, + } + if err := SaveConfig(cfgPath, cfg); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + + // Disk must contain enc://, not the raw key. + raw, _ := os.ReadFile(cfgPath) + if !strings.Contains(string(raw), "enc://") { + t.Errorf("saved file should contain enc://, got:\n%s", string(raw)) + } + if strings.Contains(string(raw), "sk-plaintext") { + t.Errorf("saved file must not contain the plaintext key") + } + + // A fresh load must decrypt back to the original plaintext. + cfg2, err := LoadConfig(cfgPath) + if err != nil { + t.Fatalf("LoadConfig after SaveConfig: %v", err) + } + if cfg2.ModelList[0].APIKey != "sk-plaintext" { + t.Errorf("loaded api_key = %q, want %q", cfg2.ModelList[0].APIKey, "sk-plaintext") + } +} + +// TestLoadConfig_NoSealWithoutPassphrase verifies that api_key values are left +// unchanged when PICOCLAW_KEY_PASSPHRASE is not set. +func TestLoadConfig_NoSealWithoutPassphrase(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + data := `{"model_list":[{"model_name":"test","model":"openai/gpt-4","api_key":"sk-plaintext"}]}` + if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "") + t.Setenv("PICOCLAW_SSH_KEY_PATH", "") + + if _, err := LoadConfig(cfgPath); err != nil { + t.Fatalf("LoadConfig: %v", err) + } + + raw, _ := os.ReadFile(cfgPath) + if strings.Contains(string(raw), "enc://") { + t.Error("config file must not be modified when no passphrase is set") + } +} + +// TestLoadConfig_FileRefNotSealed verifies that file:// api_key references are not +// converted to enc:// values (they are resolved at runtime by the Resolver). +func TestLoadConfig_FileRefNotSealed(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + keyFile := filepath.Join(dir, "openai.key") + if err := os.WriteFile(keyFile, []byte("sk-from-file"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + data := `{"model_list":[{"model_name":"test","model":"openai/gpt-4","api_key":"file://openai.key"}]}` + if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase") + t.Setenv("PICOCLAW_SSH_KEY_PATH", "") + + if _, err := LoadConfig(cfgPath); err != nil { + t.Fatalf("LoadConfig: %v", err) + } + + raw, _ := os.ReadFile(cfgPath) + if !strings.Contains(string(raw), "file://openai.key") { + t.Error("file:// reference should be preserved unchanged in the config file") + } + if strings.Contains(string(raw), "enc://") { + t.Error("file:// reference must not be converted to enc://") + } +} + +// TestSaveConfig_MixedKeys verifies that SaveConfig encrypts only plaintext api_keys +// and leaves already-encrypted (enc://) and file:// entries unchanged. +func TestSaveConfig_MixedKeys(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase") + mustSetupSSHKey(t) + + // Pre-encrypt one key so we have a genuine enc:// value to put in the config. + if err := SaveConfig(cfgPath, &Config{ + ModelList: []ModelConfig{ + {ModelName: "pre", Model: "openai/gpt-4", APIKey: "sk-already-plain"}, + }, + }); err != nil { + t.Fatalf("setup SaveConfig: %v", err) + } + raw, _ := os.ReadFile(cfgPath) + // Extract the enc:// value from the saved file. + var tmp struct { + ModelList []struct { + APIKey string `json:"api_key"` + } `json:"model_list"` + } + if err := json.Unmarshal(raw, &tmp); err != nil || len(tmp.ModelList) == 0 { + t.Fatalf("setup: could not parse saved config: %v", err) + } + alreadyEncrypted := tmp.ModelList[0].APIKey + if !strings.HasPrefix(alreadyEncrypted, "enc://") { + t.Fatalf("setup: expected enc:// key, got %q", alreadyEncrypted) + } + + // Build a config with three models: + // 1. plaintext → must be encrypted by SaveConfig + // 2. enc:// → must be left unchanged (already encrypted) + // 3. file:// → must be left unchanged (file reference) + keyFile := filepath.Join(dir, "api.key") + if err := os.WriteFile(keyFile, []byte("sk-from-file"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + cfg := &Config{ + ModelList: []ModelConfig{ + {ModelName: "plain", Model: "openai/gpt-4", APIKey: "sk-new-plaintext"}, + {ModelName: "enc", Model: "openai/gpt-4", APIKey: alreadyEncrypted}, + {ModelName: "file", Model: "openai/gpt-4", APIKey: "file://api.key"}, + }, + } + if err := SaveConfig(cfgPath, cfg); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + + raw, _ = os.ReadFile(cfgPath) + s := string(raw) + + // 1. Plaintext must be encrypted. + if strings.Contains(s, "sk-new-plaintext") { + t.Error("plaintext key must not appear in saved file") + } + // 2. The pre-existing enc:// value must still be present (byte-for-byte unchanged). + if !strings.Contains(s, alreadyEncrypted) { + t.Error("pre-existing enc:// entry must be preserved unchanged") + } + // 3. file:// must be preserved. + if !strings.Contains(s, "file://api.key") { + t.Error("file:// reference must be preserved unchanged") + } + + // Now load and verify all three decrypt/resolve correctly. + cfg2, err := LoadConfig(cfgPath) + if err != nil { + t.Fatalf("LoadConfig after SaveConfig: %v", err) + } + byName := make(map[string]string) + for _, m := range cfg2.ModelList { + byName[m.ModelName] = m.APIKey + } + if byName["plain"] != "sk-new-plaintext" { + t.Errorf("plain model api_key = %q, want %q", byName["plain"], "sk-new-plaintext") + } + if byName["enc"] != "sk-already-plain" { + t.Errorf("enc model api_key = %q, want %q", byName["enc"], "sk-already-plain") + } + if byName["file"] != "sk-from-file" { + t.Errorf("file model api_key = %q, want %q", byName["file"], "sk-from-file") + } +} + +// TestLoadConfig_MixedKeys_NoPassphrase verifies that when PICOCLAW_KEY_PASSPHRASE +// is not set, enc:// entries cause LoadConfig to return an error, while plaintext +// and file:// entries in the same config are not affected. +func TestLoadConfig_MixedKeys_NoPassphrase(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + + // First encrypt a key so we have a real enc:// value. + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase") + mustSetupSSHKey(t) + if err := SaveConfig(cfgPath, &Config{ + ModelList: []ModelConfig{ + {ModelName: "m", Model: "openai/gpt-4", APIKey: "sk-secret"}, + }, + }); err != nil { + t.Fatalf("setup SaveConfig: %v", err) + } + raw, _ := os.ReadFile(cfgPath) + var tmp struct { + ModelList []struct { + APIKey string `json:"api_key"` + } `json:"model_list"` + } + if err := json.Unmarshal(raw, &tmp); err != nil { + t.Fatalf("setup parse: %v", err) + } + encValue := tmp.ModelList[0].APIKey + + // Write a mixed config: enc:// + plaintext + file:// + keyFile := filepath.Join(dir, "api.key") + if err := os.WriteFile(keyFile, []byte("sk-from-file"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + mixed, _ := json.Marshal(map[string]any{ + "model_list": []map[string]any{ + {"model_name": "enc", "model": "openai/gpt-4", "api_key": encValue}, + {"model_name": "plain", "model": "openai/gpt-4", "api_key": "sk-plain"}, + {"model_name": "file", "model": "openai/gpt-4", "api_key": "file://api.key"}, + }, + }) + if err := os.WriteFile(cfgPath, mixed, 0o600); err != nil { + t.Fatalf("setup write: %v", err) + } + + // Now clear the passphrase — LoadConfig must fail because enc:// cannot be decrypted. + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "") + + _, err := LoadConfig(cfgPath) + if err == nil { + t.Fatal("LoadConfig should fail when enc:// key is present and no passphrase is set") + } + if !strings.Contains(err.Error(), "passphrase required") { + t.Errorf("error should mention passphrase required, got: %v", err) + } +} + +// TestSaveConfig_UsesPassphraseProvider verifies that SaveConfig encrypts plaintext +// api_keys using credential.PassphraseProvider() rather than os.Getenv directly. +// This matters for the launcher, which clears the environment variable and redirects +// PassphraseProvider to an in-memory SecureStore. +func TestSaveConfig_UsesPassphraseProvider(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + + // Ensure the env var is empty — passphrase must come from PassphraseProvider only. + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "") + mustSetupSSHKey(t) + + // Replace PassphraseProvider with an in-memory function (simulating SecureStore). + const testPassphrase = "provider-passphrase" + orig := credential.PassphraseProvider + credential.PassphraseProvider = func() string { return testPassphrase } + t.Cleanup(func() { credential.PassphraseProvider = orig }) + + cfg := DefaultConfig() + cfg.ModelList = []ModelConfig{ + {ModelName: "test", Model: "openai/gpt-4", APIKey: "sk-plaintext"}, + } + if err := SaveConfig(cfgPath, cfg); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + + raw, _ := os.ReadFile(cfgPath) + if !strings.Contains(string(raw), "enc://") { + t.Errorf("SaveConfig should have encrypted plaintext key via PassphraseProvider; got:\n%s", raw) + } +} + +// TestLoadConfig_UsesPassphraseProvider verifies that LoadConfig decrypts enc:// keys +// using credential.PassphraseProvider() rather than os.Getenv directly. +func TestLoadConfig_UsesPassphraseProvider(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + + // Ensure the env var is empty throughout. + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "") + mustSetupSSHKey(t) + + const testPassphrase = "provider-passphrase" + const plainKey = "sk-secret" + + // First, encrypt the key using the same passphrase. + encrypted, err := credential.Encrypt(testPassphrase, "", plainKey) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + raw, _ := json.Marshal(map[string]any{ + "model_list": []map[string]any{ + {"model_name": "test", "model": "openai/gpt-4", "api_key": encrypted}, + }, + }) + if err = os.WriteFile(cfgPath, raw, 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + // Redirect PassphraseProvider — env var is empty, so without this the load would fail. + orig := credential.PassphraseProvider + credential.PassphraseProvider = func() string { return testPassphrase } + t.Cleanup(func() { credential.PassphraseProvider = orig }) + + cfg, err := LoadConfig(cfgPath) + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if cfg.ModelList[0].APIKey != plainKey { + t.Errorf("api_key = %q, want %q", cfg.ModelList[0].APIKey, plainKey) + } +} diff --git a/pkg/credential/credential.go b/pkg/credential/credential.go new file mode 100644 index 000000000..83af3fc9f --- /dev/null +++ b/pkg/credential/credential.go @@ -0,0 +1,335 @@ +// Package credential resolves API credential values for model_list entries. +// +// An API key is a form of authorization credential. This package centralizes +// how raw credential strings—plaintext or file references—are resolved into +// their actual values, keeping that logic out of the config loader. +// +// Supported formats for the api_key field: +// +// - Plaintext: "sk-abc123" → returned as-is +// - File ref: "file://filename.key" → content read from configDir/filename.key +// - Encrypted: "enc://" → AES-256-GCM decrypt via PICOCLAW_KEY_PASSPHRASE +// - Empty: "" → returned as-is (auth_method=oauth etc.) +// +// Encryption uses AES-256-GCM with HKDF-SHA256 key derivation (< 1ms, safe for embedded Linux). +// An SSH private key is required for both encryption and decryption. +// Key derivation: +// +// HKDF-SHA256(ikm=HMAC-SHA256(SHA256(sshKeyBytes), passphrase), salt, info) +// +// SSH key path resolution priority: +// +// 1. sshKeyPath argument to Encrypt (explicit) +// 2. PICOCLAW_SSH_KEY_PATH env var +// 3. ~/.ssh/picoclaw_ed25519.key (os.UserHomeDir is cross-platform) +package credential + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/hkdf" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// PassphraseEnvVar is the environment variable that holds the encryption passphrase. +// Other packages (e.g. config) reference this constant to avoid duplicating the string. +const PassphraseEnvVar = "PICOCLAW_KEY_PASSPHRASE" + +// PassphraseProvider is the function used to retrieve the passphrase for enc:// +// credential decryption. It defaults to reading PICOCLAW_KEY_PASSPHRASE from the +// process environment. Replace it at startup to use a different source, such as +// an in-memory SecureStore, so that all LoadConfig() calls everywhere share the +// same passphrase source without needing os.Environ. +// +// Example (launcher main.go): +// +// credential.PassphraseProvider = apiHandler.passphraseStore.Get +var PassphraseProvider func() string = func() string { + return os.Getenv(PassphraseEnvVar) +} + +// ErrPassphraseRequired is returned when an enc:// credential is encountered but +// no passphrase is available from PassphraseProvider. Callers can detect this +// with errors.Is to distinguish a missing-passphrase condition from other errors. +var ErrPassphraseRequired = errors.New("credential: enc:// passphrase required") + +// ErrDecryptionFailed is returned when an enc:// credential cannot be decrypted, +// indicating a wrong passphrase or SSH key. Callers can detect this with errors.Is. +var ErrDecryptionFailed = errors.New("credential: enc:// decryption failed (wrong passphrase or SSH key?)") + +const ( + fileScheme = "file://" + encScheme = "enc://" + hkdfInfo = "picoclaw-credential-v1" + saltLen = 16 + nonceLen = 12 + keyLen = 32 + sshKeyEnv = "PICOCLAW_SSH_KEY_PATH" +) + +// Resolver resolves raw credential strings for model_list api_key fields. +// File references are resolved relative to the directory of the config file. +type Resolver struct { + configDir string + resolvedConfigDir string // symlink-resolved form of configDir +} + +// NewResolver returns a Resolver that resolves file:// references relative to +// configDir (typically filepath.Dir of the config file path). +func NewResolver(configDir string) *Resolver { + resolved := configDir + if configDir != "" { + if linkedPath, err := filepath.EvalSymlinks(configDir); err == nil { + resolved = linkedPath + } + } + return &Resolver{configDir: configDir, resolvedConfigDir: resolved} +} + +// Resolve returns the actual credential value for raw: +// +// - "" → "" (no error; auth_method=oauth needs no key) +// - "file://name.key" → trimmed content of configDir/name.key +// - anything else → raw unchanged (plaintext credential) +func (r *Resolver) Resolve(raw string) (string, error) { + if raw == "" { + return "", nil + } + + if strings.HasPrefix(raw, fileScheme) { + fileName := strings.TrimSpace(strings.TrimPrefix(raw, fileScheme)) + if fileName == "" { + return "", fmt.Errorf("credential: file:// reference has no filename") + } + + baseDir := r.resolvedConfigDir + if baseDir == "" { + baseDir = r.configDir + } + keyPath := filepath.Join(baseDir, fileName) + // Resolve symlinks before enforcing containment to prevent escaping via symlinks. + realKeyPath, err := filepath.EvalSymlinks(keyPath) + if err != nil { + return "", fmt.Errorf("credential: failed to resolve credential file path %q: %w", keyPath, err) + } + if !isWithinDir(realKeyPath, baseDir) { + return "", fmt.Errorf("credential: file:// path escapes config directory") + } + data, err := os.ReadFile(realKeyPath) + if err != nil { + return "", fmt.Errorf("credential: failed to read credential file %q: %w", realKeyPath, err) + } + + value := strings.TrimSpace(string(data)) + if value == "" { + return "", fmt.Errorf("credential: credential file %q is empty", realKeyPath) + } + + return value, nil + } + + if strings.HasPrefix(raw, encScheme) { + return resolveEncrypted(raw) + } + + // Plaintext credential — return unchanged. + return raw, nil +} + +// resolveEncrypted decrypts an enc:// credential using PassphraseProvider. +func resolveEncrypted(raw string) (string, error) { + passphrase := PassphraseProvider() + if passphrase == "" { + return "", ErrPassphraseRequired + } + + sshKeyPath := pickSSHKeyPath("") // override="": consult env then auto-detect + + b64 := strings.TrimPrefix(raw, encScheme) + blob, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + return "", fmt.Errorf("credential: enc:// invalid base64: %w", err) + } + if len(blob) < saltLen+nonceLen+1 { + return "", fmt.Errorf("credential: enc:// payload too short") + } + + salt := blob[:saltLen] + nonce := blob[saltLen : saltLen+nonceLen] + ciphertext := blob[saltLen+nonceLen:] + + key, err := deriveKey(passphrase, sshKeyPath, salt) + if err != nil { + return "", err + } + block, err := aes.NewCipher(key) + if err != nil { + return "", fmt.Errorf("credential: enc:// cipher init: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("credential: enc:// gcm init: %w", err) + } + + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", fmt.Errorf("%w: %w", ErrDecryptionFailed, err) + } + return string(plaintext), nil +} + +// Encrypt encrypts plaintext and returns an enc:// credential string. +// +// passphrase is required (PICOCLAW_KEY_PASSPHRASE value). +// sshKeyPath is the SSH private key file to use; pass "" to auto-detect via +// PICOCLAW_SSH_KEY_PATH env var or ~/.ssh/picoclaw_ed25519.key. +// An SSH private key must be resolvable or Encrypt returns an error. +func Encrypt(passphrase, sshKeyPath, plaintext string) (string, error) { + if passphrase == "" { + return "", fmt.Errorf("credential: passphrase must not be empty") + } + sshKeyPath = pickSSHKeyPath(sshKeyPath) + + salt := make([]byte, saltLen) + if _, err := io.ReadFull(rand.Reader, salt); err != nil { + return "", fmt.Errorf("credential: failed to generate salt: %w", err) + } + + key, err := deriveKey(passphrase, sshKeyPath, salt) + if err != nil { + return "", err + } + block, err := aes.NewCipher(key) + if err != nil { + return "", fmt.Errorf("credential: cipher init: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("credential: gcm init: %w", err) + } + + nonce := make([]byte, nonceLen) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", fmt.Errorf("credential: failed to generate nonce: %w", err) + } + + ciphertext := gcm.Seal(nil, nonce, []byte(plaintext), nil) + blob := make([]byte, 0, saltLen+nonceLen+len(ciphertext)) + blob = append(blob, salt...) + blob = append(blob, nonce...) + blob = append(blob, ciphertext...) + return encScheme + base64.StdEncoding.EncodeToString(blob), nil +} + +// isWithinDir reports whether path is contained within (or equal to) dir. +// Uses filepath.IsLocal on the relative path for robust cross-platform traversal detection. +func isWithinDir(path, dir string) bool { + rel, err := filepath.Rel(filepath.Clean(dir), filepath.Clean(path)) + return err == nil && filepath.IsLocal(rel) +} + +// allowedSSHKeyPath reports whether path is in a permitted location for SSH key files: +// - exact match with PICOCLAW_SSH_KEY_PATH env var +// - within the PICOCLAW_HOME env var directory +// - within ~/.ssh/ +func allowedSSHKeyPath(path string) bool { + if path == "" { + return true // passphrase-only mode; no file will be read + } + clean := filepath.Clean(path) + + // Exact match with PICOCLAW_SSH_KEY_PATH. + if envPath, ok := os.LookupEnv(sshKeyEnv); ok && envPath != "" { + if clean == filepath.Clean(envPath) { + return true + } + } + + // Within PICOCLAW_HOME. + if picoHome := os.Getenv("PICOCLAW_HOME"); picoHome != "" { + if isWithinDir(clean, picoHome) { + return true + } + } + + // Within ~/.ssh/. + if userHome, err := os.UserHomeDir(); err == nil { + if isWithinDir(clean, filepath.Join(userHome, ".ssh")) { + return true + } + } + + return false +} + +// deriveKey derives a 32-byte AES-256 key from passphrase and SSH private key. +// +// ikm = HMAC-SHA256(key=SHA256(sshKeyBytes), msg=passphrase) +// Final key: HKDF-SHA256(ikm, salt, info="picoclaw-credential-v1", 32 bytes) +// sshKeyPath must be non-empty; returns an error otherwise. +func deriveKey(passphrase, sshKeyPath string, salt []byte) ([]byte, error) { + if sshKeyPath == "" { + return nil, fmt.Errorf( + "credential: SSH private key is required but not found" + + " (set PICOCLAW_SSH_KEY_PATH or place key at ~/.ssh/picoclaw_ed25519.key)") + } + if !allowedSSHKeyPath(sshKeyPath) { + return nil, fmt.Errorf( + "credential: SSH key path %q is not in an allowed location (PICOCLAW_SSH_KEY_PATH, PICOCLAW_HOME, or ~/.ssh/)", + sshKeyPath, + ) + } + sshBytes, err := os.ReadFile(sshKeyPath) + if err != nil { + return nil, fmt.Errorf("credential: cannot read SSH key %q: %w", sshKeyPath, err) + } + sshHash := sha256.Sum256(sshBytes) + mac := hmac.New(sha256.New, sshHash[:]) + mac.Write([]byte(passphrase)) + ikm := mac.Sum(nil) + + key, err := hkdf.Key(sha256.New, ikm, salt, hkdfInfo, keyLen) + if err != nil { + return nil, fmt.Errorf("credential: HKDF expand failed: %w", err) + } + return key, nil +} + +// pickSSHKeyPath returns the SSH private key path to use for encryption/decryption. +// +// Priority: +// 1. override (non-empty explicit argument) +// 2. PICOCLAW_SSH_KEY_PATH env var +// 3. ~/.ssh/picoclaw_ed25519.key (auto-detection) +// +// Returns "" when no key is found; deriveKey will return an error in that case. +func pickSSHKeyPath(override string) string { + if override != "" { + return override + } + if p, ok := os.LookupEnv(sshKeyEnv); ok { + return p // respect explicit setting, even if "" + } + return findDefaultSSHKey() +} + +// findDefaultSSHKey returns the picoclaw-specific SSH key path if it exists. +func findDefaultSSHKey() string { + p, err := DefaultSSHKeyPath() + if err != nil { + return "" + } + if _, err := os.Stat(p); err == nil { + return p + } + return "" +} diff --git a/pkg/credential/credential_test.go b/pkg/credential/credential_test.go new file mode 100644 index 000000000..138af3134 --- /dev/null +++ b/pkg/credential/credential_test.go @@ -0,0 +1,283 @@ +package credential_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/sipeed/picoclaw/pkg/credential" +) + +func TestResolve_PlainKey(t *testing.T) { + r := credential.NewResolver(t.TempDir()) + got, err := r.Resolve("sk-plaintext-key") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "sk-plaintext-key" { + t.Fatalf("got %q, want %q", got, "sk-plaintext-key") + } +} + +func TestResolve_FileKey_Success(t *testing.T) { + dir := t.TempDir() + keyFile := "openai_plain.key" + if err := os.WriteFile(filepath.Join(dir, keyFile), []byte("sk-from-file\n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + r := credential.NewResolver(dir) + got, err := r.Resolve("file://" + keyFile) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "sk-from-file" { + t.Fatalf("got %q, want %q", got, "sk-from-file") + } +} + +func TestResolve_FileKey_NotFound(t *testing.T) { + r := credential.NewResolver(t.TempDir()) + _, err := r.Resolve("file://missing.key") + if err == nil { + t.Fatal("expected error for missing file, got nil") + } +} + +func TestResolve_FileKey_Empty(t *testing.T) { + dir := t.TempDir() + keyFile := "empty.key" + if err := os.WriteFile(filepath.Join(dir, keyFile), []byte(" \n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + r := credential.NewResolver(dir) + _, err := r.Resolve("file://" + keyFile) + if err == nil { + t.Fatal("expected error for empty credential file, got nil") + } +} + +// TestResolve_EncKey_RoundTrip tests basic encryption/decryption round-trip with an SSH key. +func TestResolve_EncKey_RoundTrip(t *testing.T) { + dir := t.TempDir() + sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key") + if err := os.WriteFile(sshKeyPath, []byte("fake-ssh-key-material\n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + const passphrase = "test-passphrase-32bytes-long-ok!" + const plaintext = "sk-encrypted-secret" + + t.Setenv("PICOCLAW_SSH_KEY_PATH", sshKeyPath) + + enc, err := credential.Encrypt(passphrase, "", plaintext) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + t.Setenv("PICOCLAW_KEY_PASSPHRASE", passphrase) + + r := credential.NewResolver(t.TempDir()) + got, err := r.Resolve(enc) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if got != plaintext { + t.Fatalf("got %q, want %q", got, plaintext) + } +} + +// TestResolve_EncKey_WithSSHKey tests that the SSH key file is incorporated into key derivation. +func TestResolve_EncKey_WithSSHKey(t *testing.T) { + dir := t.TempDir() + sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key") + if err := os.WriteFile(sshKeyPath, []byte("fake-ssh-private-key-material\n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + const passphrase = "test-passphrase" + const plaintext = "sk-ssh-protected-secret" + + // Set PICOCLAW_SSH_KEY_PATH before Encrypt so the path passes allowedSSHKeyPath validation. + t.Setenv("PICOCLAW_KEY_PASSPHRASE", passphrase) + t.Setenv("PICOCLAW_SSH_KEY_PATH", sshKeyPath) + + enc, err := credential.Encrypt(passphrase, sshKeyPath, plaintext) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + r := credential.NewResolver(t.TempDir()) + got, err := r.Resolve(enc) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if got != plaintext { + t.Fatalf("got %q, want %q", got, plaintext) + } +} + +func TestResolve_EncKey_NoPassphrase(t *testing.T) { + dir := t.TempDir() + sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key") + if err := os.WriteFile(sshKeyPath, []byte("fake-ssh-key\n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + t.Setenv("PICOCLAW_SSH_KEY_PATH", sshKeyPath) + + enc, err := credential.Encrypt("some-passphrase", "", "sk-secret") + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "") + + r := credential.NewResolver(t.TempDir()) + _, err = r.Resolve(enc) + if err == nil { + t.Fatal("expected error when PICOCLAW_KEY_PASSPHRASE is unset, got nil") + } +} + +func TestResolve_EncKey_BadCiphertext(t *testing.T) { + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "some-passphrase") + t.Setenv("PICOCLAW_SSH_KEY_PATH", "") + + r := credential.NewResolver(t.TempDir()) + _, err := r.Resolve("enc://!!not-valid-base64!!") + if err == nil { + t.Fatal("expected error for invalid enc:// payload, got nil") + } +} + +func TestResolve_EncKey_PayloadTooShort(t *testing.T) { + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "some-passphrase") + t.Setenv("PICOCLAW_SSH_KEY_PATH", "") + + // Valid base64 but fewer bytes than salt(16)+nonce(12)+1 minimum. + import64 := "dG9vc2hvcnQ=" // "tooshort" = 8 bytes + r := credential.NewResolver(t.TempDir()) + _, err := r.Resolve("enc://" + import64) + if err == nil { + t.Fatal("expected error for too-short enc:// payload, got nil") + } +} + +func TestResolve_EncKey_WrongPassphrase(t *testing.T) { + dir := t.TempDir() + sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key") + if err := os.WriteFile(sshKeyPath, []byte("fake-ssh-key\n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + t.Setenv("PICOCLAW_SSH_KEY_PATH", sshKeyPath) + + enc, err := credential.Encrypt("correct-passphrase", "", "sk-secret") + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "wrong-passphrase") + + r := credential.NewResolver(t.TempDir()) + _, err = r.Resolve(enc) + if err == nil { + t.Fatal("expected decryption error for wrong passphrase, got nil") + } +} + +func TestEncrypt_EmptyPassphrase(t *testing.T) { + _, err := credential.Encrypt("", "", "sk-secret") + if err == nil { + t.Fatal("expected error for empty passphrase, got nil") + } +} + +func TestDeriveKey_SSHKeyNotFound(t *testing.T) { + // Encrypt with a real SSH key path, then try to decrypt with a missing path. + dir := t.TempDir() + sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key") + if err := os.WriteFile(sshKeyPath, []byte("fake-key\n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + // Register the real key path so allowedSSHKeyPath validation passes for Encrypt. + t.Setenv("PICOCLAW_SSH_KEY_PATH", sshKeyPath) + + enc, err := credential.Encrypt("passphrase", sshKeyPath, "sk-secret") + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + // Point to a non-existent SSH key so deriveKey's ReadFile fails. + // The path is still under the same dir, so allowedSSHKeyPath passes (exact env match). + t.Setenv("PICOCLAW_KEY_PASSPHRASE", "passphrase") + t.Setenv("PICOCLAW_SSH_KEY_PATH", filepath.Join(dir, "nonexistent_key")) + + r := credential.NewResolver(t.TempDir()) + _, err = r.Resolve(enc) + if err == nil { + t.Fatal("expected error when SSH key file is missing, got nil") + } +} + +// TestResolve_FileRef_PathTraversal verifies that file:// references cannot escape configDir +// via relative traversal ("../../etc/passwd") or absolute paths ("/abs/path"). +func TestResolve_FileRef_PathTraversal(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.json") + // Create a file outside configDir that the traversal would point to. + outsideFile := filepath.Join(t.TempDir(), "secret.key") + if err := os.WriteFile(outsideFile, []byte("stolen"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + r := credential.NewResolver(filepath.Dir(cfgPath)) + + cases := []string{ + "file://../../secret.key", + "file://../secret.key", + "file://" + outsideFile, // absolute path + } + for _, raw := range cases { + _, err := r.Resolve(raw) + if err == nil { + t.Errorf("Resolve(%q): expected path traversal error, got nil", raw) + } + } +} + +// TestResolve_FileRef_withinConfigDir verifies that a legitimate relative file:// ref works. +func TestResolve_FileRef_withinConfigDir(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "my.key"), []byte("sk-valid\n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + r := credential.NewResolver(dir) + got, err := r.Resolve("file://my.key") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "sk-valid" { + t.Fatalf("got %q, want %q", got, "sk-valid") + } +} + +// TestEncrypt_SSHKeyOutsideAllowedDirs verifies that Encrypt rejects SSH key paths +// that are not under PICOCLAW_SSH_KEY_PATH, PICOCLAW_HOME, or ~/.ssh/. +func TestEncrypt_SSHKeyOutsideAllowedDirs(t *testing.T) { + dir := t.TempDir() + sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key") + if err := os.WriteFile(sshKeyPath, []byte("fake-key\n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + // Make sure none of the allowed env vars point here. + t.Setenv("PICOCLAW_SSH_KEY_PATH", "") + t.Setenv("PICOCLAW_HOME", "") + + _, err := credential.Encrypt("passphrase", sshKeyPath, "sk-secret") + if err == nil { + t.Fatal("expected error for SSH key outside allowed directories, got nil") + } +} diff --git a/pkg/credential/keygen.go b/pkg/credential/keygen.go new file mode 100644 index 000000000..c57564a76 --- /dev/null +++ b/pkg/credential/keygen.go @@ -0,0 +1,62 @@ +package credential + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "fmt" + "os" + "path/filepath" + + "golang.org/x/crypto/ssh" +) + +// DefaultSSHKeyPath returns the canonical path for the picoclaw-specific SSH key. +// The path is always ~/.ssh/picoclaw_ed25519.key (os.UserHomeDir is cross-platform). +func DefaultSSHKeyPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("credential: cannot determine home directory: %w", err) + } + return filepath.Join(home, ".ssh", "picoclaw_ed25519.key"), nil +} + +// GenerateSSHKey generates an Ed25519 SSH key pair and writes the private key +// to path (permissions 0600) and the public key to path+".pub" (permissions 0644). +// The ~/.ssh/ directory is created with 0700 if it does not exist. +// If the files already exist they are overwritten. +func GenerateSSHKey(path string) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("credential: keygen: cannot create directory %q: %w", filepath.Dir(path), err) + } + + pubRaw, privRaw, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return fmt.Errorf("credential: keygen: ed25519 key generation failed: %w", err) + } + + // Marshal private key as OpenSSH PEM. + block, err := ssh.MarshalPrivateKey(privRaw, "") + if err != nil { + return fmt.Errorf("credential: keygen: marshal private key: %w", err) + } + privPEM := pem.EncodeToMemory(block) + + if err = os.WriteFile(path, privPEM, 0o600); err != nil { + return fmt.Errorf("credential: keygen: write private key %q: %w", path, err) + } + + // Marshal public key as authorized_keys line. + sshPub, err := ssh.NewPublicKey(pubRaw) + if err != nil { + return fmt.Errorf("credential: keygen: marshal public key: %w", err) + } + pubLine := ssh.MarshalAuthorizedKey(sshPub) + + pubPath := path + ".pub" + if err := os.WriteFile(pubPath, pubLine, 0o644); err != nil { + return fmt.Errorf("credential: keygen: write public key %q: %w", pubPath, err) + } + + return nil +} diff --git a/pkg/credential/keygen_test.go b/pkg/credential/keygen_test.go new file mode 100644 index 000000000..1e21ea0b9 --- /dev/null +++ b/pkg/credential/keygen_test.go @@ -0,0 +1,115 @@ +package credential + +import ( + "crypto/ed25519" + "os" + "path/filepath" + "runtime" + "testing" + + "golang.org/x/crypto/ssh" +) + +func TestGenerateSSHKey_CreatesFiles(t *testing.T) { + dir := t.TempDir() + keyPath := filepath.Join(dir, "test_ed25519.key") + + if err := GenerateSSHKey(keyPath); err != nil { + t.Fatalf("GenerateSSHKey() error = %v", err) + } + + // Private key must exist. + privInfo, err := os.Stat(keyPath) + if err != nil { + t.Fatalf("private key file missing: %v", err) + } + + // Check permissions on non-Windows (Windows does not support Unix permission bits). + if runtime.GOOS != "windows" { + if got := privInfo.Mode().Perm(); got != 0o600 { + t.Errorf("private key permissions = %04o, want 0600", got) + } + } + + // Public key must exist. + pubPath := keyPath + ".pub" + pubInfo, err := os.Stat(pubPath) + if err != nil { + t.Fatalf("public key file missing: %v", err) + } + if runtime.GOOS != "windows" { + if got := pubInfo.Mode().Perm(); got != 0o644 { + t.Errorf("public key permissions = %04o, want 0644", got) + } + } + + // Private key must be parseable as an OpenSSH ed25519 key. + privPEM, err := os.ReadFile(keyPath) + if err != nil { + t.Fatalf("read private key: %v", err) + } + privKey, err := ssh.ParseRawPrivateKey(privPEM) + if err != nil { + t.Fatalf("parse private key: %v", err) + } + if _, ok := privKey.(*ed25519.PrivateKey); !ok { + t.Errorf("private key type = %T, want *ed25519.PrivateKey", privKey) + } + + // Public key must be parseable as authorized_keys line. + pubBytes, err := os.ReadFile(pubPath) + if err != nil { + t.Fatalf("read public key: %v", err) + } + pubKey, _, _, rest, err := ssh.ParseAuthorizedKey(pubBytes) + if err != nil { + t.Fatalf("parse public key: %v", err) + } + if pubKey == nil { + t.Fatal("expected non-nil public key") + } + if len(rest) > 0 { + t.Errorf("unexpected trailing bytes after public key: %d bytes", len(rest)) + } +} + +func TestGenerateSSHKey_OverwritesExisting(t *testing.T) { + dir := t.TempDir() + keyPath := filepath.Join(dir, "test_ed25519.key") + + // Generate twice; second call must not error and must produce a different key. + if err := GenerateSSHKey(keyPath); err != nil { + t.Fatalf("first GenerateSSHKey() error = %v", err) + } + first, err := os.ReadFile(keyPath) + if err != nil { + t.Fatalf("read first key: %v", err) + } + + if err = GenerateSSHKey(keyPath); err != nil { + t.Fatalf("second GenerateSSHKey() error = %v", err) + } + second, err := os.ReadFile(keyPath) + if err != nil { + t.Fatalf("read second key: %v", err) + } + + // Two independently generated Ed25519 keys must differ. + if string(first) == string(second) { + t.Error("expected overwritten key to differ from original") + } +} + +func TestGenerateSSHKey_CreatesDirectory(t *testing.T) { + dir := t.TempDir() + // Nested directory that does not yet exist. + keyPath := filepath.Join(dir, "subdir", ".ssh", "picoclaw_ed25519.key") + + if err := GenerateSSHKey(keyPath); err != nil { + t.Fatalf("GenerateSSHKey() error = %v", err) + } + + if _, err := os.Stat(keyPath); err != nil { + t.Fatalf("private key not created: %v", err) + } +} diff --git a/pkg/credential/store.go b/pkg/credential/store.go new file mode 100644 index 000000000..9c72974b0 --- /dev/null +++ b/pkg/credential/store.go @@ -0,0 +1,44 @@ +package credential + +import "sync/atomic" + +// SecureStore holds a passphrase in memory. +// +// Uses atomic.Pointer so reads and writes are lock-free. +// The passphrase is never written to disk; callers decide how to +// transport it outside this store (e.g., via cmd.Env or os.Environ). +type SecureStore struct { + val atomic.Pointer[string] +} + +// NewSecureStore creates an empty SecureStore. +func NewSecureStore() *SecureStore { + return &SecureStore{} +} + +// SetString stores the passphrase. An empty string clears the store. +func (s *SecureStore) SetString(passphrase string) { + if passphrase == "" { + s.val.Store(nil) + return + } + s.val.Store(&passphrase) +} + +// Get returns the stored passphrase, or "" if not set. +func (s *SecureStore) Get() string { + if p := s.val.Load(); p != nil { + return *p + } + return "" +} + +// IsSet reports whether a passphrase is currently stored. +func (s *SecureStore) IsSet() bool { + return s.val.Load() != nil +} + +// Clear removes the stored passphrase. +func (s *SecureStore) Clear() { + s.val.Store(nil) +} diff --git a/pkg/credential/store_test.go b/pkg/credential/store_test.go new file mode 100644 index 000000000..63299743a --- /dev/null +++ b/pkg/credential/store_test.go @@ -0,0 +1,81 @@ +package credential + +import ( + "sync" + "testing" +) + +func TestSecureStore_SetGet(t *testing.T) { + s := NewSecureStore() + if s.IsSet() { + t.Error("expected empty store") + } + + s.SetString("hunter2") + if !s.IsSet() { + t.Error("expected store to be set") + } + if got := s.Get(); got != "hunter2" { + t.Errorf("Get() = %q, want %q", got, "hunter2") + } +} + +func TestSecureStore_Clear(t *testing.T) { + s := NewSecureStore() + s.SetString("secret") + s.Clear() + + if s.IsSet() { + t.Error("expected store to be empty after Clear()") + } + if got := s.Get(); got != "" { + t.Errorf("Get() after Clear() = %q, want empty", got) + } +} + +func TestSecureStore_SetOverwrites(t *testing.T) { + s := NewSecureStore() + s.SetString("first") + s.SetString("second") + + if got := s.Get(); got != "second" { + t.Errorf("Get() = %q, want %q", got, "second") + } +} + +func TestSecureStore_EmptyPassphrase(t *testing.T) { + s := NewSecureStore() + s.SetString("") // empty → should not mark as set + + if s.IsSet() { + t.Error("empty passphrase should not mark store as set") + } +} + +func TestSecureStore_ConcurrentSetGet(t *testing.T) { + s := NewSecureStore() + const goroutines = 10 + const iterations = 1000 + + var wg sync.WaitGroup + wg.Add(goroutines) + for i := 0; i < goroutines; i++ { + go func(id int) { + defer wg.Done() + for j := 0; j < iterations; j++ { + if id%2 == 0 { + s.SetString("even") + } else { + s.SetString("odd") + } + _ = s.Get() + } + }(i) + } + wg.Wait() + + final := s.Get() + if final != "" && final != "even" && final != "odd" { + t.Errorf("Get() returned unexpected value %q after concurrent Set/Get", final) + } +} From 4d4243b919accb4969f2cfe71011e4a9989b80d5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:08:29 +0800 Subject: [PATCH 19/28] chore(deps): bump docker/setup-buildx-action from 3 to 4 (#1595) Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3 to 4. - [Release notes](https://github.com/docker/setup-buildx-action/releases) - [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4) --- updated-dependencies: - dependency-name: docker/setup-buildx-action dependency-version: '4' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docker-build.yml | 2 +- .github/workflows/nightly.yml | 2 +- .github/workflows/release.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index dadbed212..c03c6346f 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -31,7 +31,7 @@ jobs: # ── Docker Buildx ───────────────────────── - name: 🔧 Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 # ── Login to GHCR ───────────────────────── - name: 🔑 Login to GitHub Container Registry diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 0103fcff1..375e2e211 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -59,7 +59,7 @@ jobs: uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to GitHub Container Registry uses: docker/login-action@v3 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4a584773d..56d2f2b23 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -77,7 +77,7 @@ jobs: uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to GitHub Container Registry uses: docker/login-action@v3 From 44ac304e5b122bac7fbc9c9b6699ff335f0da0e2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:09:01 +0800 Subject: [PATCH 20/28] chore(deps): bump actions/setup-node from 4 to 6 (#1597) Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 6. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/nightly.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 375e2e211..b29881d6c 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -48,7 +48,7 @@ jobs: go-version-file: go.mod - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 22 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 56d2f2b23..84aade578 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -66,7 +66,7 @@ jobs: go-version-file: go.mod - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 22 From f247c3bc00cb8bba874712535ce07de8c6b1b613 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:09:36 +0800 Subject: [PATCH 21/28] chore(deps): bump actions/setup-go from 5 to 6 (#1600) Bumps [actions/setup-go](https://github.com/actions/setup-go) from 5 to 6. - [Release notes](https://github.com/actions/setup-go/releases) - [Commits](https://github.com/actions/setup-go/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/setup-go dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 1e9a7919a..902d4d4eb 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -34,7 +34,7 @@ jobs: persist-credentials: false - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version-file: go.mod From b7b8d1eeca7a750f3c42820727135dca8f080ced Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:10:19 +0800 Subject: [PATCH 22/28] chore(deps): bump docker/build-push-action from 6 to 7 (#1602) Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6 to 7. - [Release notes](https://github.com/docker/build-push-action/releases) - [Commits](https://github.com/docker/build-push-action/compare/v6...v7) --- updated-dependencies: - dependency-name: docker/build-push-action dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docker-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index c03c6346f..8b4c033c2 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -62,7 +62,7 @@ jobs: # ── Build & Push ────────────────────────── - name: 🚀 Build and push Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . push: true From 0c94e6f7b3d2a19d4e0f85bb1b5647dda14355e0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:11:22 +0800 Subject: [PATCH 23/28] chore(deps): bump docker/login-action from 3 to 4 (#1604) Bumps [docker/login-action](https://github.com/docker/login-action) from 3 to 4. - [Release notes](https://github.com/docker/login-action/releases) - [Commits](https://github.com/docker/login-action/compare/v3...v4) --- updated-dependencies: - dependency-name: docker/login-action dependency-version: '4' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docker-build.yml | 4 ++-- .github/workflows/nightly.yml | 4 ++-- .github/workflows/release.yml | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 8b4c033c2..784c404a6 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -35,7 +35,7 @@ jobs: # ── Login to GHCR ───────────────────────── - name: 🔑 Login to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.GHCR_REGISTRY }} username: ${{ github.actor }} @@ -43,7 +43,7 @@ jobs: # ── Login to Docker Hub ──────────────────── - name: 🔑 Login to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.DOCKERHUB_REGISTRY }} username: ${{ secrets.DOCKERHUB_USERNAME }} diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index b29881d6c..e001dc3e9 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -62,14 +62,14 @@ jobs: uses: docker/setup-buildx-action@v4 - name: Login to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Login to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: docker.io username: ${{ secrets.DOCKERHUB_USERNAME }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 84aade578..19c8e5404 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -80,14 +80,14 @@ jobs: uses: docker/setup-buildx-action@v4 - name: Login to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Login to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: docker.io username: ${{ secrets.DOCKERHUB_USERNAME }} From c513ad22d73a579bb3fb30efc5b012b07c71a32c Mon Sep 17 00:00:00 2001 From: wenjie Date: Mon, 16 Mar 2026 16:25:16 +0800 Subject: [PATCH 24/28] fix(web): refactor pico chat flow and fix proxied websocket URLs (#1639) - move chat controller, state, protocol, history, and websocket logic into a dedicated chat feature module - improve chat reconnection, session hydration, and send gating based on actual websocket state - preserve gateway status during transient SSE disconnects and update stop state immediately - generate wss websocket URLs behind HTTPS proxies and add backend tests for forwarded proto handling --- web/backend/api/gateway_host.go | 20 +- web/backend/api/gateway_host_test.go | 53 +++ .../src/components/chat/chat-composer.tsx | 4 +- .../src/components/chat/chat-empty-state.tsx | 2 +- .../src/components/chat/chat-page.tsx | 23 +- .../chat/controller.ts} | 337 ++++++++++-------- web/frontend/src/features/chat/history.ts | 68 ++++ web/frontend/src/features/chat/protocol.ts | 81 +++++ .../chat/state.ts} | 0 web/frontend/src/features/chat/websocket.ts | 57 +++ web/frontend/src/hooks/use-gateway.ts | 12 +- web/frontend/src/hooks/use-pico-chat.ts | 4 +- web/frontend/src/hooks/use-websocket.ts | 47 --- web/frontend/src/routes/__root.tsx | 2 +- web/frontend/src/store/chat.ts | 2 +- web/frontend/src/store/gateway.ts | 12 +- 16 files changed, 509 insertions(+), 215 deletions(-) rename web/frontend/src/{lib/pico-chat-controller.ts => features/chat/controller.ts} (53%) create mode 100644 web/frontend/src/features/chat/history.ts create mode 100644 web/frontend/src/features/chat/protocol.ts rename web/frontend/src/{lib/pico-chat-state.ts => features/chat/state.ts} (100%) create mode 100644 web/frontend/src/features/chat/websocket.ts delete mode 100644 web/frontend/src/hooks/use-websocket.ts diff --git a/web/backend/api/gateway_host.go b/web/backend/api/gateway_host.go index a499c1ea2..5ef3ba2c5 100644 --- a/web/backend/api/gateway_host.go +++ b/web/backend/api/gateway_host.go @@ -57,10 +57,28 @@ func requestHostName(r *http.Request) string { return "127.0.0.1" } +func requestWSScheme(r *http.Request) string { + if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwarded != "" { + proto := strings.ToLower(strings.TrimSpace(strings.Split(forwarded, ",")[0])) + if proto == "https" || proto == "wss" { + return "wss" + } + if proto == "http" || proto == "ws" { + return "ws" + } + } + + if r.TLS != nil { + return "wss" + } + + return "ws" +} + func (h *Handler) buildWsURL(r *http.Request, cfg *config.Config) string { host := h.effectiveGatewayBindHost(cfg) if host == "" || host == "0.0.0.0" { host = requestHostName(r) } - return "ws://" + net.JoinHostPort(host, strconv.Itoa(cfg.Gateway.Port)) + "/pico/ws" + return requestWSScheme(r) + "://" + net.JoinHostPort(host, strconv.Itoa(cfg.Gateway.Port)) + "/pico/ws" } diff --git a/web/backend/api/gateway_host_test.go b/web/backend/api/gateway_host_test.go index afd600359..43e84ff0e 100644 --- a/web/backend/api/gateway_host_test.go +++ b/web/backend/api/gateway_host_test.go @@ -1,6 +1,7 @@ package api import ( + "crypto/tls" "net/http/httptest" "path/filepath" "testing" @@ -57,3 +58,55 @@ func TestGatewayProbeHostUsesLoopbackForWildcardBind(t *testing.T) { t.Fatalf("gatewayProbeHost() = %q, want %q", got, "127.0.0.1") } } + +func TestBuildWsURLUsesWSSWhenForwardedProtoIsHTTPS(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + cfg := config.DefaultConfig() + cfg.Gateway.Host = "0.0.0.0" + cfg.Gateway.Port = 18790 + + req := httptest.NewRequest("GET", "http://launcher.local/api/pico/token", nil) + req.Host = "chat.example.com" + req.Header.Set("X-Forwarded-Proto", "https") + + if got := h.buildWsURL(req, cfg); got != "wss://chat.example.com:18790/pico/ws" { + t.Fatalf("buildWsURL() = %q, want %q", got, "wss://chat.example.com:18790/pico/ws") + } +} + +func TestBuildWsURLUsesWSSWhenRequestIsTLS(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + cfg := config.DefaultConfig() + cfg.Gateway.Host = "0.0.0.0" + cfg.Gateway.Port = 18790 + + req := httptest.NewRequest("GET", "https://launcher.local/api/pico/token", nil) + req.Host = "secure.example.com" + req.TLS = &tls.ConnectionState{} + + if got := h.buildWsURL(req, cfg); got != "wss://secure.example.com:18790/pico/ws" { + t.Fatalf("buildWsURL() = %q, want %q", got, "wss://secure.example.com:18790/pico/ws") + } +} + +func TestBuildWsURLPrefersForwardedHTTPOverTLS(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + cfg := config.DefaultConfig() + cfg.Gateway.Host = "0.0.0.0" + cfg.Gateway.Port = 18790 + + req := httptest.NewRequest("GET", "https://launcher.local/api/pico/token", nil) + req.Host = "chat.example.com" + req.TLS = &tls.ConnectionState{} + req.Header.Set("X-Forwarded-Proto", "http") + + if got := h.buildWsURL(req, cfg); got != "ws://chat.example.com:18790/pico/ws" { + t.Fatalf("buildWsURL() = %q, want %q", got, "ws://chat.example.com:18790/pico/ws") + } +} diff --git a/web/frontend/src/components/chat/chat-composer.tsx b/web/frontend/src/components/chat/chat-composer.tsx index e8bae89b8..7d696b898 100644 --- a/web/frontend/src/components/chat/chat-composer.tsx +++ b/web/frontend/src/components/chat/chat-composer.tsx @@ -42,7 +42,7 @@ export function ChatComposer({ placeholder={t("chat.placeholder")} disabled={!canInput} className={cn( - "max-h-[200px] min-h-[60px] resize-none border-0 bg-transparent px-2 py-1 text-[15px] shadow-none transition-colors focus-visible:ring-0 focus-visible:outline-none dark:bg-transparent", + "placeholder:text-muted-foreground max-h-[200px] min-h-[60px] resize-none border-0 bg-transparent px-2 py-1 text-[15px] shadow-none transition-colors focus-visible:ring-0 focus-visible:outline-none dark:bg-transparent", !canInput && "cursor-not-allowed", )} minRows={1} @@ -56,7 +56,7 @@ export function ChatComposer({ size="icon" className="size-8 rounded-full bg-violet-500 text-white transition-transform hover:bg-violet-600 active:scale-95" onClick={onSend} - disabled={!input.trim() || !isConnected} + disabled={!input.trim() || !canInput} > diff --git a/web/frontend/src/components/chat/chat-empty-state.tsx b/web/frontend/src/components/chat/chat-empty-state.tsx index 624ff9c59..0574c44d1 100644 --- a/web/frontend/src/components/chat/chat-empty-state.tsx +++ b/web/frontend/src/components/chat/chat-empty-state.tsx @@ -34,7 +34,7 @@ export function ChatEmptyState({

{t("chat.empty.noConfiguredModelDescription")}

- diff --git a/web/frontend/src/components/chat/chat-page.tsx b/web/frontend/src/components/chat/chat-page.tsx index 1906a0367..ebcde8981 100644 --- a/web/frontend/src/components/chat/chat-page.tsx +++ b/web/frontend/src/components/chat/chat-page.tsx @@ -15,7 +15,6 @@ import { useChatModels } from "@/hooks/use-chat-models" import { useGateway } from "@/hooks/use-gateway" import { usePicoChat } from "@/hooks/use-pico-chat" import { useSessionHistory } from "@/hooks/use-session-history" -import { hydrateActiveSession } from "@/lib/pico-chat-controller" export function ChatPage() { const { t } = useTranslation() @@ -26,6 +25,7 @@ export function ChatPage() { const { messages, + connectionState, isTyping, activeSessionId, sendMessage, @@ -34,7 +34,8 @@ export function ChatPage() { } = usePicoChat() const { state: gwState } = useGateway() - const isConnected = gwState === "running" + const isGatewayRunning = gwState === "running" + const isChatConnected = connectionState === "connected" const { defaultModelName, @@ -43,7 +44,8 @@ export function ChatPage() { oauthModels, localModels, handleSetDefault, - } = useChatModels({ isConnected }) + } = useChatModels({ isConnected: isGatewayRunning }) + const canSend = isChatConnected && Boolean(defaultModelName) const { sessions, @@ -68,10 +70,6 @@ export function ChatPage() { syncScrollState(e.currentTarget) } - useEffect(() => { - void hydrateActiveSession() - }, []) - useEffect(() => { if (scrollRef.current) { if (isAtBottom) { @@ -82,9 +80,10 @@ export function ChatPage() { }, [messages, isTyping, isAtBottom]) const handleSend = () => { - if (!input.trim() || !isConnected) return - sendMessage(input.trim()) - setInput("") + if (!input.trim() || !canSend) return + if (sendMessage(input.trim())) { + setInput("") + } } return ( @@ -143,7 +142,7 @@ export function ChatPage() { )} @@ -168,7 +167,7 @@ export function ChatPage() { input={input} onInputChange={setInput} onSend={handleSend} - isConnected={isConnected} + isConnected={isChatConnected} hasDefaultModel={Boolean(defaultModelName)} /> diff --git a/web/frontend/src/lib/pico-chat-controller.ts b/web/frontend/src/features/chat/controller.ts similarity index 53% rename from web/frontend/src/lib/pico-chat-controller.ts rename to web/frontend/src/features/chat/controller.ts index 0e77d1ad0..5e6eb2229 100644 --- a/web/frontend/src/lib/pico-chat-controller.ts +++ b/web/frontend/src/features/chat/controller.ts @@ -2,24 +2,24 @@ import { getDefaultStore } from "jotai" import { toast } from "sonner" import { getPicoToken } from "@/api/pico" -import { getSessionHistory } from "@/api/sessions" -import i18n from "@/i18n" +import { + loadSessionMessages, + mergeHistoryMessages, +} from "@/features/chat/history" +import { type PicoMessage, handlePicoMessage } from "@/features/chat/protocol" import { clearStoredSessionId, generateSessionId, - normalizeUnixTimestamp, readStoredSessionId, -} from "@/lib/pico-chat-state" -import { type ChatMessage, getChatState, updateChatStore } from "@/store/chat" -import { gatewayAtom } from "@/store/gateway" - -interface PicoMessage { - type: string - id?: string - session_id?: string - timestamp?: number | string - payload?: Record -} +} from "@/features/chat/state" +import { + invalidateSocket, + isCurrentSocket, + normalizeWsUrlForBrowser, +} from "@/features/chat/websocket" +import i18n from "@/i18n" +import { getChatState, updateChatStore } from "@/store/chat" +import { type GatewayState, gatewayAtom } from "@/store/gateway" const store = getDefaultStore() @@ -31,81 +31,51 @@ let initialized = false let unsubscribeGateway: (() => void) | null = null let hydratePromise: Promise | null = null let connectionGeneration = 0 +let reconnectTimer: number | null = null +let reconnectAttempts = 0 +let shouldMaintainConnection = false -async function loadSessionMessages(sessionId: string): Promise { - const detail = await getSessionHistory(sessionId) - const fallbackTime = detail.updated - - return detail.messages.map((message, index) => ({ - id: `hist-${index}-${Date.now()}`, - role: message.role, - content: message.content, - timestamp: fallbackTime, - })) +function clearReconnectTimer() { + if (reconnectTimer !== null) { + window.clearTimeout(reconnectTimer) + reconnectTimer = null + } } -function handlePicoMessage(message: PicoMessage) { - const payload = message.payload || {} +function shouldReconnectFor(generation: number, sessionId: string): boolean { + return ( + shouldMaintainConnection && + generation === connectionGeneration && + sessionId === activeSessionIdRef && + store.get(gatewayAtom).status === "running" + ) +} - switch (message.type) { - case "message.create": { - const content = (payload.content as string) || "" - const messageId = (payload.message_id as string) || `pico-${Date.now()}` - const timestamp = - message.timestamp !== undefined && - Number.isFinite(Number(message.timestamp)) - ? normalizeUnixTimestamp(Number(message.timestamp)) - : Date.now() - - updateChatStore((prev) => ({ - messages: [ - ...prev.messages, - { - id: messageId, - role: "assistant", - content, - timestamp, - }, - ], - isTyping: false, - })) - break - } - - case "message.update": { - const content = (payload.content as string) || "" - const messageId = payload.message_id as string - if (!messageId) { - break - } - - updateChatStore((prev) => ({ - messages: prev.messages.map((msg) => - msg.id === messageId ? { ...msg, content } : msg, - ), - })) - break - } - - case "typing.start": - updateChatStore({ isTyping: true }) - break - - case "typing.stop": - updateChatStore({ isTyping: false }) - break - - case "error": - console.error("Pico error:", payload) - updateChatStore({ isTyping: false }) - break - - case "pong": - break - - default: - console.log("Unknown pico message type:", message.type) +function scheduleReconnect(generation: number, sessionId: string) { + if (!shouldReconnectFor(generation, sessionId) || reconnectTimer !== null) { + return } + + const delay = Math.min(1000 * 2 ** reconnectAttempts, 5000) + reconnectAttempts += 1 + reconnectTimer = window.setTimeout(() => { + reconnectTimer = null + if (!shouldReconnectFor(generation, sessionId)) { + return + } + void connectChat() + }, delay) +} + +function needsActiveSessionHydration(): boolean { + const state = getChatState() + const storedSessionId = readStoredSessionId() + + return Boolean( + storedSessionId && + storedSessionId === state.activeSessionId && + !state.hasHydratedActiveSession, + ) } function setActiveSessionId(sessionId: string) { @@ -113,8 +83,35 @@ function setActiveSessionId(sessionId: string) { updateChatStore({ activeSessionId: sessionId }) } +function disconnectChatInternal({ + clearDesiredConnection, +}: { + clearDesiredConnection: boolean +}) { + connectionGeneration += 1 + clearReconnectTimer() + + if (clearDesiredConnection) { + shouldMaintainConnection = false + } + + const socket = wsRef + wsRef = null + isConnecting = false + + invalidateSocket(socket) + + updateChatStore({ + connectionState: "disconnected", + isTyping: false, + }) +} + export async function connectChat() { - if (store.get(gatewayAtom).status !== "running") { + if ( + store.get(gatewayAtom).status !== "running" || + needsActiveSessionHydration() + ) { return } @@ -130,12 +127,15 @@ export async function connectChat() { const generation = connectionGeneration + 1 connectionGeneration = generation isConnecting = true + clearReconnectTimer() updateChatStore({ connectionState: "connecting" }) try { const { token, ws_url } = await getPicoToken() + const sessionId = activeSessionIdRef if (generation !== connectionGeneration) { + isConnecting = false return } @@ -143,56 +143,71 @@ export async function connectChat() { console.error("No pico token available") updateChatStore({ connectionState: "error" }) isConnecting = false + scheduleReconnect(generation, sessionId) return } - let finalWsUrl = ws_url - try { - const parsedUrl = new URL(ws_url) - const isLocalHost = - parsedUrl.hostname === "localhost" || - parsedUrl.hostname === "127.0.0.1" || - parsedUrl.hostname === "0.0.0.0" - const isBrowserLocal = - window.location.hostname === "localhost" || - window.location.hostname === "127.0.0.1" - - if (isLocalHost && !isBrowserLocal) { - parsedUrl.hostname = window.location.hostname - finalWsUrl = parsedUrl.toString() - } - } catch (error) { - console.warn("Could not parse ws_url:", error) - } - - const url = `${finalWsUrl}?session_id=${encodeURIComponent(activeSessionIdRef)}` - // Send token as a subprotocol so it doesn't end up in the URL. + const finalWsUrl = normalizeWsUrlForBrowser(ws_url) + const url = `${finalWsUrl}?session_id=${encodeURIComponent(sessionId)}` const socket = new WebSocket(url, [`token.${token}`]) if (generation !== connectionGeneration) { - socket.close() + isConnecting = false + invalidateSocket(socket) return } socket.onopen = () => { - if (wsRef !== socket) { + if ( + !isCurrentSocket({ + socket, + currentSocket: wsRef, + generation, + currentGeneration: connectionGeneration, + sessionId, + currentSessionId: activeSessionIdRef, + }) + ) { return } updateChatStore({ connectionState: "connected" }) isConnecting = false + reconnectAttempts = 0 } socket.onmessage = (event) => { + if ( + !isCurrentSocket({ + socket, + currentSocket: wsRef, + generation, + currentGeneration: connectionGeneration, + sessionId, + currentSessionId: activeSessionIdRef, + }) + ) { + return + } + try { - const message: PicoMessage = JSON.parse(event.data) - handlePicoMessage(message) + const message = JSON.parse(event.data) as PicoMessage + handlePicoMessage(message, sessionId) } catch { console.warn("Non-JSON message from pico:", event.data) } } socket.onclose = () => { - if (wsRef !== socket) { + if ( + !isCurrentSocket({ + socket, + currentSocket: wsRef, + generation, + currentGeneration: connectionGeneration, + sessionId, + currentSessionId: activeSessionIdRef, + }) + ) { return } wsRef = null @@ -201,42 +216,42 @@ export async function connectChat() { connectionState: "disconnected", isTyping: false, }) + scheduleReconnect(generation, sessionId) } socket.onerror = () => { - if (wsRef !== socket) { + if ( + !isCurrentSocket({ + socket, + currentSocket: wsRef, + generation, + currentGeneration: connectionGeneration, + sessionId, + currentSessionId: activeSessionIdRef, + }) + ) { return } isConnecting = false updateChatStore({ connectionState: "error" }) + scheduleReconnect(generation, sessionId) } wsRef = socket } catch (error) { if (generation !== connectionGeneration) { + isConnecting = false return } console.error("Failed to connect to pico:", error) updateChatStore({ connectionState: "error" }) isConnecting = false + scheduleReconnect(generation, activeSessionIdRef) } } export function disconnectChat() { - connectionGeneration += 1 - - const socket = wsRef - wsRef = null - isConnecting = false - - if (socket) { - socket.close() - } - - updateChatStore({ - connectionState: "disconnected", - isTyping: false, - }) + disconnectChatInternal({ clearDesiredConnection: true }) } export async function hydrateActiveSession() { @@ -250,7 +265,6 @@ export async function hydrateActiveSession() { if ( !storedSessionId || state.hasHydratedActiveSession || - state.messages.length > 0 || storedSessionId !== state.activeSessionId ) { if (!state.hasHydratedActiveSession) { @@ -267,7 +281,13 @@ export async function hydrateActiveSession() { } if (currentState.messages.length > 0) { - updateChatStore({ hasHydratedActiveSession: true }) + updateChatStore({ + messages: mergeHistoryMessages( + historyMessages, + currentState.messages, + ), + hasHydratedActiveSession: true, + }) return } @@ -307,9 +327,10 @@ export async function hydrateActiveSession() { export function sendChatMessage(content: string) { if (!wsRef || wsRef.readyState !== WebSocket.OPEN) { console.warn("WebSocket not connected") - return + return false } + const socket = wsRef const id = `msg-${++msgIdCounter}-${Date.now()}` updateChatStore((prev) => ({ @@ -320,13 +341,23 @@ export function sendChatMessage(content: string) { isTyping: true, })) - wsRef.send( - JSON.stringify({ - type: "message.send", - id, - payload: { content }, - }), - ) + try { + socket.send( + JSON.stringify({ + type: "message.send", + id, + payload: { content }, + }), + ) + return true + } catch (error) { + console.error("Failed to send pico message:", error) + updateChatStore((prev) => ({ + messages: prev.messages.filter((message) => message.id !== id), + isTyping: false, + })) + return false + } } export async function switchChatSession(sessionId: string) { @@ -337,7 +368,7 @@ export async function switchChatSession(sessionId: string) { try { const historyMessages = await loadSessionMessages(sessionId) - disconnectChat() + disconnectChatInternal({ clearDesiredConnection: false }) setActiveSessionId(sessionId) updateChatStore({ messages: historyMessages, @@ -346,6 +377,7 @@ export async function switchChatSession(sessionId: string) { }) if (store.get(gatewayAtom).status === "running") { + shouldMaintainConnection = true await connectChat() } } catch (error) { @@ -359,7 +391,7 @@ export async function newChatSession() { return } - disconnectChat() + disconnectChatInternal({ clearDesiredConnection: false }) setActiveSessionId(generateSessionId()) updateChatStore({ messages: [], @@ -368,6 +400,7 @@ export async function newChatSession() { }) if (store.get(gatewayAtom).status === "running") { + shouldMaintainConnection = true await connectChat() } } @@ -379,23 +412,43 @@ export function initializeChatStore() { initialized = true activeSessionIdRef = getChatState().activeSessionId + let lastGatewayStatus: GatewayState | null = null - const syncConnectionWithGateway = () => { - if (store.get(gatewayAtom).status === "running") { + const syncConnectionWithGateway = (force: boolean = false) => { + const gatewayStatus = store.get(gatewayAtom).status + if (!force && gatewayStatus === lastGatewayStatus) { + return + } + lastGatewayStatus = gatewayStatus + + if (gatewayStatus === "running") { + shouldMaintainConnection = true + if (needsActiveSessionHydration()) { + return + } void connectChat() return } - disconnectChat() + if (gatewayStatus === "stopped" || gatewayStatus === "error") { + disconnectChatInternal({ clearDesiredConnection: true }) + } } unsubscribeGateway = store.sub(gatewayAtom, syncConnectionWithGateway) if (!readStoredSessionId()) { updateChatStore({ hasHydratedActiveSession: true }) + syncConnectionWithGateway(true) + return } - syncConnectionWithGateway() + void hydrateActiveSession().finally(() => { + if (!initialized) { + return + } + syncConnectionWithGateway(true) + }) } export function teardownChatStore() { diff --git a/web/frontend/src/features/chat/history.ts b/web/frontend/src/features/chat/history.ts new file mode 100644 index 000000000..886148184 --- /dev/null +++ b/web/frontend/src/features/chat/history.ts @@ -0,0 +1,68 @@ +import { getSessionHistory } from "@/api/sessions" +import { normalizeUnixTimestamp } from "@/features/chat/state" +import type { ChatMessage } from "@/store/chat" + +export async function loadSessionMessages( + sessionId: string, +): Promise { + const detail = await getSessionHistory(sessionId) + const fallbackTime = detail.updated + + return detail.messages.map((message, index) => ({ + id: `hist-${index}-${Date.now()}`, + role: message.role, + content: message.content, + timestamp: fallbackTime, + })) +} + +function normalizeMessageTimestamp(timestamp: number | string): string { + if (typeof timestamp === "number") { + return String(normalizeUnixTimestamp(timestamp)) + } + + const trimmed = timestamp.trim() + if (/^-?\d+(\.\d+)?$/.test(trimmed)) { + return String(normalizeUnixTimestamp(Number(trimmed))) + } + + const parsed = Date.parse(trimmed) + return Number.isNaN(parsed) ? trimmed : String(parsed) +} + +function messageSignature(message: ChatMessage): string { + return `${message.role}\u0000${message.content}\u0000${normalizeMessageTimestamp( + message.timestamp, + )}` +} + +function comparableTimestamp(timestamp: number | string): number { + const normalized = normalizeMessageTimestamp(timestamp) + const numeric = Number(normalized) + return Number.isFinite(numeric) ? numeric : 0 +} + +export function mergeHistoryMessages( + historyMessages: ChatMessage[], + currentMessages: ChatMessage[], +): ChatMessage[] { + const currentIds = new Set(currentMessages.map((message) => message.id)) + const currentSignatures = new Set( + currentMessages.map((message) => messageSignature(message)), + ) + + const merged = [ + ...historyMessages.filter( + (message) => + !currentIds.has(message.id) && + !currentSignatures.has(messageSignature(message)), + ), + ...currentMessages, + ] + + return merged.sort( + (left, right) => + comparableTimestamp(left.timestamp) - + comparableTimestamp(right.timestamp), + ) +} diff --git a/web/frontend/src/features/chat/protocol.ts b/web/frontend/src/features/chat/protocol.ts new file mode 100644 index 000000000..5e5220c77 --- /dev/null +++ b/web/frontend/src/features/chat/protocol.ts @@ -0,0 +1,81 @@ +import { normalizeUnixTimestamp } from "@/features/chat/state" +import { updateChatStore } from "@/store/chat" + +export interface PicoMessage { + type: string + id?: string + session_id?: string + timestamp?: number | string + payload?: Record +} + +export function handlePicoMessage( + message: PicoMessage, + expectedSessionId: string, +) { + if (message.session_id && message.session_id !== expectedSessionId) { + return + } + + const payload = message.payload || {} + + switch (message.type) { + case "message.create": { + const content = (payload.content as string) || "" + const messageId = (payload.message_id as string) || `pico-${Date.now()}` + const timestamp = + message.timestamp !== undefined && + Number.isFinite(Number(message.timestamp)) + ? normalizeUnixTimestamp(Number(message.timestamp)) + : Date.now() + + updateChatStore((prev) => ({ + messages: [ + ...prev.messages, + { + id: messageId, + role: "assistant", + content, + timestamp, + }, + ], + isTyping: false, + })) + break + } + + case "message.update": { + const content = (payload.content as string) || "" + const messageId = payload.message_id as string + if (!messageId) { + break + } + + updateChatStore((prev) => ({ + messages: prev.messages.map((msg) => + msg.id === messageId ? { ...msg, content } : msg, + ), + })) + break + } + + case "typing.start": + updateChatStore({ isTyping: true }) + break + + case "typing.stop": + updateChatStore({ isTyping: false }) + break + + case "error": + console.error("Pico error:", payload) + updateChatStore({ isTyping: false }) + break + + case "pong": + break + + default: + console.log("Unknown pico message type:", message.type) + } +} diff --git a/web/frontend/src/lib/pico-chat-state.ts b/web/frontend/src/features/chat/state.ts similarity index 100% rename from web/frontend/src/lib/pico-chat-state.ts rename to web/frontend/src/features/chat/state.ts diff --git a/web/frontend/src/features/chat/websocket.ts b/web/frontend/src/features/chat/websocket.ts new file mode 100644 index 000000000..6b132e9a6 --- /dev/null +++ b/web/frontend/src/features/chat/websocket.ts @@ -0,0 +1,57 @@ +export function normalizeWsUrlForBrowser(wsUrl: string): string { + let finalWsUrl = wsUrl + + try { + const parsedUrl = new URL(wsUrl) + const isLocalHost = + parsedUrl.hostname === "localhost" || + parsedUrl.hostname === "127.0.0.1" || + parsedUrl.hostname === "0.0.0.0" + const isBrowserLocal = + window.location.hostname === "localhost" || + window.location.hostname === "127.0.0.1" + + if (isLocalHost && !isBrowserLocal) { + parsedUrl.hostname = window.location.hostname + finalWsUrl = parsedUrl.toString() + } + } catch (error) { + console.warn("Could not parse ws_url:", error) + } + + return finalWsUrl +} + +export function invalidateSocket(socket: WebSocket | null) { + if (!socket) { + return + } + + socket.onopen = null + socket.onmessage = null + socket.onclose = null + socket.onerror = null + socket.close() +} + +export function isCurrentSocket({ + socket, + currentSocket, + generation, + currentGeneration, + sessionId, + currentSessionId, +}: { + socket: WebSocket + currentSocket: WebSocket | null + generation: number + currentGeneration: number + sessionId: string + currentSessionId: string +}): boolean { + return ( + currentSocket === socket && + generation === currentGeneration && + sessionId === currentSessionId + ) +} diff --git a/web/frontend/src/hooks/use-gateway.ts b/web/frontend/src/hooks/use-gateway.ts index 848f4d59c..65ec2b776 100644 --- a/web/frontend/src/hooks/use-gateway.ts +++ b/web/frontend/src/hooks/use-gateway.ts @@ -67,10 +67,9 @@ export function useGateway() { } es.onerror = () => { - // EventSource will auto-reconnect - updateGatewayStore((prev) => - prev.status === "restarting" ? {} : { status: "unknown" }, - ) + // EventSource will auto-reconnect. Preserve the last known gateway + // status so transient SSE disconnects do not suppress chat websocket + // reconnects while polling catches up. } return () => { @@ -105,6 +104,11 @@ export function useGateway() { setLoading(true) try { await stopGateway() + updateGatewayStore({ + status: "stopped", + canStart: true, + restartRequired: false, + }) } catch (err) { console.error("Failed to stop gateway:", err) } finally { diff --git a/web/frontend/src/hooks/use-pico-chat.ts b/web/frontend/src/hooks/use-pico-chat.ts index 1b97a2a9c..3ac2e1613 100644 --- a/web/frontend/src/hooks/use-pico-chat.ts +++ b/web/frontend/src/hooks/use-pico-chat.ts @@ -5,7 +5,7 @@ import { newChatSession, sendChatMessage, switchChatSession, -} from "@/lib/pico-chat-controller" +} from "@/features/chat/controller" import { chatAtom } from "@/store/chat" const UNIX_MS_THRESHOLD = 1e12 @@ -33,7 +33,6 @@ function parseTimestamp(dateRaw: number | string | Date) { return dayjs(dateRaw) } -// Helper to format message timestamps export function formatMessageTime(dateRaw: number | string | Date): string { const date = parseTimestamp(dateRaw) if (!date.isValid()) { @@ -48,7 +47,6 @@ export function formatMessageTime(dateRaw: number | string | Date): string { return date.format("LT") } - // Cross-day formatting if (isThisYear) { return date.format("MMM D LT") } diff --git a/web/frontend/src/hooks/use-websocket.ts b/web/frontend/src/hooks/use-websocket.ts deleted file mode 100644 index c41b5ed34..000000000 --- a/web/frontend/src/hooks/use-websocket.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react" - -export function useWebSocket(path: string) { - const [message, setMessage] = useState("No messages yet") - const [connected, setConnected] = useState(false) - const wsRef = useRef(null) - - const connect = useCallback(() => { - if (wsRef.current) { - wsRef.current.close() - } - - const protocol = window.location.protocol === "https:" ? "wss:" : "ws:" - const url = `${protocol}//${window.location.host}${path}` - const socket = new WebSocket(url) - - socket.onopen = () => { - setConnected(true) - setMessage("Connected to WebSocket server.") - } - - socket.onmessage = (event) => { - setMessage(event.data) - } - - socket.onclose = () => { - setConnected(false) - setMessage("WebSocket connection closed.") - } - - socket.onerror = (error) => { - setConnected(false) - setMessage("WebSocket error occurred.") - console.error("WebSocket Error:", error) - } - - wsRef.current = socket - }, [path]) - - useEffect(() => { - return () => { - wsRef.current?.close() - } - }, []) - - return { message, connected, connect } -} diff --git a/web/frontend/src/routes/__root.tsx b/web/frontend/src/routes/__root.tsx index 6431d9490..31fdb7804 100644 --- a/web/frontend/src/routes/__root.tsx +++ b/web/frontend/src/routes/__root.tsx @@ -3,7 +3,7 @@ import { TanStackRouterDevtools } from "@tanstack/react-router-devtools" import { useEffect } from "react" import { AppLayout } from "@/components/app-layout" -import { initializeChatStore } from "@/lib/pico-chat-controller" +import { initializeChatStore } from "@/features/chat/controller" const RootLayout = () => { useEffect(() => { diff --git a/web/frontend/src/store/chat.ts b/web/frontend/src/store/chat.ts index d79a1a93b..da5fa6670 100644 --- a/web/frontend/src/store/chat.ts +++ b/web/frontend/src/store/chat.ts @@ -3,7 +3,7 @@ import { atom, getDefaultStore } from "jotai" import { getInitialActiveSessionId, writeStoredSessionId, -} from "@/lib/pico-chat-state" +} from "@/features/chat/state" export interface ChatMessage { id: string diff --git a/web/frontend/src/store/gateway.ts b/web/frontend/src/store/gateway.ts index b7655839c..c5eee8451 100644 --- a/web/frontend/src/store/gateway.ts +++ b/web/frontend/src/store/gateway.ts @@ -31,7 +31,17 @@ function normalizeGatewayStoreState( prev: GatewayStoreState, patch: GatewayStorePatch, ) { - return { ...prev, ...patch } + const next = { ...prev, ...patch } + + if ( + next.status === prev.status && + next.canStart === prev.canStart && + next.restartRequired === prev.restartRequired + ) { + return prev + } + + return next } export function updateGatewayStore( From 1eb6b37de0ae1466affce483a0e30281d92e0171 Mon Sep 17 00:00:00 2001 From: aishannon Date: Wed, 11 Mar 2026 15:26:54 +0800 Subject: [PATCH 25/28] feat(qq): support parsing and replying to more attachment types 1.Support parsing QQ Channel emoji structures. 2.Support handling incoming voice, image, video, and file messages from QQ Channel. 3.Support replying with local voice, image, video, and file attachments (upload before sending). 4.Prioritize Markdown when replying, with a fallback to plain text if it fails. --- pkg/channels/qq/message.go | 30 +++ pkg/channels/qq/qq.go | 480 +++++++++++++++++++++++++++---------- 2 files changed, 380 insertions(+), 130 deletions(-) create mode 100644 pkg/channels/qq/message.go diff --git a/pkg/channels/qq/message.go b/pkg/channels/qq/message.go new file mode 100644 index 000000000..b23db5fe2 --- /dev/null +++ b/pkg/channels/qq/message.go @@ -0,0 +1,30 @@ +package qq + +import "github.com/tencent-connect/botgo/dto" + +// RichMediaMessage rich media message. +// It is recommended to upload first, then send using message type 7. +type RichMediaMessage struct { + FileType uint64 `json:"file_type,omitempty"` // file type: 1-image, 2-video, 3-voice (currently voice only supports silk format) + URL string `json:"url,omitempty"` // rich media file to send, HTTP or HTTPS link + FileName string `json:"file_name,omitempty"` // file name for files sent via FileData + FileData []byte `json:"file_data,omitempty"` // file binary data for files sent via FileData +} + +// GetEventID event ID +func (msg RichMediaMessage) GetEventID() string { + return "" +} + +// GetSendType message type +func (msg RichMediaMessage) GetSendType() dto.SendType { + return dto.RichMedia +} + +// MessageAttachment attachment definition +type MessageAttachment struct { + URL string `json:"url,omitempty"` + FileName string `json:"filename,omitempty"` + ContentType string `json:"content_type,omitempty"` // voice: audio, image/xxx: image, video/xxx: video + AsrReferText string `json:"asr_refer_text,omitempty"` // ASR reference text +} diff --git a/pkg/channels/qq/qq.go b/pkg/channels/qq/qq.go index 4cb4db3c6..ebd21d213 100644 --- a/pkg/channels/qq/qq.go +++ b/pkg/channels/qq/qq.go @@ -2,7 +2,15 @@ package qq import ( "context" + "encoding/base64" + "encoding/json" "fmt" + "github.com/sipeed/picoclaw/pkg/media" + "github.com/sipeed/picoclaw/pkg/utils" + "github.com/tidwall/gjson" + "math/rand" + "os" + "path/filepath" "regexp" "strings" "sync" @@ -47,7 +55,7 @@ type QQChannel struct { lastMsgID sync.Map // chatID → string // msg_seq: per-chat atomic counter for multi-part replies. - msgSeqCounters sync.Map // chatID → *atomic.Uint64 + msgSeqCounters sync.Map // chatID → *atomic.Uint32 // Time-based dedup replacing the unbounded map. dedup map[string]time.Time @@ -101,7 +109,7 @@ func (c *QQChannel) Start(ctx context.Context) error { } // initialize OpenAPI client - c.api = botgo.NewOpenAPI(c.config.AppID, c.tokenSource).WithTimeout(5 * time.Second) + c.api = botgo.NewOpenAPI(c.config.AppID, c.tokenSource).WithTimeout(20 * time.Second) // register event handlers intent := event.RegisterHandlers( @@ -176,72 +184,72 @@ func (c *QQChannel) getChatKind(chatID string) string { return "group" } +// Send sends a message to the specified chatID. +// First attempt to send a Markdown message, fallback to plain text if failed. func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { return channels.ErrNotRunning } chatKind := c.getChatKind(msg.ChatID) + textMsg, mdMsg := c.genReplyMsg(ctx, msg, chatKind) - // Build message with content. - msgToCreate := &dto.MessageToCreate{ - Content: msg.Content, + for _, _v := range []dto.MessageToCreate{mdMsg, textMsg} { + var err error + var replyMsgID *dto.Message + if chatKind == "group" { + replyMsgID, err = c.api.PostGroupMessage(ctx, msg.ChatID, _v) + } else { + replyMsgID, err = c.api.PostC2CMessage(ctx, msg.ChatID, _v) + } + if err == nil { + logger.InfoCF("qq", "Sent message", map[string]any{"postrsp ": replyMsgID}) + return nil + } + if err != nil { + logger.ErrorCF("qq", "Failed to send message", map[string]any{ + "chat_id": msg.ChatID, + "chat_kind": chatKind, + "error": err.Error(), + }) + } + } + return nil +} + +func (c *QQChannel) genReplyMsg(ctx context.Context, msg bus.OutboundMessage, chatKind string) (dto.MessageToCreate, + dto.MessageToCreate) { + textMsg := dto.MessageToCreate{ + Content: sanitizeURLs(msg.Content), MsgType: dto.TextMsg, } - // Use Markdown message type if enabled in config. - if c.config.SendMarkdown { - msgToCreate.MsgType = dto.MarkdownMsg - msgToCreate.Markdown = &dto.Markdown{ + mdMsg := dto.MessageToCreate{ + MsgType: dto.MarkdownMsg, + Markdown: &dto.Markdown{ Content: msg.Content, - } - // Clear plain content to avoid sending duplicate text. - msgToCreate.Content = "" + }, } + return textMsg, mdMsg +} + +func (c *QQChannel) getReplyExtInfo(ctx context.Context, chatID string) (replyID string, seq uint32) { // Attach passive reply msg_id and msg_seq if available. - if v, ok := c.lastMsgID.Load(msg.ChatID); ok { + if v, ok := c.lastMsgID.Load(chatID); ok { if msgID, ok := v.(string); ok && msgID != "" { - msgToCreate.MsgID = msgID - - // Increment msg_seq atomically for multi-part replies. - if counterVal, ok := c.msgSeqCounters.Load(msg.ChatID); ok { - if counter, ok := counterVal.(*atomic.Uint64); ok { - seq := counter.Add(1) - msgToCreate.MsgSeq = uint32(seq) - } - } + replyID = msgID } } - - // Sanitize URLs in group messages to avoid QQ's URL blacklist rejection. - if chatKind == "group" { - if msgToCreate.Content != "" { - msgToCreate.Content = sanitizeURLs(msgToCreate.Content) + // Increment msg_seq atomically for multi-part replies. + if counterVal, ok := c.msgSeqCounters.Load(chatID); ok { + if counter, ok := counterVal.(*atomic.Uint32); ok { + seq = counter.Add(1) } - if msgToCreate.Markdown != nil && msgToCreate.Markdown.Content != "" { - msgToCreate.Markdown.Content = sanitizeURLs(msgToCreate.Markdown.Content) - } - } - - // Route to group or C2C. - var err error - if chatKind == "group" { - _, err = c.api.PostGroupMessage(ctx, msg.ChatID, msgToCreate) } else { - _, err = c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate) + seq = rand.Uint32() } - - if err != nil { - logger.ErrorCF("qq", "Failed to send message", map[string]any{ - "chat_id": msg.ChatID, - "chat_kind": chatKind, - "error": err.Error(), - }) - return fmt.Errorf("qq send: %w", channels.ErrTemporary) - } - - return nil + return replyID, seq } // StartTyping implements channels.TypingCapable. @@ -305,85 +313,120 @@ func (c *QQChannel) StartTyping(ctx context.Context, chatID string) (func(), err } // SendMedia implements the channels.MediaSender interface. -// QQ RichMediaMessage requires an HTTP/HTTPS URL — local file paths are not supported. -// If part.Ref is already an http(s) URL it is used directly; otherwise we try -// the media store, and skip with a warning if the resolved path is not an HTTP URL. func (c *QQChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { if !c.IsRunning() { return channels.ErrNotRunning } - - chatKind := c.getChatKind(msg.ChatID) - for _, part := range msg.Parts { - // If the ref is already an HTTP(S) URL, use it directly. - mediaURL := part.Ref - if !isHTTPURL(mediaURL) { - // Try resolving through media store. - store := c.GetMediaStore() - if store == nil { - logger.WarnCF("qq", "QQ media requires HTTP/HTTPS URL, no media store available", map[string]any{ - "ref": part.Ref, - }) - continue - } - - resolved, err := store.Resolve(part.Ref) - if err != nil { - logger.ErrorCF("qq", "Failed to resolve media ref", map[string]any{ - "ref": part.Ref, - "error": err.Error(), - }) - continue - } - - if !isHTTPURL(resolved) { - logger.WarnCF("qq", "QQ media requires HTTP/HTTPS URL, local files not supported", map[string]any{ - "ref": part.Ref, - "resolved": resolved, - }) - continue - } - - mediaURL = resolved - } - - // Map part type to QQ file type: 1=image, 2=video, 3=audio, 4=file. - var fileType uint64 - switch part.Type { - case "image": - fileType = 1 - case "video": - fileType = 2 - case "audio": - fileType = 3 - default: - fileType = 4 // file - } - - richMedia := &dto.RichMediaMessage{ - FileType: fileType, - URL: mediaURL, - SrvSendMsg: true, - } - - var sendErr error - if chatKind == "group" { - _, sendErr = c.api.PostGroupMessage(ctx, msg.ChatID, richMedia) - } else { - _, sendErr = c.api.PostC2CMessage(ctx, msg.ChatID, richMedia) - } - - if sendErr != nil { + if err := c.sendOneMedia(ctx, msg.ChatID, part); err != nil { logger.ErrorCF("qq", "Failed to send media", map[string]any{ - "type": part.Type, - "chat_id": msg.ChatID, - "error": sendErr.Error(), + "part": part, + "error": err.Error(), }) - return fmt.Errorf("qq send media: %w", channels.ErrTemporary) + continue } } + return nil +} +// Upload file and then send it via API +// QQ groups do not support file sending +// When sending local files via QQ, the file size cannot exceed 10M +func (c *QQChannel) sendOneMedia(ctx context.Context, chatID string, part bus.MediaPart) error { + chatKind := c.getChatKind(chatID) + + mediaPath := part.Ref + var meta media.MediaMeta + if !isHTTPURL(mediaPath) { + store := c.GetMediaStore() + if store == nil { + logger.WarnCF("qq", "QQ media requires HTTP/HTTPS URL, no media store available", map[string]any{ + "ref": part.Ref, + }) + return fmt.Errorf("store not available") + } + var resolved string + var err error + resolved, meta, err = store.ResolveWithMeta(part.Ref) + if err != nil { + logger.ErrorCF("qq", "Failed to resolve media ref", map[string]any{ + "ref": part.Ref, + "error": err.Error(), + }) + return fmt.Errorf("store resolve failed") + } + mediaPath = resolved + } + + // Map part type to QQ file type: 1=image, 2=video, 3=audio, 4=file. + var fileType uint64 + switch part.Type { + case "image": + fileType = 1 + case "video": + fileType = 2 + case "audio": + fileType = 3 + default: + fileType = 4 // file + } + + richMedia := &RichMediaMessage{FileType: fileType} + if isHTTPURL(mediaPath) { + richMedia.URL = mediaPath + } else { + fdata, err := os.ReadFile(mediaPath) + if err != nil { + logger.ErrorCF("qq", "Failed to read media file[%v]", map[string]any{"path": mediaPath, + "error": err.Error()}) + return fmt.Errorf("read file failed") + } + richMedia.FileData = fdata + richMedia.FileName = meta.Filename + } + + if (chatKind == "group" && fileType == 4) || len(richMedia.FileData) > 10*1024*1024 { + logger.WarnCF("qq", "File size exceeds 10M, skipping send", map[string]any{ + "filename": richMedia.FileName, "size": len(richMedia.FileData)}) + return nil + } + + var sendErr error + var result *dto.Message + if chatKind == "group" { + result, sendErr = c.api.PostGroupMessage(ctx, chatID, richMedia) + } else { + result, sendErr = c.api.PostC2CMessage(ctx, chatID, richMedia) + } + + if sendErr != nil { + logger.ErrorCF("qq", "Failed to send media", map[string]any{ + "type": part.Type, + "chat_id": chatID, + "error": sendErr.Error(), + }) + return fmt.Errorf("qq send media: %w err:%v", channels.ErrTemporary, sendErr) + } + + msg := dto.MessageToCreate{ + MsgType: dto.RichMediaMsg, + Media: &dto.MediaInfo{FileInfo: result.FileInfo}, + } + msg.MsgID, msg.MsgSeq = c.getReplyExtInfo(ctx, chatID) + + if chatKind == "group" { + result, sendErr = c.api.PostGroupMessage(ctx, chatID, msg) + } else { + result, sendErr = c.api.PostC2CMessage(ctx, chatID, msg) + } + if sendErr != nil { + logger.ErrorCF("qq", "Failed to send media", map[string]any{ + "type": part.Type, + "chat_id": chatID, + "error": sendErr.Error(), + }) + return fmt.Errorf("qq send media: %w err:%v", channels.ErrTemporary, sendErr) + } return nil } @@ -404,10 +447,11 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { return nil } - // extract message content - content := data.Content + scope := channels.BuildMediaScope("qq", senderID, data.ID) + + content, mediaPaths := c.decodeMesasge(context.Background(), event, (*dto.Message)(data), scope) if content == "" { - logger.DebugC("qq", "Received empty message, ignoring") + logger.DebugC("qq", "Received empty C2C message, ignoring") return nil } @@ -421,7 +465,7 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { c.lastMsgID.Store(senderID, data.ID) // Reset msg_seq counter for new inbound message. - c.msgSeqCounters.Store(senderID, new(atomic.Uint64)) + c.msgSeqCounters.Store(senderID, new(atomic.Uint32)) metadata := map[string]string{ "account_id": senderID, @@ -443,7 +487,7 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { senderID, senderID, content, - []string{}, + mediaPaths, metadata, sender, ) @@ -468,14 +512,13 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { logger.WarnC("qq", "Received group message with no sender ID") return nil } + scope := channels.BuildMediaScope("qq", data.GroupID, data.ID) - // extract message content (remove @ bot part) - content := data.Content + content, mediaPaths := c.decodeMesasge(context.Background(), event, (*dto.Message)(data), scope) if content == "" { logger.DebugC("qq", "Received empty group message, ignoring") return nil } - // GroupAT event means bot is always mentioned; apply group trigger filtering respond, cleaned := c.ShouldRespondInGroup(true, content) if !respond { @@ -492,10 +535,6 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { // Store chat routing context using GroupID as chatID. c.chatType.Store(data.GroupID, "group") c.lastMsgID.Store(data.GroupID, data.ID) - - // Reset msg_seq counter for new inbound message. - c.msgSeqCounters.Store(data.GroupID, new(atomic.Uint64)) - metadata := map[string]string{ "account_id": senderID, "group_id": data.GroupID, @@ -517,7 +556,7 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { senderID, data.GroupID, content, - []string{}, + mediaPaths, metadata, sender, ) @@ -582,6 +621,144 @@ func (c *QQChannel) dedupJanitor() { } } +func (c *QQChannel) decodeMesasge(ctx context.Context, event *dto.WSPayload, data *dto.Message, scope string) (content string, mediaPaths []string) { + + content = parseEmojiText(data.Content) + wavURL, asrReferText := getVoiceInfo(event) + if data.Attachments != nil && len(data.Attachments) > 0 { + var attachments []MessageAttachment + for _, att := range data.Attachments { + if att.ContentType == "voice" && wavURL != "" { + attachments = append(attachments, MessageAttachment{ + ContentType: "voice", + URL: wavURL, + FileName: filepath.Base(wavURL), + AsrReferText: asrReferText, + }) + continue + } else { + attachments = append(attachments, MessageAttachment{ + ContentType: att.ContentType, + URL: att.URL, + FileName: att.FileName, + }) + } + } + processedPaths, attachmentContent := c.processAttachments(ctx, attachments, scope) + if asrReferText != "" { + attachmentContent = fmt.Sprintf("[audio: %v]", asrReferText) + } + mediaPaths = processedPaths + if content != "" { + content += "\n" + } + content += attachmentContent + } + return content, mediaPaths +} + +// processAttachments processes all attachments in a message +func (c *QQChannel) processAttachments(ctx context.Context, attachments []MessageAttachment, scope string) ([]string, string) { + mediaPaths := []string{} + content := "" + + // Helper to register a local file with the media store + storeMedia := func(localPath, filename string) string { + store := c.GetMediaStore() + if store == nil { + logger.ErrorCF("qq", "media store is nil", map[string]any{ + "scope": scope, + }) + return "" + } + ref, err := store.Store(localPath, media.MediaMeta{Filename: filename, Source: "qq"}, scope) + if err != nil { + logger.InfoCF("qq", "Stored media", map[string]any{ + "scope": scope, + "localPath": localPath, + "filename": filename, + }) + return ref + } + logger.ErrorCF("qq", "Stored media", map[string]any{ + "scope": scope, + "localPath": localPath, + "ref": ref, + }) + return localPath + } + + for _, attachment := range attachments { + attachmentType := c.getAttachmentType(attachment) + localPath := c.downloadAttachment(ctx, attachment) + if localPath == "" { + mediaPaths = append(mediaPaths, attachment.URL) + content += appendContent(content, fmt.Sprintf("[%v: %s]", attachment.ContentType, attachment.URL)) + continue + } + ref := storeMedia(localPath, attachment.FileName) + mediaPaths = append(mediaPaths, ref) + if attachmentType == "audio" && attachment.AsrReferText != "" { + content += appendContent(content, fmt.Sprintf("[audio: %s]", attachment.AsrReferText)) + continue + } + content += appendContent(content, fmt.Sprintf("[%v: %s]", attachment.ContentType, ref)) + } + + return mediaPaths, content +} + +// downloadAttachment downloads an attachment from QQ server +func (c *QQChannel) downloadAttachment(ctx context.Context, attachment MessageAttachment) string { + logger.InfoCF("qq", "Downloading attachment", map[string]any{ + "attachment": attachment, + }) + return utils.DownloadFile(attachment.URL, attachment.FileName, utils.DownloadOptions{ + LoggerPrefix: "qq", + }) +} + +// getAttachmentType determines the type of attachment (image, audio, video, file) +func (c *QQChannel) getAttachmentType(attachment MessageAttachment) string { + + if strings.HasPrefix(attachment.ContentType, "image") { + return "image" + } else if strings.HasPrefix(attachment.ContentType, "video") { + return "video" + } else if strings.HasPrefix(attachment.ContentType, "voice") { + return "audio" + } + return "file" +} + +// appendContent safely appends content to existing text +func appendContent(content, suffix string) string { + if content == "" { + return suffix + } + return content + "\n" + suffix +} + +func getVoiceInfo(event *dto.WSPayload) (string, string) { + _raw, err := json.Marshal(event.Data) + if err != nil { + logger.ErrorCF("qq", "Failed to marshal event data", map[string]any{ + "error": err.Error(), + }) + return "", "" + } + // 使用gjson提取voice_wav_url字段 + rawJSON := string(_raw) + + // 首先尝试从attachments数组的第一个元素中提取voice_wav_url + voiceWavURL := gjson.Get(rawJSON, "attachments.0.voice_wav_url").String() + asrReferText := gjson.Get(rawJSON, "attachments.0.asr_refer_text").String() + logger.DebugCF("qq", "Found voice_wav_url in attachments", map[string]any{ + "url": voiceWavURL, "asr_refer_text": asrReferText, + }) + return voiceWavURL, asrReferText +} + // isHTTPURL returns true if s starts with http:// or https://. func isHTTPURL(s string) bool { return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") @@ -625,3 +802,46 @@ func sanitizeURLs(text string) string { return scheme + domain + path }) } + +// parseEmojiText decodes emoji text +func parseEmojiText(content string) string { + content = strings.ReplaceAll(content, `\\`, `\`) + content = strings.ReplaceAll(content, "\\u003c", "<") + content = strings.ReplaceAll(content, "\\u003e", ">") + content = strings.ReplaceAll(content, `\"`, `"`) + + combinedRegexp := regexp.MustCompile(`<[^<]*?ext="([^"]+)"[^<]*?faceType=(\d+)[^<]*?>|<[^<]*?faceType=(\d+)[^<]*?ext="([^"]+)"[^<]*?>`) + + contentParts := combinedRegexp.Split(content, -1) + matches := combinedRegexp.FindAllString(content, -1) + + var result strings.Builder + for i, part := range contentParts { + if strings.TrimSpace(part) != "" { + result.WriteString(part) + } + if i < len(matches) { + match := matches[i] + if strings.Contains(match, "faceType=") { + result.WriteString(processEmoji(match)) + } + } + } + + return result.String() +} + +func processEmoji(match string) string { + extRegexp := regexp.MustCompile(`ext="([^"]+)"`) + extMatch := extRegexp.FindStringSubmatch(match) + + if len(extMatch) > 1 { + ext, err := base64.StdEncoding.DecodeString(extMatch[1]) + if err == nil { + var faceDesc map[string]string + json.Unmarshal(ext, &faceDesc) + return fmt.Sprintf("[表情 %v]", faceDesc["text"]) + } + } + return "" +} From e0b12c99b2a658a9bc35d100163aa46567840369 Mon Sep 17 00:00:00 2001 From: aishannon Date: Thu, 12 Mar 2026 17:30:14 +0800 Subject: [PATCH 26/28] fix cr --- pkg/channels/qq/qq.go | 79 ++++++++++++++++++++----------------------- 1 file changed, 37 insertions(+), 42 deletions(-) diff --git a/pkg/channels/qq/qq.go b/pkg/channels/qq/qq.go index ebd21d213..05e2c4f3a 100644 --- a/pkg/channels/qq/qq.go +++ b/pkg/channels/qq/qq.go @@ -5,10 +5,7 @@ import ( "encoding/base64" "encoding/json" "fmt" - "github.com/sipeed/picoclaw/pkg/media" - "github.com/sipeed/picoclaw/pkg/utils" - "github.com/tidwall/gjson" - "math/rand" + "math" "os" "path/filepath" "regexp" @@ -17,6 +14,10 @@ import ( "sync/atomic" "time" + "github.com/sipeed/picoclaw/pkg/media" + "github.com/sipeed/picoclaw/pkg/utils" + "github.com/tidwall/gjson" + "github.com/tencent-connect/botgo" "github.com/tencent-connect/botgo/dto" "github.com/tencent-connect/botgo/event" @@ -36,9 +37,11 @@ const ( dedupInterval = 60 * time.Second dedupMaxSize = 10000 // hard cap on dedup map entries typingResend = 8 * time.Second - typingSeconds = 10 + typingSeconds = 20 ) +var emojiRegexp = regexp.MustCompile(`<[^<]*?ext="([^"]+)"[^<]*?faceType=(\d+)[^<]*?>|<[^<]*?faceType=(\d+)[^<]*?ext="([^"]+)"[^<]*?>`) + type QQChannel struct { *channels.BaseChannel config config.QQConfig @@ -54,8 +57,8 @@ type QQChannel struct { // Passive reply: store last inbound message ID per chat. lastMsgID sync.Map // chatID → string - // msg_seq: per-chat atomic counter for multi-part replies. - msgSeqCounters sync.Map // chatID → *atomic.Uint32 + replySeq atomic.Uint32 + seqLock sync.Mutex // Time-based dedup replacing the unbounded map. dedup map[string]time.Time @@ -86,7 +89,6 @@ func (c *QQChannel) Start(ctx context.Context) error { return fmt.Errorf("QQ app_id and app_secret not configured") } - botgo.SetLogger(logger.NewLogger("botgo")) logger.InfoC("qq", "Starting QQ bot (WebSocket mode)") // Reinitialize shutdown signal for clean restart. @@ -193,14 +195,13 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { chatKind := c.getChatKind(msg.ChatID) textMsg, mdMsg := c.genReplyMsg(ctx, msg, chatKind) - - for _, _v := range []dto.MessageToCreate{mdMsg, textMsg} { - var err error + var err error + for _, replyMsg := range []dto.MessageToCreate{mdMsg, textMsg} { var replyMsgID *dto.Message if chatKind == "group" { - replyMsgID, err = c.api.PostGroupMessage(ctx, msg.ChatID, _v) + replyMsgID, err = c.api.PostGroupMessage(ctx, msg.ChatID, replyMsg) } else { - replyMsgID, err = c.api.PostC2CMessage(ctx, msg.ChatID, _v) + replyMsgID, err = c.api.PostC2CMessage(ctx, msg.ChatID, replyMsg) } if err == nil { logger.InfoCF("qq", "Sent message", map[string]any{"postrsp ": replyMsgID}) @@ -214,7 +215,7 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { }) } } - return nil + return err } func (c *QQChannel) genReplyMsg(ctx context.Context, msg bus.OutboundMessage, chatKind string) (dto.MessageToCreate, @@ -241,14 +242,15 @@ func (c *QQChannel) getReplyExtInfo(ctx context.Context, chatID string) (replyID replyID = msgID } } - // Increment msg_seq atomically for multi-part replies. - if counterVal, ok := c.msgSeqCounters.Load(chatID); ok { - if counter, ok := counterVal.(*atomic.Uint32); ok { - seq = counter.Add(1) - } - } else { - seq = rand.Uint32() + + // Attach msg_seq for active reply. + c.seqLock.Lock() + defer c.seqLock.Unlock() + seq = c.replySeq.Add(1) + if seq > math.MaxInt32 { + c.replySeq.Store(1) } + return replyID, seq } @@ -449,7 +451,7 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { scope := channels.BuildMediaScope("qq", senderID, data.ID) - content, mediaPaths := c.decodeMesasge(context.Background(), event, (*dto.Message)(data), scope) + content, mediaPaths := c.decodeMessage(context.Background(), event, (*dto.Message)(data), scope) if content == "" { logger.DebugC("qq", "Received empty C2C message, ignoring") return nil @@ -464,12 +466,7 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { c.chatType.Store(senderID, "direct") c.lastMsgID.Store(senderID, data.ID) - // Reset msg_seq counter for new inbound message. - c.msgSeqCounters.Store(senderID, new(atomic.Uint32)) - - metadata := map[string]string{ - "account_id": senderID, - } + metadata := map[string]string{} sender := bus.SenderInfo{ Platform: "qq", @@ -514,7 +511,7 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { } scope := channels.BuildMediaScope("qq", data.GroupID, data.ID) - content, mediaPaths := c.decodeMesasge(context.Background(), event, (*dto.Message)(data), scope) + content, mediaPaths := c.decodeMessage(context.Background(), event, (*dto.Message)(data), scope) if content == "" { logger.DebugC("qq", "Received empty group message, ignoring") return nil @@ -535,9 +532,9 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { // Store chat routing context using GroupID as chatID. c.chatType.Store(data.GroupID, "group") c.lastMsgID.Store(data.GroupID, data.ID) + metadata := map[string]string{ - "account_id": senderID, - "group_id": data.GroupID, + "group_id": data.GroupID, } sender := bus.SenderInfo{ @@ -621,7 +618,8 @@ func (c *QQChannel) dedupJanitor() { } } -func (c *QQChannel) decodeMesasge(ctx context.Context, event *dto.WSPayload, data *dto.Message, scope string) (content string, mediaPaths []string) { +func (c *QQChannel) decodeMessage(ctx context.Context, event *dto.WSPayload, data *dto.Message, + scope string) (content string, mediaPaths []string) { content = parseEmojiText(data.Content) wavURL, asrReferText := getVoiceInfo(event) @@ -658,9 +656,8 @@ func (c *QQChannel) decodeMesasge(ctx context.Context, event *dto.WSPayload, dat } // processAttachments processes all attachments in a message -func (c *QQChannel) processAttachments(ctx context.Context, attachments []MessageAttachment, scope string) ([]string, string) { - mediaPaths := []string{} - content := "" +func (c *QQChannel) processAttachments(ctx context.Context, attachments []MessageAttachment, + scope string) (mediaPaths []string, content string) { // Helper to register a local file with the media store storeMedia := func(localPath, filename string) string { @@ -672,7 +669,7 @@ func (c *QQChannel) processAttachments(ctx context.Context, attachments []Messag return "" } ref, err := store.Store(localPath, media.MediaMeta{Filename: filename, Source: "qq"}, scope) - if err != nil { + if err == nil { logger.InfoCF("qq", "Stored media", map[string]any{ "scope": scope, "localPath": localPath, @@ -680,10 +677,10 @@ func (c *QQChannel) processAttachments(ctx context.Context, attachments []Messag }) return ref } - logger.ErrorCF("qq", "Stored media", map[string]any{ + logger.ErrorCF("qq", "Stored media err ", map[string]any{ "scope": scope, "localPath": localPath, - "ref": ref, + "err": err.Error(), }) return localPath } @@ -810,10 +807,8 @@ func parseEmojiText(content string) string { content = strings.ReplaceAll(content, "\\u003e", ">") content = strings.ReplaceAll(content, `\"`, `"`) - combinedRegexp := regexp.MustCompile(`<[^<]*?ext="([^"]+)"[^<]*?faceType=(\d+)[^<]*?>|<[^<]*?faceType=(\d+)[^<]*?ext="([^"]+)"[^<]*?>`) - - contentParts := combinedRegexp.Split(content, -1) - matches := combinedRegexp.FindAllString(content, -1) + contentParts := emojiRegexp.Split(content, -1) + matches := emojiRegexp.FindAllString(content, -1) var result strings.Builder for i, part := range contentParts { From 12e918c09b096b3371919efeb6ec83b5fd1019cd Mon Sep 17 00:00:00 2001 From: aishannon Date: Wed, 11 Mar 2026 15:26:54 +0800 Subject: [PATCH 27/28] feat(qq): support parsing and replying to more attachment types 1.Support parsing QQ Channel emoji structures. 2.Support handling incoming voice, image, video, and file messages from QQ Channel. 3.Support replying with local voice, image, video, and file attachments (upload before sending). 4.Prioritize Markdown when replying, with a fallback to plain text if it fails. --- pkg/channels/qq/qq.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pkg/channels/qq/qq.go b/pkg/channels/qq/qq.go index 05e2c4f3a..532d05178 100644 --- a/pkg/channels/qq/qq.go +++ b/pkg/channels/qq/qq.go @@ -466,7 +466,9 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { c.chatType.Store(senderID, "direct") c.lastMsgID.Store(senderID, data.ID) - metadata := map[string]string{} + metadata := map[string]string{ + "account_id": senderID, + } sender := bus.SenderInfo{ Platform: "qq", @@ -534,7 +536,8 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { c.lastMsgID.Store(data.GroupID, data.ID) metadata := map[string]string{ - "group_id": data.GroupID, + "account_id": senderID, + "group_id": data.GroupID, } sender := bus.SenderInfo{ From 4f0366d66356e705ef5aed61584bab9a11ed373c Mon Sep 17 00:00:00 2001 From: aishannon Date: Sun, 15 Mar 2026 19:15:13 +0800 Subject: [PATCH 28/28] 1. fix seq reset. 2. remove SendMarkdown config --- pkg/channels/qq/qq.go | 8 +++++--- pkg/config/config.go | 1 - 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/pkg/channels/qq/qq.go b/pkg/channels/qq/qq.go index 532d05178..79dc81030 100644 --- a/pkg/channels/qq/qq.go +++ b/pkg/channels/qq/qq.go @@ -37,7 +37,7 @@ const ( dedupInterval = 60 * time.Second dedupMaxSize = 10000 // hard cap on dedup map entries typingResend = 8 * time.Second - typingSeconds = 20 + typingSeconds = 10 ) var emojiRegexp = regexp.MustCompile(`<[^<]*?ext="([^"]+)"[^<]*?faceType=(\d+)[^<]*?>|<[^<]*?faceType=(\d+)[^<]*?ext="([^"]+)"[^<]*?>`) @@ -57,6 +57,8 @@ type QQChannel struct { // Passive reply: store last inbound message ID per chat. lastMsgID sync.Map // chatID → string + // 消息回复时API时的SEQ,用于回复时去重 + // 被动回复: 同一个chatID+replyMsgID+seq 去重。 主动回复:不限制。 replySeq atomic.Uint32 seqLock sync.Mutex @@ -248,9 +250,9 @@ func (c *QQChannel) getReplyExtInfo(ctx context.Context, chatID string) (replyID defer c.seqLock.Unlock() seq = c.replySeq.Add(1) if seq > math.MaxInt32 { - c.replySeq.Store(1) + c.replySeq.Store(0) + seq = c.replySeq.Add(1) } - return replyID, seq } diff --git a/pkg/config/config.go b/pkg/config/config.go index 2937c36e4..41393fbc7 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -353,7 +353,6 @@ type QQConfig struct { AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` MaxMessageLength int `json:"max_message_length" env:"PICOCLAW_CHANNELS_QQ_MAX_MESSAGE_LENGTH"` - SendMarkdown bool `json:"send_markdown" env:"PICOCLAW_CHANNELS_QQ_SEND_MARKDOWN"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID"` }