Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions internal/app/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"

Expand All @@ -20,6 +21,16 @@ var validClaudeModels = map[string]bool{
"opusplan": true,

// Current models
"claude-opus-5": true, "claude-opus-5[1m]": true,
"claude-opus-5-5": true, "claude-opus-5-5[1m]": true,
"claude-sonnet-5": true, "claude-sonnet-5[1m]": true,
"claude-fable-5": true, "claude-fable-5[1m]": true,
"claude-fable-5-1": true, "claude-fable-5-1[1m]": true,
"claude-mythos-5": true, "claude-mythos-5[1m]": true,
"claude-opus-4-8": true, "claude-sonnet-4-8": true,
"claude-opus-4-8[1m]": true, "claude-sonnet-4-8[1m]": true,
"claude-opus-4-7": true, "claude-sonnet-4-7": true,
"claude-opus-4-7[1m]": true, "claude-sonnet-4-7[1m]": true,
"claude-opus-4-6": true, "claude-sonnet-4-6": true,
"claude-haiku-4-5-20251001": true, "claude-haiku-4-5": true,
"claude-opus-4-6[1m]": true, "claude-sonnet-4-6[1m]": true,
Expand All @@ -39,11 +50,14 @@ func ValidateClaudeModel(model string) error {
return nil
}
if !validClaudeModels[model] {
var valid []string
// List the full allowlist: a stale hardcoded example set sent an agent
// chasing the wrong fix when Opus 5 shipped (2026-07-25).
valid := make([]string, 0, len(validClaudeModels))
for k := range validClaudeModels {
valid = append(valid, k)
}
return fmt.Errorf("invalid claude model %q; valid models: sonnet, opus, haiku, claude-opus-4-6, claude-sonnet-4-6, etc.", model)
sort.Strings(valid)
return fmt.Errorf("invalid claude model %q; valid models: %s", model, strings.Join(valid, ", "))
}
return nil
}
Expand Down
9 changes: 6 additions & 3 deletions internal/claudetui/headless.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,15 @@ import (

type HeadlessRuntime struct {
WorkspaceDir string
Model string
}

func NewHeadlessRuntime(workspaceDir string) *HeadlessRuntime {
return &HeadlessRuntime{WorkspaceDir: workspaceDir}
func NewHeadlessRuntime(workspaceDir, model string) *HeadlessRuntime {
return &HeadlessRuntime{WorkspaceDir: workspaceDir, Model: model}
}

func (h *HeadlessRuntime) Descriptor() agent.RuntimeDescriptor {
return NewSessionRuntime(h.WorkspaceDir, "").Descriptor()
return NewSessionRuntime(h.WorkspaceDir, "", h.Model).Descriptor()
}

func (h *HeadlessRuntime) RunSync(ctx context.Context, store *db.Store, req agent.HeadlessRequest) (agent.HeadlessResult, error) {
Expand All @@ -35,6 +36,7 @@ func (h *HeadlessRuntime) RunSync(ctx context.Context, store *db.Store, req agen
NotifyMainSession: req.NotifyMainSession,
LogCaller: req.LogCaller,
SessionName: sessionname.ClaudeTUI(workspaceDir),
Model: h.Model,
Runtime: db.ExecutionRuntime{
Provider: "claude_tui",
Mode: "headless_exec",
Expand Down Expand Up @@ -68,6 +70,7 @@ func (h *HeadlessRuntime) RunBackground(store *db.Store, req agent.HeadlessReque
NotifyMainSession: req.NotifyMainSession,
LogCaller: req.LogCaller,
SessionName: sessionname.ClaudeTUI(workspaceDir),
Model: h.Model,
Runtime: db.ExecutionRuntime{
Provider: "claude_tui",
Mode: "headless_exec",
Expand Down
7 changes: 6 additions & 1 deletion internal/claudetui/tmux_bridge.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@ type TmuxBridge struct {
WorkspaceDir string
LogDir string
SessionName string
Model string
}

func NewSessionRuntime(workspaceDir, logDir string) *TmuxBridge {
func NewSessionRuntime(workspaceDir, logDir, model string) *TmuxBridge {
return &TmuxBridge{
WorkspaceDir: workspaceDir,
LogDir: logDir,
SessionName: sessionname.ClaudeTUI(workspaceDir),
Model: model,
}
}

Expand Down Expand Up @@ -188,6 +190,9 @@ func (b *TmuxBridge) writeSessionID(id string) error {

func (b *TmuxBridge) startSession(ctx context.Context, session, sessionID string, resume bool) error {
args := []string{"claude", "--dangerously-skip-permissions"}
if b.Model != "" {
args = append(args, "--model", b.Model)
}
if resume {
args = append(args, "--resume", sessionID)
} else {
Expand Down
2 changes: 1 addition & 1 deletion internal/gateway/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -733,6 +733,6 @@ func failedAttachmentReply(failed []AttachmentResult) string {
lines = append(lines, fmt.Sprintf("- and %d more", len(failed)-limit))
}

lines = append(lines, "Supported uploads include images, PDF, CSV/TSV, DOCX, and XLSX within the configured size limits.")
lines = append(lines, "Supported uploads include images, PDF, CSV/TSV, DOCX, XLSX, and text files (TXT, MD, JSON, YAML, LOG) within the configured size limits.")
return strings.Join(lines, "\n")
}
4 changes: 2 additions & 2 deletions internal/gateway/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,7 @@ func TestHandleMessage_FailedAttachmentOnlyRepliesToUser(t *testing.T) {
if len(resp.messages) != 1 {
t.Fatalf("expected 1 user-facing message, got %d", len(resp.messages))
}
want := "I couldn't process that attachment:\n- report.exe: unsupported file type\nSupported uploads include images, PDF, CSV/TSV, DOCX, and XLSX within the configured size limits."
want := "I couldn't process that attachment:\n- report.exe: unsupported file type\nSupported uploads include images, PDF, CSV/TSV, DOCX, XLSX, and text files (TXT, MD, JSON, YAML, LOG) within the configured size limits."
if resp.messages[0] != want {
t.Fatalf("reply = %q, want %q", resp.messages[0], want)
}
Expand All @@ -371,7 +371,7 @@ func TestFailedAttachmentReply_LimitsListedFailures(t *testing.T) {
{Index: 3, Filename: "four.pdf", Reason: "failed four"},
})

want := "I couldn't process those attachments:\n- one.pdf: failed one\n- two.pdf: failed two\n- three.pdf: failed three\n- and 1 more\nSupported uploads include images, PDF, CSV/TSV, DOCX, and XLSX within the configured size limits."
want := "I couldn't process those attachments:\n- one.pdf: failed one\n- two.pdf: failed two\n- three.pdf: failed three\n- and 1 more\nSupported uploads include images, PDF, CSV/TSV, DOCX, XLSX, and text files (TXT, MD, JSON, YAML, LOG) within the configured size limits."
if got != want {
t.Fatalf("failedAttachmentReply() = %q, want %q", got, want)
}
Expand Down
4 changes: 2 additions & 2 deletions internal/runtime/factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ func New(cfg app.Config) (agent.Runtime, error) {
descriptor: session.Descriptor(),
}, nil
case agent.RuntimeClaudeTUI:
session := claudetui.NewSessionRuntime(cfg.WorkspaceDir, cfg.LogDir)
headless := claudetui.NewHeadlessRuntime(cfg.WorkspaceDir)
session := claudetui.NewSessionRuntime(cfg.WorkspaceDir, cfg.LogDir, cfg.Model)
headless := claudetui.NewHeadlessRuntime(cfg.WorkspaceDir, cfg.Model)
return &runtimeImpl{
session: session,
headless: headless,
Expand Down
69 changes: 69 additions & 0 deletions internal/telegram/attachments.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ var allowedAttachmentExts = map[string]struct{}{
".xlsx": {},
".docx": {},
".pdf": {},
".txt": {},
".md": {},
".json": {},
".yaml": {},
".yml": {},
".log": {},
}

type AttachmentConfig struct {
Expand Down Expand Up @@ -429,12 +435,41 @@ func (c *Connector) cleanupExpiredAttachments() {
)
}

// textLikeMIMEs is the explicit set of declared types we accept as text
// uploads. It mirrors the text formats named in the user-facing supported
// uploads message (TXT, MD, JSON, YAML, LOG, CSV/TSV) plus the aliases
// Telegram clients and browsers realistically send for them. A blanket
// "text/" prefix would also pull in text/html, text/x-python and friends,
// which we do not promise to handle.
var textLikeMIMEs = map[string]struct{}{
"text/plain": {},
"text/markdown": {},
"text/x-markdown": {},
"application/json": {},
"text/json": {},
"application/yaml": {},
"application/x-yaml": {},
"text/yaml": {},
"text/x-yaml": {},
"text/csv": {},
"application/csv": {},
"text/tab-separated-values": {},
}

func isTextLikeMIME(mime string) bool {
_, ok := textLikeMIMEs[mime]
return ok
}

func isAllowedByMetadata(filename, mime string) bool {
mime = strings.ToLower(strings.TrimSpace(mime))
ext := normalizedAttachmentExt(filename)
if strings.HasPrefix(mime, "image/") {
return true
}
if isTextLikeMIME(mime) {
return true
}
if _, ok := allowedAttachmentMIMEs[mime]; ok {
return true
}
Expand All @@ -461,12 +496,38 @@ func isAllowedByContent(filename, mime, detected string) bool {
if ext == ".csv" || ext == ".tsv" || mime == "text/csv" || mime == "text/tab-separated-values" || mime == "application/csv" {
return strings.HasPrefix(detected, "text/plain") || detected == "application/octet-stream"
}
if isTextLikeMIME(mime) || isTextLikeExt(ext) {
// Genuine text always sniffs as some text/ type. Anything else here
// is a binary payload wearing a text extension: executables and other
// unrecognised bytes come back as application/octet-stream, archives
// as application/zip, and so on.
return strings.HasPrefix(detected, "text/")
}
if _, ok := allowedAttachmentMIMEs[mime]; ok {
return detected == mime || detected == "application/octet-stream"
}
return false
}

// textLikeExts backs the fallback for uploads Telegram sends without a usable
// MIME type. Every entry must also appear in allowedAttachmentExts, otherwise
// isAllowedByMetadata rejects the file before content sniffing ever runs.
var textLikeExts = map[string]struct{}{
".txt": {},
".md": {},
".json": {},
".yaml": {},
".yml": {},
".log": {},
".csv": {},
".tsv": {},
}

func isTextLikeExt(ext string) bool {
_, ok := textLikeExts[ext]
return ok
}

func normalizedAttachmentExt(name string) string {
ext := strings.ToLower(strings.TrimSpace(filepath.Ext(name)))
if ext == ".tiff" {
Expand Down Expand Up @@ -496,6 +557,14 @@ func extFromMIME(mime string) string {
return ".xlsx"
case "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
return ".docx"
case "text/plain":
return ".txt"
case "text/markdown":
return ".md"
case "application/json":
return ".json"
case "application/yaml", "application/x-yaml", "text/yaml":
return ".yaml"
default:
return ""
}
Expand Down
Loading