From 38c1adfcc24f35550f7de1acc4f6b50ef9660ed9 Mon Sep 17 00:00:00 2001 From: Jihad Irfansyah Date: Sun, 2 Aug 2026 23:30:49 +0700 Subject: [PATCH 01/11] Harden edit_file matching and complete VPS SSH tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit edit_file now recovers from the common read_file→edit failures (CRLF vs LF, pasted NUMBER| prefixes) and diagnoses tab/space mismatches; system prompt documents the NUMBER|CONTENT contract. vps_run no longer false-timeouts on systemctl-class work: default 120s, agent envelope above the 900s max, race-free cancel, keyboard-interactive auth, and clear timeout errors. Add vps_upload/vps_download over SFTP. --- docs/tools.md | 20 +- go.mod | 4 +- go.sum | 70 +++++ internal/agent/agent.go | 31 +- internal/agent/prompt.go | 6 + internal/agent/vps_timeout_test.go | 28 ++ internal/config/defaults.go | 6 +- internal/hub/catalog/skills/vps-manager.md | 30 +- internal/roles/catalog/vps-manager.md | 11 +- internal/tools/file.go | 194 +++++++++++- internal/tools/file_edit_regression_test.go | 120 ++++++++ internal/tools/register.go | 4 +- internal/tools/registry.go | 4 +- internal/tools/vps.go | 258 ++++++++++++++-- internal/vps/transfer.go | 204 ++++++++++++ internal/vps/vps.go | 109 ++++++- internal/vps/vps_test.go | 325 ++++++++++++++++++++ 17 files changed, 1327 insertions(+), 97 deletions(-) create mode 100644 internal/agent/vps_timeout_test.go create mode 100644 internal/vps/transfer.go create mode 100644 internal/vps/vps_test.go diff --git a/docs/tools.md b/docs/tools.md index 8131ccd..2663876 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -9,7 +9,7 @@ whatever MCP servers add. | Tool | What it does | |---|---| -| `read_file` | Read a text file, numbered lines, with offset and limit for large ones | +| `read_file` | Read a text file as `NUMBER|CONTENT` lines, with offset and limit for large ones | | `write_file` | Create or overwrite, making parent directories | | `edit_file` | Replace an exact string, which must appear exactly once unless told otherwise | | `list_files` | Directory entries, optionally recursive | @@ -21,6 +21,12 @@ Paths are relative to the workspace and cannot escape it. `edit_file` requiring a unique match is deliberate: an edit that silently hits the wrong occurrence is worse than one that fails. +`read_file` prints each line as `NUMBER|CONTENT`. The number and `|` are +metadata for the model — they are not part of the file. `edit_file` matches +line endings to the file automatically (so a paste from `read_file` works on +CRLF files) and will strip a whole-block paste of `NUMBER|` prefixes if the +model includes them by mistake. Tabs and spaces must still match exactly. + ### Terminal | Tool | What it does | @@ -30,6 +36,18 @@ the wrong occurrence is worse than one that fails. The session survives between calls — `cd`, exported variables, and activated environments persist. Backends are local, Docker, or SSH. +### VPS (saved servers over SSH) + +| Tool | What it does | +|---|---| +| `vps_run` | Run a shell command on a dashboard-saved VPS; omit command to list servers | +| `vps_upload` | SFTP upload a local workspace file to the VPS | +| `vps_download` | SFTP download a remote file into the workspace | + +Default command timeout is 120 seconds (raise `timeout_seconds` up to 900 for +`systemctl restart` / upgrades). Transfers are capped at 256 MiB. Credentials +stay encrypted at rest; host keys are pinned on first connect (TOFU). + ### Web | Tool | What it does | diff --git a/go.mod b/go.mod index 97a2a03..42569de 100644 --- a/go.mod +++ b/go.mod @@ -8,9 +8,11 @@ require ( github.com/charmbracelet/glamour v1.0.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/emersion/go-imap v1.2.1 + github.com/enowdev/cloak-go v0.2.2-0.20260802105213-db0721c9e2e8 github.com/jackc/pgx/v5 v5.10.0 github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728 github.com/muesli/termenv v0.16.0 + github.com/pkg/sftp v1.13.9 github.com/sardanioss/httpcloak v1.6.8 golang.org/x/crypto v0.54.0 golang.org/x/net v0.56.0 @@ -39,7 +41,6 @@ require ( github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect - github.com/enowdev/cloak-go v0.2.2-0.20260802105213-db0721c9e2e8 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 // indirect github.com/gobwas/httphead v0.1.0 // indirect @@ -51,6 +52,7 @@ require ( github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/klauspost/compress v1.18.2 // indirect + github.com/kr/fs v0.1.0 // indirect github.com/kr/text v0.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect diff --git a/go.sum b/go.sum index 94f7cfc..2a82dbe 100644 --- a/go.sum +++ b/go.sum @@ -96,6 +96,8 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -125,10 +127,14 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 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/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= github.com/oschwald/geoip2-golang v1.13.0 h1:Q44/Ldc703pasJeP5V9+aFSZFmBN7DKHbNsSFzQATJI= github.com/oschwald/geoip2-golang v1.13.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo= github.com/oschwald/maxminddb-golang v1.13.0 h1:R8xBorY71s84yO06NgTmQvqvTvlS/bnYZrrWX1MElnU= github.com/oschwald/maxminddb-golang v1.13.0/go.mod h1:BU0z8BfFVhi1LQaonTwwGQlsHUEu9pWNdMfmq4ztm0o= +github.com/pkg/sftp v1.13.9 h1:4NGkvGudBL7GteO3m6qnaQ4pC0Kvf0onSVc9gR3EWBw= +github.com/pkg/sftp v1.13.9/go.mod h1:OBN7bVXdstkFFN/gdnHPUb5TE8eb8G1Rp9wCItqjkkA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= @@ -154,43 +160,107 @@ github.com/sardanioss/udpbara v1.1.0/go.mod h1:aNCe+94AMrx1FiSPusvGPQnsJ6TPEc1Ra github.com/sardanioss/utls v1.10.3 h1:b1HU1+owWCUhfLUk0mk2mOgepL14rmthbA1PS8XGQXo= github.com/sardanioss/utls v1.10.3/go.mod h1:3sXK05Ir31HiMGINYV1uMDFRv/z9JL+QY9ITi/WEbV0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 90e44f6..ab91103 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -158,19 +158,19 @@ type Result struct { // Agent owns the shared services a run needs. type Agent struct { - cfg *config.Config - db store.Store - reg *tools.Registry - shell *tools.ShellManager - rag tools.RAGProvider - skills *skills.Manager - checks *checkpoint.Store - plugins *plugin.Manager - roles *roles.Registry - findings *findings.Store - intel *engagement.Store - roleperf *roleperf.Tracker - board *board.Board + cfg *config.Config + db store.Store + reg *tools.Registry + shell *tools.ShellManager + rag tools.RAGProvider + skills *skills.Manager + checks *checkpoint.Store + plugins *plugin.Manager + roles *roles.Registry + findings *findings.Store + intel *engagement.Store + roleperf *roleperf.Tracker + board *board.Board socialBrowser tools.SocialBrowserManager bg *bgManager @@ -871,6 +871,11 @@ func (a *Agent) toolTimeout(name string) time.Duration { // process(wait) intentionally blocks for at most 30 seconds. Leave margin // for scheduling and JSON serialization so the tool can return its state. return 45 * time.Second + case "vps_run", "vps_upload", "vps_download": + // Tools accept timeout_seconds up to 900. The agent envelope must sit + // above that or a long systemctl/apt/transfer is killed early with a + // bare context deadline and looks like a flaky VPS failure. + return 16 * time.Minute case "delegate_task": return 30 * time.Minute default: diff --git a/internal/agent/prompt.go b/internal/agent/prompt.go index 199c2bf..a971e5f 100644 --- a/internal/agent/prompt.go +++ b/internal/agent/prompt.go @@ -87,6 +87,12 @@ help them now — do not block them. if len(active) > 0 { b.WriteString("\n## Tool notes\n\n") b.WriteString("- Paths given to file tools are relative to the workspace; you cannot read outside it.\n") + if hasTool(active, "read_file") || hasTool(active, "edit_file") { + // Harness guidance for the read → edit loop. Without this, models + // paste line numbers into old_string or expand tabs to spaces and + // the exact match fails repeatedly. + b.WriteString("- read_file returns lines as `NUMBER|CONTENT`. The `|` is metadata only. When calling edit_file, copy **only** the content after `|` into old_string/new_string — never the line number. Preserve tabs and spaces exactly (do not expand tabs to spaces). Line endings are matched automatically.\n") + } b.WriteString("- The terminal keeps state between calls: `cd`, exports, and activated environments persist.\n") if hasTool(active, "memory") && cfg.Memory.Enabled { b.WriteString("- Save durable facts about the user or project with the memory tool. Save only what stays true across sessions.\n") diff --git a/internal/agent/vps_timeout_test.go b/internal/agent/vps_timeout_test.go new file mode 100644 index 0000000..f5dc62f --- /dev/null +++ b/internal/agent/vps_timeout_test.go @@ -0,0 +1,28 @@ +package agent + +import ( + "testing" + "time" + + "github.com/enowdev/antares/internal/config" +) + +func TestVPSToolTimeoutExceedsMaxCommandTimeout(t *testing.T) { + a := &Agent{cfg: config.Default()} + for _, name := range []string{"vps_run", "vps_upload", "vps_download"} { + got := a.toolTimeout(name) + // Tools allow timeout_seconds up to 900; the agent envelope must not + // cut them off earlier (that produced bare "context deadline exceeded" + // failures on long systemctl/apt runs). + if got < 15*time.Minute { + t.Fatalf("%s tool timeout = %s, want at least 15m (above 900s max command timeout)", name, got) + } + } +} + +func TestVPSToolTimeoutFromConfigMap(t *testing.T) { + cfg := config.Default() + if cfg.Tools.Timeouts["vps_run"] < 900 { + t.Fatalf("default config vps_run timeout = %d, want >= 900", cfg.Tools.Timeouts["vps_run"]) + } +} diff --git a/internal/config/defaults.go b/internal/config/defaults.go index b88cb01..c2b6ce6 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -64,7 +64,11 @@ func Default() *Config { }, Tools: Tools{ Toolset: "default", ApprovalMode: "auto", MaxOutputChars: 60000, - Timeouts: map[string]int{"terminal": 300, "web_fetch": 60, "web_search": 30}, + Timeouts: map[string]int{ + "terminal": 300, "web_fetch": 60, "web_search": 30, + // VPS tools allow up to 900s per call; keep the agent envelope above that. + "vps_run": 960, "vps_upload": 960, "vps_download": 960, + }, WebSearch: WebSearch{Provider: "browser", MaxResults: 8}, Browser: Browser{ Enabled: true, Width: 1280, Height: 800, diff --git a/internal/hub/catalog/skills/vps-manager.md b/internal/hub/catalog/skills/vps-manager.md index cd9ddae..c61bd57 100644 --- a/internal/hub/catalog/skills/vps-manager.md +++ b/internal/hub/catalog/skills/vps-manager.md @@ -7,9 +7,15 @@ triggers: [vps, server, ssh, systemctl, restart service, check server, disk full # VPS manager -The user has saved VPS servers on the dashboard's VPS page. You reach them with -the **`vps_run`** tool: it runs a shell command on a chosen server over SSH and -returns the output. There is no agent on the box — just standard commands. +The user has saved VPS servers on the dashboard's VPS page. You reach them with: + +| Tool | Purpose | +|---|---| +| **`vps_run`** | Run a shell command over SSH; returns stdout+stderr | +| **`vps_upload`** | Copy a local workspace file → remote path (SFTP) | +| **`vps_download`** | Copy a remote file → local workspace path (SFTP) | + +There is no agent on the box — just standard SSH/SFTP. ## Pick the server first @@ -17,6 +23,12 @@ Call `vps_run` with **no command** to list the saved servers (id, label, user@host). Then pass `vps=` on every call. If the user named a server, match it to a label; if there is only one, use it. +## Timeouts + +Default command timeout is **120 seconds**. `systemctl restart` / `stop` and +package upgrades often need more — pass `timeout_seconds` (up to 900). On +timeout, raise it and prefer non-interactive flags (`--no-pager`, `-y`). + ## Look before you touch Start read-only to understand the box, then act. Useful reads: @@ -36,14 +48,16 @@ something. Once you know the state, operate deliberately: -- **Restart a service:** `systemctl restart ` then confirm with - `systemctl status --no-pager`. +- **Restart a service:** `systemctl restart ` (consider `timeout_seconds` + ≥ 180) then confirm with `systemctl status --no-pager`. - **Free disk:** clear old logs (`journalctl --vacuum-time=7d`), package caches (`apt-get clean` / `dnf clean all`), then re-check `df -h`. - **Update packages:** `apt-get update && apt-get -y upgrade` (Debian/Ubuntu) or - `dnf -y upgrade` (RHEL family). Say what changed. -- **Deploy / app ops:** cd into the app, pull, build, restart its unit or - container — follow the user's stated workflow, don't invent one. + `dnf -y upgrade` (RHEL family) with a higher timeout. Say what changed. +- **Deploy / app ops:** upload artifacts with `vps_upload`, or pull on the box; + then build/restart. Follow the user's stated workflow. +- **Fetch logs/configs:** `vps_download` for a single file; use `vps_run` + + `journalctl` for live service logs. ## Rules diff --git a/internal/roles/catalog/vps-manager.md b/internal/roles/catalog/vps-manager.md index 67077fa..cb69227 100644 --- a/internal/roles/catalog/vps-manager.md +++ b/internal/roles/catalog/vps-manager.md @@ -11,11 +11,12 @@ You are a server operator. You look after the user's VPS servers over SSH: check their health, read logs, restart services, free disk, deploy updates, and fix what's broken — carefully, on machines the user owns. -You reach a server with the **`vps_run`** tool: it runs a shell command on a -chosen server and returns the output. There is no agent on the box — just -ordinary commands. The dashboard's VPS page shows CPU/RAM/disk/uptime and a -process list at a glance; use `vps_run` when you need something specific or need -to change something. +You reach a server with **`vps_run`** (SSH command), **`vps_upload`** and +**`vps_download`** (SFTP file copy). There is no agent on the box — just +ordinary SSH/SFTP. The dashboard's VPS page shows CPU/RAM/disk/uptime and a +process list at a glance; use the tools when you need something specific, need +to change something, or need to move files. Default command timeout is 120s — +raise `timeout_seconds` for `systemctl restart` and package upgrades (max 900). ## Work the problem, don't guess diff --git a/internal/tools/file.go b/internal/tools/file.go index 633e8cc..dee5481 100644 --- a/internal/tools/file.go +++ b/internal/tools/file.go @@ -286,13 +286,13 @@ type editFileTool struct{} func (editFileTool) Name() string { return "edit_file" } func (editFileTool) Description() string { - return "Replace an exact string in a file. The old_string must appear exactly once unless replace_all is set." + return "Replace an exact string in a file. The old_string must appear exactly once unless replace_all is set. Copy old_string from read_file output using only the content after the NUMBER| separator (never the line number). Preserve tabs/spaces exactly; line endings are matched automatically." } func (editFileTool) RequiresApproval() bool { return true } func (editFileTool) Schema() map[string]any { return schema(map[string]any{ "path": prop("string", "File to edit."), - "old_string": prop("string", "Exact text to find, including indentation."), + "old_string": prop("string", "Exact text to find, including indentation (tabs/spaces). Do not include read_file line numbers."), "new_string": prop("string", "Replacement text."), "replace_all": propDefault("boolean", "Replace every occurrence.", false), }, "path", "old_string", "new_string") @@ -320,24 +320,15 @@ func (editFileTool) Execute(_ context.Context, in Input) Result { return Errorf("cannot read %s: %v", args.Path, err) } content := string(data) - oldString, newString := args.OldString, args.NewString - count := strings.Count(content, oldString) - if count == 0 && strings.Contains(content, "\r\n") { - // read_file displays logical lines with LF separators. Translate copied - // multi-line edits back to the source convention without rewriting the - // untouched CRLF content. - oldString = strings.ReplaceAll(oldString, "\n", "\r\n") - newString = strings.ReplaceAll(newString, "\n", "\r\n") - count = strings.Count(content, oldString) - } + oldString, newString, count, how := resolveEditMatch(content, args.OldString, args.NewString) switch { case count == 0: - return Errorf("old_string not found in %s. Read the file first and copy the exact text.", args.Path) + return Errorf("%s", editNotFoundMessage(args.Path, content, args.OldString)) case count > 1 && !args.ReplaceAll: return Errorf("old_string appears %d times in %s; add more surrounding context or set replace_all", count, args.Path) } - updated := content + var updated string if args.ReplaceAll { updated = strings.ReplaceAll(content, oldString, newString) } else { @@ -351,12 +342,185 @@ func (editFileTool) Execute(_ context.Context, in Input) Result { replaced = 1 } rel := relTo(in.Workspace, path) + msg := fmt.Sprintf("Edited %s (%d replacement(s))", rel, replaced) + if how != "" { + msg += " [" + how + "]" + } return Result{ - Content: fmt.Sprintf("Edited %s (%d replacement(s))", rel, replaced), + Content: msg, Meta: map[string]any{"path": rel, "replacements": replaced}, } } +// fileEOL returns the dominant newline sequence used in s. +func fileEOL(s string) string { + if strings.Contains(s, "\r\n") { + return "\r\n" + } + if strings.Contains(s, "\r") { + return "\r" + } + return "\n" +} + +// toEOL rewrites every newline in s to the given eol sequence. +func toEOL(s, eol string) string { + s = strings.ReplaceAll(s, "\r\n", "\n") + s = strings.ReplaceAll(s, "\r", "\n") + if eol == "\n" { + return s + } + return strings.ReplaceAll(s, "\n", eol) +} + +// stripReadFileLinePrefixes removes a NUMBER| prefix from every line when the +// whole block looks like a paste of read_file output. Returns ok=false when the +// string should be left alone (mixed or missing prefixes). +func stripReadFileLinePrefixes(s string) (string, bool) { + if s == "" { + return s, false + } + // Work on LF so CR in a pasted block does not hide the prefix. + normalized := strings.ReplaceAll(s, "\r\n", "\n") + normalized = strings.ReplaceAll(normalized, "\r", "\n") + // Preserve whether the input ended with a newline so join stays faithful. + trimTrailing := strings.HasSuffix(normalized, "\n") + body := normalized + if trimTrailing { + body = strings.TrimSuffix(body, "\n") + } + if body == "" { + return s, false + } + lines := strings.Split(body, "\n") + out := make([]string, 0, len(lines)) + for _, line := range lines { + i := strings.IndexByte(line, '|') + if i <= 0 { + return s, false + } + for _, c := range line[:i] { + if c < '0' || c > '9' { + return s, false + } + } + out = append(out, line[i+1:]) + } + joined := strings.Join(out, "\n") + if trimTrailing { + joined += "\n" + } + return joined, true +} + +// resolveEditMatch finds old/new strings that match content, recovering from +// the two failure modes that read_file → edit_file commonly hits: +// 1. LF vs CRLF (read_file always displays LF) +// 2. pasted NUMBER| line prefixes from read_file output +// +// how is a short note for the success message when recovery was used; empty on +// a plain exact match. +func resolveEditMatch(content, oldIn, newIn string) (oldString, newString string, count int, how string) { + eol := fileEOL(content) + + try := func(oldCand, newCand, label string) bool { + o := toEOL(oldCand, eol) + n := toEOL(newCand, eol) + if o == "" { + return false + } + c := strings.Count(content, o) + if c == 0 { + return false + } + oldString, newString, count, how = o, n, c, label + return true + } + + // 1. Exact / EOL-normalized (covers LF paste against a CRLF file). + if try(oldIn, newIn, "") { + // Only annotate when the on-disk form actually differs from the input + // (i.e. we rewrote newlines). A pure exact match stays silent. + if oldString != oldIn { + how = "normalized line endings to match file" + } + return + } + + // 2. Strip NUMBER| prefixes from a full paste of read_file output. + oldStripped, oldOK := stripReadFileLinePrefixes(oldIn) + newStripped, newOK := stripReadFileLinePrefixes(newIn) + if oldOK { + newCand := newIn + if newOK { + newCand = newStripped + } + if try(oldStripped, newCand, "stripped read_file NUMBER| prefixes") { + return + } + } + + return oldIn, newIn, 0, "" +} + +// editNotFoundMessage explains why an edit missed, with actionable recovery +// hints for the model (line prefixes, tabs vs spaces, re-read). +func editNotFoundMessage(path, content, oldString string) string { + var b strings.Builder + fmt.Fprintf(&b, "old_string not found in %s.", path) + + if stripped, ok := stripReadFileLinePrefixes(oldString); ok { + if strings.Count(content, toEOL(stripped, fileEOL(content))) > 0 { + b.WriteString(" Your old_string still includes read_file line numbers (NUMBER|). Call edit_file again with only the content after each |.") + return b.String() + } + } + + if strings.Contains(content, "\t") && strings.Contains(oldString, " ") && !strings.Contains(oldString, "\t") { + // Spaces in old_string might still be inter-word; only flag when a + // detabbed view of the file contains the old_string. + for _, width := range []int{2, 4, 8} { + detabbed := expandTabs(content, width) + if strings.Contains(detabbed, toEOL(oldString, "\n")) || strings.Contains(detabbed, oldString) { + fmt.Fprintf(&b, " The file indents with TAB characters, but old_string uses spaces (tab width ~%d). Re-read the file and copy the content after NUMBER| without expanding tabs.", width) + return b.String() + } + } + } + + b.WriteString(" Read the file first and copy only the content after the NUMBER| separator; preserve tabs, spaces, and indentation exactly.") + return b.String() +} + +// expandTabs replaces leading and embedded tabs with spaces at the given width +// (stop-based), used only for mismatch diagnosis. +func expandTabs(s string, width int) string { + if width <= 0 { + width = 4 + } + var b strings.Builder + b.Grow(len(s)) + col := 0 + for _, r := range s { + switch r { + case '\t': + spaces := width - (col % width) + b.WriteString(strings.Repeat(" ", spaces)) + col += spaces + case '\n': + b.WriteByte('\n') + col = 0 + case '\r': + // Keep CR out of the comparison view; pair with LF handling above. + continue + default: + b.WriteRune(r) + col++ + } + } + return b.String() +} + // ---- list_files ------------------------------------------------------------- type listFilesTool struct{} diff --git a/internal/tools/file_edit_regression_test.go b/internal/tools/file_edit_regression_test.go index 4ff3cac..614d34e 100644 --- a/internal/tools/file_edit_regression_test.go +++ b/internal/tools/file_edit_regression_test.go @@ -2,6 +2,7 @@ package tools import ( "context" + "encoding/json" "os" "path/filepath" "strings" @@ -40,3 +41,122 @@ func TestReadAndEditPreserveTabbedCRLFContent(t *testing.T) { t.Fatalf("edited bytes = %q, want %q", got, want) } } + +// Model copies old_string from read_file output, which always uses LF, even when +// the on-disk file is CRLF. edit_file must still match and preserve the file's +// original line endings on write. +func TestEditFileMatchesCRLFWhenCopiedFromRead(t *testing.T) { + workspace := t.TempDir() + path := filepath.Join(workspace, "win.go") + original := "package main\r\n\r\nfunc main() {\r\n\tfmt.Println(\"hi\")\r\n}\r\n" + if err := os.WriteFile(path, []byte(original), 0o644); err != nil { + t.Fatal(err) + } + + readArgs, _ := json.Marshal(map[string]any{"path": "win.go"}) + read := (readFileTool{}).Execute(context.Background(), Input{Args: readArgs, Workspace: workspace}) + if read.IsError { + t.Fatalf("read: %s", read.Content) + } + + var copied []string + for _, line := range strings.Split(strings.TrimSuffix(read.Content, "\n"), "\n") { + _, content, ok := strings.Cut(line, "|") + if !ok { + t.Fatalf("read line missing NUMBER| separator: %q", line) + } + copied = append(copied, content) + } + // Function body as the model would reassemble it from the LF display. + oldString := strings.Join(copied[2:5], "\n") + newString := strings.Replace(oldString, "hi", "bye", 1) + + editArgs, _ := json.Marshal(map[string]any{ + "path": "win.go", "old_string": oldString, "new_string": newString, + }) + edited := (editFileTool{}).Execute(context.Background(), Input{Args: editArgs, Workspace: workspace}) + if edited.IsError { + t.Fatalf("edit_file failed for CRLF file after read_file copy: %s", edited.Content) + } + + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + want := "package main\r\n\r\nfunc main() {\r\n\tfmt.Println(\"bye\")\r\n}\r\n" + if string(got) != want { + t.Fatalf("edited content = %q\nwant %q", got, want) + } +} + +// Models sometimes paste the whole NUMBER| line from read_file into old_string. +// edit_file should strip a consistent line-number prefix block and still match. +func TestEditFileStripsReadFileLineNumberPrefixes(t *testing.T) { + workspace := t.TempDir() + path := filepath.Join(workspace, "a.go") + original := "package main\n\nfunc main() {\n\treturn\n}\n" + if err := os.WriteFile(path, []byte(original), 0o644); err != nil { + t.Fatal(err) + } + + // Accidental paste of the read_file display format. + oldWithPrefix := "3|func main() {\n4|\treturn\n5|}" + newWithPrefix := "3|func main() {\n4|\treturn nil\n5|}" + editArgs, _ := json.Marshal(map[string]any{ + "path": "a.go", "old_string": oldWithPrefix, "new_string": newWithPrefix, + }) + edited := (editFileTool{}).Execute(context.Background(), Input{Args: editArgs, Workspace: workspace}) + if edited.IsError { + t.Fatalf("edit_file should strip NUMBER| prefixes: %s", edited.Content) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + want := "package main\n\nfunc main() {\n\treturn nil\n}\n" + if string(got) != want { + t.Fatalf("edited = %q, want %q", got, want) + } +} + +// When the match still fails, the error must say what went wrong in a way the +// model can act on (tabs vs spaces is the common indentation trap). +func TestEditFileDiagnosesTabVsSpaceMismatch(t *testing.T) { + workspace := t.TempDir() + path := filepath.Join(workspace, "tabs.c") + original := "\t\tif (x) {\n\t\t\tdo_work();\n\t\t}\n" + if err := os.WriteFile(path, []byte(original), 0o644); err != nil { + t.Fatal(err) + } + editArgs, _ := json.Marshal(map[string]any{ + "path": "tabs.c", + "old_string": " if (x) {\n do_work();\n }", + "new_string": " if (x) {\n do_work2();\n }", + }) + edited := (editFileTool{}).Execute(context.Background(), Input{Args: editArgs, Workspace: workspace}) + if !edited.IsError { + t.Fatal("expected failure for tab/space mismatch") + } + if !strings.Contains(edited.Content, "tab") { + t.Fatalf("error should diagnose tabs vs spaces, got: %s", edited.Content) + } +} + +func TestStripReadFileLinePrefixes(t *testing.T) { + in := "10|\tfoo()\n11|\tbar()\n12|}" + got, ok := stripReadFileLinePrefixes(in) + if !ok { + t.Fatal("expected strip success") + } + if got != "\tfoo()\n\tbar()\n}" { + t.Fatalf("got %q", got) + } + // Not every line prefixed → leave alone (could be real pipe content). + if _, ok := stripReadFileLinePrefixes("a|b\nc"); ok { + t.Fatal("partial prefix block must not strip") + } + // Single line of real data that happens to contain a pipe stays intact. + if s, ok := stripReadFileLinePrefixes("nope"); ok || s != "nope" { + t.Fatalf("non-prefixed = %q ok=%v", s, ok) + } +} diff --git a/internal/tools/register.go b/internal/tools/register.go index 7e52a5b..c282e9f 100644 --- a/internal/tools/register.go +++ b/internal/tools/register.go @@ -49,8 +49,8 @@ func init() { osintEmailFullTool{}, // Global proxy store lookup — lets the agent pick a stored proxy. listProxiesTool{}, - // Run commands on the user's saved VPS servers over SSH. - vpsRunTool{}, + // Run commands and transfer files on the user's saved VPS servers over SSH/SFTP. + vpsRunTool{}, vpsUploadTool{}, vpsDownloadTool{}, // Anti-detect CAPTCHA solver (reuses the stealth browser). solveCaptchaTool{}, // Social Media: read IMAP inbox for verification emails and OTP. diff --git a/internal/tools/registry.go b/internal/tools/registry.go index 67a6f53..b058974 100644 --- a/internal/tools/registry.go +++ b/internal/tools/registry.go @@ -221,7 +221,7 @@ var Toolsets = map[string][]string{ "read_file", "write_file", "list_files", "glob", "grep", "terminal", "process", "web_search", "web_fetch", "http_request", "browser", "todo", "skill", "report_finding", "triage_finding", "add_intel", "methodology_status", - "delegate_task", "task", "list_roles", "diagnostics", "ask_user", "vps_run", + "delegate_task", "task", "list_roles", "diagnostics", "ask_user", "vps_run", "vps_upload", "vps_download", "osint_dns", "osint_dorks", "osint_whois", "osint_ip", "osint_username", "osint_github", "osint_email", "osint_email_full", "list_proxies", "osint_breach", "osint_shodan", "osint_reputation", "osint_crypto", "osint_domain", "osint_phone", "osint_scrape", "osint_paste", "osint_footprint", "osint_pivot", "osint_google", "osint_dorks_live", "check_dependencies", "re_info", "re_strings", "re_analyze", "re_decompile", "solve_captcha", "intercept", "attack_script", "awshook", "azurehook", "kubehook", "winhook", "machook", "cipipe", "ebpf", "hackbrowser", @@ -251,7 +251,7 @@ var Toolsets = map[string][]string{ }, "default": { "read_file", "read_document", "write_file", "edit_file", "list_files", "glob", "grep", - "terminal", "process", "web_search", "web_fetch", "http_request", "browser", "todo", "board", "project_info", "set_soul", "memory", "list_proxies", "vps_run", + "terminal", "process", "web_search", "web_fetch", "http_request", "browser", "todo", "board", "project_info", "set_soul", "memory", "list_proxies", "vps_run", "vps_upload", "vps_download", "session_search", "rag_search", "rag_index", "skill", "delegate_task", "task", "list_roles", "image_generate", "view_image", "speak", "transcribe", "computer", "diagnostics", "ask_user", "schedule", "osint_dns", "osint_dorks", "osint_whois", "osint_ip", "osint_username", "osint_github", "osint_email", "osint_email_full", "osint_breach", "osint_shodan", "osint_reputation", "osint_crypto", "osint_domain", "osint_phone", "osint_scrape", "osint_paste", "osint_footprint", "osint_pivot", "osint_google", "osint_dorks_live", "check_dependencies", "re_info", "re_strings", "re_analyze", "re_decompile", "solve_captcha", "intercept", "email_read", "temp_mail", "social_browser", "social_account", diff --git a/internal/tools/vps.go b/internal/tools/vps.go index bb19c70..b40fc6e 100644 --- a/internal/tools/vps.go +++ b/internal/tools/vps.go @@ -7,9 +7,77 @@ import ( "strings" "time" + "github.com/enowdev/antares/internal/store" "github.com/enowdev/antares/internal/vps" ) +// ---- shared host resolution ------------------------------------------------- + +// resolveVPSHost picks a saved host by id or case-insensitive label. +func resolveVPSHost(ctx context.Context, in Input, ref string) (*store.VPSHost, error) { + if in.Deps == nil || in.Deps.Store == nil { + return nil, fmt.Errorf("no VPS store available in this runtime") + } + hosts, err := in.Deps.Store.ListVPSHosts(ctx) + if err != nil { + return nil, fmt.Errorf("could not read saved servers: %v", err) + } + if len(hosts) == 0 { + return nil, fmt.Errorf("no servers are saved. Add one on the dashboard's VPS page (host, port, user, and a password or SSH key), then reference it by id or label") + } + ref = strings.TrimSpace(ref) + if ref == "" { + return nil, fmt.Errorf("vps is required — call vps_run with no command to list saved servers") + } + for i := range hosts { + h := &hosts[i] + if h.ID == ref || strings.EqualFold(h.Label, ref) { + return h, nil + } + } + return nil, fmt.Errorf("no saved server matches %q — call vps_run with no command to list them", ref) +} + +func targetFromHost(h *store.VPSHost) vps.Target { + return vps.Target{ + Host: h.Host, Port: h.Port, Username: h.Username, AuthMethod: h.AuthMethod, + Password: h.Password, PrivateKey: h.PrivateKey, Passphrase: h.Passphrase, + KnownHostKey: h.HostKey, + } +} + +func pinIfNeeded(ctx context.Context, in Input, h *store.VPSHost, seen string, err error) { + if h.HostKey != "" || seen == "" || errors.Is(err, vps.ErrHostKeyChanged) { + return + } + if in.Deps != nil && in.Deps.Store != nil { + _ = in.Deps.Store.SetVPSHostKey(ctx, h.ID, seen) + } +} + +func formatVPSList(hosts []store.VPSHost) string { + var b strings.Builder + fmt.Fprintf(&b, "Saved servers (%d) — pass id or label as `vps`:\n\n", len(hosts)) + for _, h := range hosts { + fmt.Fprintf(&b, " - id=%s label=%q %s@%s:%d\n", h.ID, h.Label, h.Username, h.Host, h.Port) + } + return b.String() +} + +// clampVPSTimeout returns a sane duration for a VPS tool call. Default 120s +// (systemctl restart/stop often exceeds 60s). Hard cap 900s matches the schema. +func clampVPSTimeout(seconds int) time.Duration { + if seconds <= 0 { + seconds = 120 + } + if seconds > 900 { + seconds = 900 + } + return time.Duration(seconds) * time.Second +} + +// ---- vps_run ---------------------------------------------------------------- + // vpsRunTool runs a shell command on one of the user's saved VPS hosts over SSH // and returns its output. It is the muscle behind the "VPS manager" skill: // inspect services, read logs, restart things, deploy, update packages — the @@ -20,15 +88,15 @@ func (vpsRunTool) Name() string { return "vps_run" } func (vpsRunTool) Description() string { return "Run a shell command on one of the user's saved VPS servers over SSH and return its output. " + "Call with no command to list the available servers (id + label + host). Pass `vps` as a server's id " + - "or label. Use it to inspect and manage a server — systemctl, journalctl, docker, df, apt/yum, deploys, " + - "edits. Commands run as the configured SSH user; be careful with destructive ones. For authorized use " + - "on servers the user owns." + "or label. Use it to inspect and manage a server — systemctl, journalctl, docker, df, apt/yum, deploys. " + + "For file copy use vps_upload / vps_download (SFTP). Default timeout is 120s (raise timeout_seconds for " + + "systemctl restart, apt upgrade, long deploys). Commands run as the configured SSH user." } func (vpsRunTool) Schema() map[string]any { return schema(map[string]any{ "vps": prop("string", "Which server: its id or label (call with no command to list them)."), "command": prop("string", "The shell command to run. Omit to just list the saved servers."), - "timeout_seconds": propDefault("integer", "How long to allow the command to run.", 60), + "timeout_seconds": propDefault("integer", "How long to allow the command to run (default 120, max 900). systemctl restart/stop often needs >60s.", 120), }) } @@ -60,50 +128,44 @@ func (vpsRunTool) Execute(ctx context.Context, in Input) Result { // No command → list the servers so the agent can pick one. ref := strings.TrimSpace(args.VPS) if strings.TrimSpace(args.Command) == "" || ref == "" { - var b strings.Builder - fmt.Fprintf(&b, "Saved servers (%d) — pass id or label as `vps`, plus a `command`:\n\n", len(hosts)) - for _, h := range hosts { - fmt.Fprintf(&b, " - id=%s label=%q %s@%s:%d\n", h.ID, h.Label, h.Username, h.Host, h.Port) - } - return Text(b.String()) + return Text(formatVPSList(hosts)) } - // Resolve by id, then case-insensitive label. - var target *vps.Target - label, hostID, hadKey := "", "", false + var host *store.VPSHost for i := range hosts { - h := hosts[i] + h := &hosts[i] if h.ID == ref || strings.EqualFold(h.Label, ref) { - t := vps.Target{ - Host: h.Host, Port: h.Port, Username: h.Username, AuthMethod: h.AuthMethod, - Password: h.Password, PrivateKey: h.PrivateKey, Passphrase: h.Passphrase, - KnownHostKey: h.HostKey, - } - target = &t - label, hostID, hadKey = h.Label, h.ID, h.HostKey != "" + host = h break } } - if target == nil { + if host == nil { return Errorf("no saved server matches %q — call vps_run with no command to list them", ref) } - if args.Timeout <= 0 || args.Timeout > 900 { - args.Timeout = 60 - } - runCtx, cancel := context.WithTimeout(ctx, time.Duration(args.Timeout)*time.Second) + timeout := clampVPSTimeout(args.Timeout) + runCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - in.Emit(Progress{Tool: "vps_run", Message: fmt.Sprintf("running on %s…", label)}) - out, seen, err := vps.Run(runCtx, *target, args.Command) - // Pin the host key on first successful connect (TOFU). - if !hadKey && seen != "" && !errors.Is(err, vps.ErrHostKeyChanged) { - _ = in.Deps.Store.SetVPSHostKey(ctx, hostID, seen) + label := host.Label + if label == "" { + label = host.Host } + in.Emit(Progress{Tool: "vps_run", Message: fmt.Sprintf("running on %s…", label)}) + out, seen, err := vps.Run(runCtx, targetFromHost(host), args.Command) + pinIfNeeded(ctx, in, host, seen, err) out = strings.TrimRight(out, "\n") if err != nil { - // Include whatever output there was (usually stderr) — more useful than - // the bare error. + if errors.Is(err, vps.ErrTimeout) || errors.Is(err, context.DeadlineExceeded) { + msg := fmt.Sprintf("Command timed out after %s on %s. "+ + "Increase timeout_seconds (max 900). systemctl restart/stop and package upgrades often need 120–300s. "+ + "Prefer non-interactive flags (e.g. systemctl status NAME --no-pager).", + timeout, label) + if out != "" { + return Result{Content: msg + "\n\nPartial output:\n" + out, IsError: true} + } + return Errorf("%s", msg) + } msg := err.Error() if out != "" { return Result{Content: fmt.Sprintf("Command failed on %s: %s\n\n%s", label, msg, out), IsError: true} @@ -115,3 +177,133 @@ func (vpsRunTool) Execute(ctx context.Context, in Input) Result { } return Text(fmt.Sprintf("On %s:\n\n%s", label, out)) } + +// ---- vps_upload ------------------------------------------------------------- + +type vpsUploadTool struct{} + +func (vpsUploadTool) Name() string { return "vps_upload" } +func (vpsUploadTool) Description() string { + return "Upload a local file to a saved VPS over SFTP. " + + "`local_path` is relative to the workspace (or absolute inside write roots); " + + "`remote_path` is the destination on the server. Creates remote parent dirs. " + + "Max 256 MiB. Call vps_run with no command first if you need the server id/label." +} +func (vpsUploadTool) Schema() map[string]any { + return schema(map[string]any{ + "vps": prop("string", "Server id or label."), + "local_path": prop("string", "Local file path (workspace-relative or absolute in write roots)."), + "remote_path": prop("string", "Destination path on the VPS (absolute or relative to the SSH user's home)."), + "timeout_seconds": propDefault("integer", "Transfer timeout in seconds.", 120), + }, "vps", "local_path", "remote_path") +} +func (vpsUploadTool) RequiresApproval() bool { return true } + +func (vpsUploadTool) Execute(ctx context.Context, in Input) Result { + var args struct { + VPS string `json:"vps"` + LocalPath string `json:"local_path"` + RemotePath string `json:"remote_path"` + Timeout int `json:"timeout_seconds"` + } + if err := in.Bind(&args); err != nil { + return Errorf("%v", err) + } + host, err := resolveVPSHost(ctx, in, args.VPS) + if err != nil { + return Errorf("%v", err) + } + local, err := resolveRead(in, args.LocalPath) + if err != nil { + return Errorf("%v", err) + } + // Upload reads a local file; ensure it is inside a writable root when in a + // project session so the agent cannot scoop arbitrary system files onto the + // VPS without the same boundary write_file would enforce. Ordinary sessions + // already confine resolveRead to the workspace. + if len(in.WriteRoots) > 0 { + if _, err := resolveWrite(in, args.LocalPath); err != nil { + return Errorf("local_path must be inside the project or Antares workspace: %v", err) + } + } + + timeout := clampVPSTimeout(args.Timeout) + runCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + label := host.Label + if label == "" { + label = host.Host + } + in.Emit(Progress{Tool: "vps_upload", Message: fmt.Sprintf("uploading to %s…", label)}) + n, seen, err := vps.Upload(runCtx, targetFromHost(host), local, args.RemotePath) + pinIfNeeded(ctx, in, host, seen, err) + if err != nil { + if errors.Is(err, vps.ErrTimeout) || errors.Is(err, context.DeadlineExceeded) { + return Errorf("upload timed out after %s on %s: %v", timeout, label, err) + } + return Errorf("upload to %s failed: %v", label, err) + } + return Text(fmt.Sprintf("Uploaded %s → %s@%s:%s (%d bytes)", + relTo(in.Workspace, local), host.Username, label, args.RemotePath, n)) +} + +// ---- vps_download ----------------------------------------------------------- + +type vpsDownloadTool struct{} + +func (vpsDownloadTool) Name() string { return "vps_download" } +func (vpsDownloadTool) Description() string { + return "Download a file from a saved VPS over SFTP into the local workspace. " + + "`remote_path` is on the server; `local_path` is the destination (workspace-relative). " + + "Creates local parent dirs. Max 256 MiB." +} +func (vpsDownloadTool) Schema() map[string]any { + return schema(map[string]any{ + "vps": prop("string", "Server id or label."), + "remote_path": prop("string", "Source path on the VPS."), + "local_path": prop("string", "Local destination path (must be inside the workspace / write roots)."), + "timeout_seconds": propDefault("integer", "Transfer timeout in seconds.", 120), + }, "vps", "remote_path", "local_path") +} +func (vpsDownloadTool) RequiresApproval() bool { return true } + +func (vpsDownloadTool) Execute(ctx context.Context, in Input) Result { + var args struct { + VPS string `json:"vps"` + RemotePath string `json:"remote_path"` + LocalPath string `json:"local_path"` + Timeout int `json:"timeout_seconds"` + } + if err := in.Bind(&args); err != nil { + return Errorf("%v", err) + } + host, err := resolveVPSHost(ctx, in, args.VPS) + if err != nil { + return Errorf("%v", err) + } + local, err := resolveWrite(in, args.LocalPath) + if err != nil { + return Errorf("%v", err) + } + + timeout := clampVPSTimeout(args.Timeout) + runCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + label := host.Label + if label == "" { + label = host.Host + } + in.Emit(Progress{Tool: "vps_download", Message: fmt.Sprintf("downloading from %s…", label)}) + n, seen, err := vps.Download(runCtx, targetFromHost(host), args.RemotePath, local) + pinIfNeeded(ctx, in, host, seen, err) + if err != nil { + if errors.Is(err, vps.ErrTimeout) || errors.Is(err, context.DeadlineExceeded) { + return Errorf("download timed out after %s on %s: %v", timeout, label, err) + } + return Errorf("download from %s failed: %v", label, err) + } + return Text(fmt.Sprintf("Downloaded %s@%s:%s → %s (%d bytes)", + host.Username, label, args.RemotePath, relTo(in.Workspace, local), n)) +} diff --git a/internal/vps/transfer.go b/internal/vps/transfer.go new file mode 100644 index 0000000..4f56fda --- /dev/null +++ b/internal/vps/transfer.go @@ -0,0 +1,204 @@ +package vps + +import ( + "context" + "fmt" + "io" + "os" + "path" + "path/filepath" + "strings" + + "github.com/pkg/sftp" +) + +// MaxTransferBytes caps a single upload or download so a runaway path cannot +// exhaust memory or fill the workspace. 256 MiB is enough for configs, modest +// builds, and logs; larger payloads should use a remote pull (curl/wget) or +// rsync via vps_run. +const MaxTransferBytes = 256 << 20 + +// Upload copies a local file to remotePath on the VPS over SFTP. Parent +// directories on the remote side are created as needed. Returns bytes written +// and the host key seen (for TOFU pinning). +func Upload(ctx context.Context, t Target, localPath, remotePath string) (n int64, seen string, err error) { + localPath = filepath.Clean(localPath) + remotePath = cleanRemotePath(remotePath) + if remotePath == "" { + return 0, "", fmt.Errorf("remote_path is required") + } + fi, err := os.Stat(localPath) + if err != nil { + return 0, "", fmt.Errorf("local file: %w", err) + } + if fi.IsDir() { + return 0, "", fmt.Errorf("local path is a directory; upload a single file") + } + if fi.Size() > MaxTransferBytes { + return 0, "", fmt.Errorf("local file is %d bytes; max is %d — use a remote pull for larger payloads", fi.Size(), MaxTransferBytes) + } + + client, err := dial(ctx, t) + if err != nil { + return 0, "", err + } + defer client.Close() + seen = client.seenHostKey + + sc, err := sftp.NewClient(client.Client) + if err != nil { + return 0, seen, fmt.Errorf("sftp: %w", err) + } + defer sc.Close() + + if dir := path.Dir(remotePath); dir != "" && dir != "." { + if err := sc.MkdirAll(dir); err != nil { + return 0, seen, fmt.Errorf("create remote dir %s: %w", dir, err) + } + } + + src, err := os.Open(localPath) + if err != nil { + return 0, seen, err + } + defer src.Close() + + dst, err := sc.OpenFile(remotePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC) + if err != nil { + return 0, seen, fmt.Errorf("open remote %s: %w", remotePath, err) + } + defer dst.Close() + + n, err = copyWithContext(ctx, dst, src) + if err != nil { + return n, seen, err + } + // Preserve mode bits the local file had (best-effort; some servers refuse). + _ = sc.Chmod(remotePath, fi.Mode().Perm()) + return n, seen, nil +} + +// Download copies remotePath from the VPS into localPath over SFTP. Parent +// directories on the local side are created as needed. Returns bytes written +// and the host key seen. +func Download(ctx context.Context, t Target, remotePath, localPath string) (n int64, seen string, err error) { + localPath = filepath.Clean(localPath) + remotePath = cleanRemotePath(remotePath) + if remotePath == "" { + return 0, "", fmt.Errorf("remote_path is required") + } + + client, err := dial(ctx, t) + if err != nil { + return 0, "", err + } + defer client.Close() + seen = client.seenHostKey + + sc, err := sftp.NewClient(client.Client) + if err != nil { + return 0, seen, fmt.Errorf("sftp: %w", err) + } + defer sc.Close() + + fi, err := sc.Stat(remotePath) + if err != nil { + return 0, seen, fmt.Errorf("remote file: %w", err) + } + if fi.IsDir() { + return 0, seen, fmt.Errorf("remote path is a directory; download a single file") + } + if fi.Size() > MaxTransferBytes { + return 0, seen, fmt.Errorf("remote file is %d bytes; max is %d", fi.Size(), MaxTransferBytes) + } + + if err := os.MkdirAll(filepath.Dir(localPath), 0o755); err != nil { + return 0, seen, fmt.Errorf("create local dir: %w", err) + } + + src, err := sc.Open(remotePath) + if err != nil { + return 0, seen, fmt.Errorf("open remote %s: %w", remotePath, err) + } + defer src.Close() + + dst, err := os.OpenFile(localPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fi.Mode().Perm()) + if err != nil { + return 0, seen, err + } + defer func() { + _ = dst.Close() + }() + + n, err = copyWithContext(ctx, dst, src) + return n, seen, err +} + +// cleanRemotePath normalises a remote path for SFTP (slash-separated). Absolute +// paths stay absolute; relative paths stay relative so they resolve under the +// SSH user's home when the server supports it. +func cleanRemotePath(orig string) string { + orig = strings.TrimSpace(orig) + if orig == "" { + return "" + } + orig = filepath.ToSlash(orig) + abs := strings.HasPrefix(orig, "/") + cleaned := path.Clean(orig) + if cleaned == "." { + return "" + } + if abs && !strings.HasPrefix(cleaned, "/") { + cleaned = "/" + cleaned + } + return cleaned +} + +// copyWithContext streams src to dst, aborting if ctx is cancelled, and +// enforcing MaxTransferBytes. Reads run in a goroutine so a blocked Read does +// not ignore cancellation (plain io.Copy cannot be cancelled). +func copyWithContext(ctx context.Context, dst io.Writer, src io.Reader) (int64, error) { + buf := make([]byte, 32*1024) + var written int64 + type readRes struct { + n int + err error + } + for { + if err := ctx.Err(); err != nil { + return written, fmt.Errorf("%w: %v", ErrTimeout, err) + } + ch := make(chan readRes, 1) + go func() { + n, err := src.Read(buf) + ch <- readRes{n: n, err: err} + }() + var rr readRes + select { + case <-ctx.Done(): + return written, fmt.Errorf("%w: %v", ErrTimeout, ctx.Err()) + case rr = <-ch: + } + if rr.n > 0 { + if written+int64(rr.n) > MaxTransferBytes { + return written, fmt.Errorf("transfer exceeds max of %d bytes", MaxTransferBytes) + } + nw, ew := dst.Write(buf[0:rr.n]) + if nw > 0 { + written += int64(nw) + } + if ew != nil { + return written, ew + } + if rr.n != nw { + return written, io.ErrShortWrite + } + } + if rr.err == io.EOF { + return written, nil + } + if rr.err != nil { + return written, rr.err + } + } +} diff --git a/internal/vps/vps.go b/internal/vps/vps.go index 60d6a45..2628e2e 100644 --- a/internal/vps/vps.go +++ b/internal/vps/vps.go @@ -1,7 +1,7 @@ // Package vps connects to a user's server over SSH and reads its state on // demand — no agent installed on the box, just standard commands whose output -// is parsed into metrics. It also runs arbitrary commands for the VPS-manager -// tool. +// is parsed into metrics. It also runs arbitrary commands and transfers files +// for the VPS-manager tools. package vps import ( @@ -45,6 +45,10 @@ func (t Target) addr() string { // pinned one — a possible man-in-the-middle, or a legitimately rebuilt server. var ErrHostKeyChanged = errors.New("host key changed since it was first trusted — possible MITM, or the server was rebuilt; remove and re-add it if you trust the change") +// ErrTimeout is returned when a remote command or transfer exceeds its deadline. +// The error text includes the duration; partial output may accompany it from Run. +var ErrTimeout = errors.New("vps operation timed out") + // conn wraps an ssh.Client with the host key the server actually presented, so // the caller can pin it after a first-use connect. type conn struct { @@ -77,24 +81,58 @@ func dial(ctx context.Context, t Target) (*conn, error) { User: user, Auth: auth, HostKeyCallback: hostKeyCb, - Timeout: 12 * time.Second, + // Handshake timeout only. Command runtime is bounded by the caller's ctx. + Timeout: 20 * time.Second, } - d := net.Dialer{Timeout: 12 * time.Second} + // Dial timeout is separate from the overall command timeout so a slow host + // does not burn the whole vps_run budget before the command starts. + d := net.Dialer{Timeout: 20 * time.Second} netConn, err := d.DialContext(ctx, "tcp", t.addr()) if err != nil { + if ctx.Err() != nil { + return nil, fmt.Errorf("%w while connecting to %s: %v", ErrTimeout, t.addr(), err) + } return nil, fmt.Errorf("connect %s: %w", t.addr(), err) } + // Honour cancellation during the SSH handshake too. + if deadline, ok := ctx.Deadline(); ok { + _ = netConn.SetDeadline(deadline) + } c, chans, reqs, err := ssh.NewClientConn(netConn, t.addr(), cfg) if err != nil { netConn.Close() - // A host-key mismatch surfaces here wrapped by the ssh handshake; keep the - // sentinel recognisable to the caller. if errors.Is(err, ErrHostKeyChanged) { return nil, ErrHostKeyChanged } + if ctx.Err() != nil { + return nil, fmt.Errorf("%w during ssh handshake with %s: %v", ErrTimeout, t.addr(), err) + } return nil, fmt.Errorf("ssh handshake: %w", err) } - return &conn{Client: ssh.NewClient(c, chans, reqs), seenHostKey: seen}, nil + // Clear the dial deadline so long-running commands are not cut off by it. + _ = netConn.SetDeadline(time.Time{}) + client := ssh.NewClient(c, chans, reqs) + // Keep the TCP session alive through NAT/firewalls during long systemctl + // restarts and package upgrades. Best-effort; ignored if the server does not + // recognise the request. + go keepAlive(ctx, client) + return &conn{Client: client, seenHostKey: seen}, nil +} + +func keepAlive(ctx context.Context, client *ssh.Client) { + t := time.NewTicker(20 * time.Second) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + _, _, err := client.SendRequest("keepalive@openssh.com", true, nil) + if err != nil { + return + } + } + } } // hostKeysEqual compares two authorized_keys lines by their type+base64 body, @@ -128,7 +166,26 @@ func authMethods(t Target) ([]ssh.AuthMethod, error) { if t.Password == "" { return nil, fmt.Errorf("no password or private key configured") } - return []ssh.AuthMethod{ssh.Password(t.Password)}, nil + // Many cloud images and hardened OpenSSH configs offer only + // keyboard-interactive (or prefer it over "password"). Offering both + // methods is what OpenSSH clients do and is required for those hosts. + return []ssh.AuthMethod{ + ssh.Password(t.Password), + ssh.KeyboardInteractive(passwordKeyboardInteractive(t.Password)), + }, nil +} + +// passwordKeyboardInteractive answers every prompt with the stored password. +// Servers that ask a single "Password:" question work; multi-factor prompts +// that need a second factor will still fail (as they should without the factor). +func passwordKeyboardInteractive(password string) ssh.KeyboardInteractiveChallenge { + return func(_, _ string, questions []string, _ []bool) ([]string, error) { + answers := make([]string, len(questions)) + for i := range questions { + answers[i] = password + } + return answers, nil + } } // Run opens a connection, runs one command, and returns its combined output @@ -149,21 +206,41 @@ func runOn(ctx context.Context, client *ssh.Client, command string) (string, err if err != nil { return "", err } + // Closing the session is the reliable way to unblock CombinedOutput on + // cancel; Signal alone is frequently ignored without a PTY. defer sess.Close() - done := make(chan struct{}) - var out []byte - var runErr error + // Best-effort: keep remote tools from waiting on a pager. Setenv is often + // refused by the server (AcceptEnv); failures are ignored. + _ = sess.Setenv("SYSTEMD_PAGER", "cat") + _ = sess.Setenv("PAGER", "cat") + _ = sess.Setenv("SYSTEMD_COLORS", "0") + _ = sess.Setenv("GIT_PAGER", "cat") + + type result struct { + out []byte + err error + } + done := make(chan result, 1) go func() { - out, runErr = sess.CombinedOutput(command) - close(done) + out, err := sess.CombinedOutput(command) + done <- result{out: out, err: err} }() + select { case <-ctx.Done(): + // Signal first (best-effort), then close so CombinedOutput returns. _ = sess.Signal(ssh.SIGKILL) - return string(out), ctx.Err() - case <-done: - return string(out), runErr + _ = sess.Close() + res := <-done // wait so there is no race on the output buffer + partial := string(res.out) + cause := ctx.Err() + if cause == nil { + cause = ErrTimeout + } + return partial, fmt.Errorf("%w: %v", ErrTimeout, cause) + case res := <-done: + return string(res.out), res.err } } diff --git a/internal/vps/vps_test.go b/internal/vps/vps_test.go new file mode 100644 index 0000000..9286b4a --- /dev/null +++ b/internal/vps/vps_test.go @@ -0,0 +1,325 @@ +package vps + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "errors" + "fmt" + "io" + "net" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/pkg/sftp" + "golang.org/x/crypto/ssh" +) + +// testSSHServer is a minimal password-auth SSH server on loopback that either +// runs shell commands via "exec" requests or serves SFTP on "subsystem sftp". +type testSSHServer struct { + addr string + listener net.Listener + config *ssh.ServerConfig + wg sync.WaitGroup + closed chan struct{} + + // delayExec, when set, sleeps before responding to exec (simulates slow systemctl). + delayExec time.Duration + // execOutput is returned as stdout for any exec command. + execOutput string + // requireKbdInt forces keyboard-interactive instead of password. + requireKbdInt bool + // sftpHandlers is shared across sessions so upload then download sees the same fs. + sftpHandlers sftp.Handlers +} + +func newTestSSHServer(t *testing.T, opts ...func(*testSSHServer)) *testSSHServer { + t.Helper() + priv, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + signer, err := ssh.NewSignerFromKey(priv) + if err != nil { + t.Fatal(err) + } + + s := &testSSHServer{ + closed: make(chan struct{}), + execOutput: "ok\n", + sftpHandlers: sftp.InMemHandler(), + } + for _, o := range opts { + o(s) + } + + cfg := &ssh.ServerConfig{} + if s.requireKbdInt { + cfg.KeyboardInteractiveCallback = func(c ssh.ConnMetadata, challenge ssh.KeyboardInteractiveChallenge) (*ssh.Permissions, error) { + ans, err := challenge("user", "", []string{"Password: "}, []bool{false}) + if err != nil { + return nil, err + } + if len(ans) == 1 && ans[0] == "secret" { + return nil, nil + } + return nil, fmt.Errorf("bad password") + } + } else { + cfg.PasswordCallback = func(c ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) { + if string(pass) == "secret" { + return nil, nil + } + return nil, fmt.Errorf("bad password") + } + } + cfg.AddHostKey(signer) + s.config = cfg + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + s.listener = ln + s.addr = ln.Addr().String() + + s.wg.Add(1) + go func() { + defer s.wg.Done() + for { + conn, err := ln.Accept() + if err != nil { + select { + case <-s.closed: + return + default: + return + } + } + s.wg.Add(1) + go func(nc net.Conn) { + defer s.wg.Done() + s.handleConn(nc) + }(conn) + } + }() + + t.Cleanup(func() { + close(s.closed) + _ = ln.Close() + s.wg.Wait() + }) + return s +} + +func (s *testSSHServer) handleConn(nc net.Conn) { + sc, chans, reqs, err := ssh.NewServerConn(nc, s.config) + if err != nil { + _ = nc.Close() + return + } + defer sc.Close() + go ssh.DiscardRequests(reqs) + for newCh := range chans { + if newCh.ChannelType() != "session" { + _ = newCh.Reject(ssh.UnknownChannelType, "only session") + continue + } + ch, requests, err := newCh.Accept() + if err != nil { + continue + } + s.wg.Add(1) + go func() { + defer s.wg.Done() + s.handleSession(ch, requests) + }() + } +} + +func (s *testSSHServer) handleSession(ch ssh.Channel, requests <-chan *ssh.Request) { + defer ch.Close() + for req := range requests { + switch req.Type { + case "env": + if req.WantReply { + _ = req.Reply(true, nil) + } + case "exec": + if s.delayExec > 0 { + time.Sleep(s.delayExec) + } + _, _ = ch.Write([]byte(s.execOutput)) + if req.WantReply { + _ = req.Reply(true, nil) + } + _, _ = ch.SendRequest("exit-status", false, []byte{0, 0, 0, 0}) + return + case "subsystem": + name := "" + if len(req.Payload) >= 4 { + l := int(req.Payload[0])<<24 | int(req.Payload[1])<<16 | int(req.Payload[2])<<8 | int(req.Payload[3]) + if l > 0 && 4+l <= len(req.Payload) { + name = string(req.Payload[4 : 4+l]) + } + } + if name == "sftp" { + if req.WantReply { + _ = req.Reply(true, nil) + } + server := sftp.NewRequestServer(ch, s.sftpHandlers) + _ = server.Serve() + _ = server.Close() + return + } + if req.WantReply { + _ = req.Reply(false, nil) + } + return + default: + if req.WantReply { + _ = req.Reply(false, nil) + } + } + } +} + +func (s *testSSHServer) target() Target { + host, portStr, _ := net.SplitHostPort(s.addr) + port := 22 + fmt.Sscanf(portStr, "%d", &port) + return Target{ + Host: host, Port: port, Username: "test", + AuthMethod: "password", Password: "secret", + } +} + +func TestAuthMethodsIncludesKeyboardInteractive(t *testing.T) { + methods, err := authMethods(Target{AuthMethod: "password", Password: "x"}) + if err != nil { + t.Fatal(err) + } + if len(methods) < 2 { + t.Fatalf("want password + keyboard-interactive, got %d methods", len(methods)) + } +} + +func TestRunKeyboardInteractiveOnlyServer(t *testing.T) { + srv := newTestSSHServer(t, func(s *testSSHServer) { + s.requireKbdInt = true + s.execOutput = "whoami-ok\n" + }) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + out, _, err := Run(ctx, srv.target(), "whoami") + if err != nil { + t.Fatalf("Run with kbd-int server: %v", err) + } + if out != "whoami-ok\n" { + t.Fatalf("out = %q", out) + } +} + +func TestRunTimeoutReturnsErrTimeoutAndPartialSafe(t *testing.T) { + srv := newTestSSHServer(t, func(s *testSSHServer) { + s.delayExec = 2 * time.Second + s.execOutput = "never-seen\n" + }) + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + _, _, err := Run(ctx, srv.target(), "systemctl restart slow") + if err == nil { + t.Fatal("expected timeout error") + } + if !errors.Is(err, ErrTimeout) && !errors.Is(err, context.DeadlineExceeded) { + // ErrTimeout wraps the cause + if !errors.Is(err, ErrTimeout) { + t.Fatalf("err = %v, want ErrTimeout wrapper", err) + } + } + if !errors.Is(err, ErrTimeout) { + t.Fatalf("err = %v, want errors.Is ErrTimeout", err) + } +} + +func TestUploadDownloadRoundTrip(t *testing.T) { + srv := newTestSSHServer(t) + dir := t.TempDir() + local := filepath.Join(dir, "hello.txt") + if err := os.WriteFile(local, []byte("payload-bytes"), 0o644); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + n, _, err := Upload(ctx, srv.target(), local, "/data/hello.txt") + if err != nil { + t.Fatalf("upload: %v", err) + } + if n != int64(len("payload-bytes")) { + t.Fatalf("uploaded %d bytes", n) + } + + gotPath := filepath.Join(dir, "out.txt") + n2, _, err := Download(ctx, srv.target(), "/data/hello.txt", gotPath) + if err != nil { + t.Fatalf("download: %v", err) + } + if n2 != n { + t.Fatalf("downloaded %d, uploaded %d", n2, n) + } + body, err := os.ReadFile(gotPath) + if err != nil { + t.Fatal(err) + } + if string(body) != "payload-bytes" { + t.Fatalf("body = %q", body) + } +} + +func TestCleanRemotePath(t *testing.T) { + cases := map[string]string{ + "/etc/nginx/nginx.conf": "/etc/nginx/nginx.conf", + "app/config.yml": "app/config.yml", + "./x": "x", + "": "", + " /a/b ": "/a/b", + } + for in, want := range cases { + if got := cleanRemotePath(in); got != want { + t.Errorf("cleanRemotePath(%q) = %q, want %q", in, got, want) + } + } +} + +func TestCopyWithContextCancels(t *testing.T) { + pr, pw := io.Pipe() + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(50 * time.Millisecond) + cancel() + // keep writer open briefly + time.Sleep(100 * time.Millisecond) + _ = pw.Close() + }() + // Blocked reader: write nothing until cancel + var dst writeCounter + _, err := copyWithContext(ctx, &dst, pr) + if err == nil { + t.Fatal("expected cancel error") + } + if !errors.Is(err, ErrTimeout) { + t.Fatalf("err = %v", err) + } +} + +type writeCounter struct{ n int } + +func (w *writeCounter) Write(p []byte) (int, error) { + w.n += len(p) + return len(p), nil +} From 008076763fc08865409c1252f9dc0155698a335b Mon Sep 17 00:00:00 2001 From: Jihad Irfansyah Date: Sun, 2 Aug 2026 23:37:05 +0700 Subject: [PATCH 02/11] Fix CI go job when dashboard dist is not embedded //go:embed all:dist fails on a bare checkout because built assets are gitignored. Track a .gitkeep placeholder and recreate it in CI so test/vet compile without a frontend build. --- .github/workflows/ci.yml | 5 +++++ internal/server/dist/.gitkeep | 0 2 files changed, 5 insertions(+) create mode 100644 internal/server/dist/.gitkeep diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 063cfda..7ce529f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,11 @@ jobs: with: go-version-file: go.mod cache: true + # //go:embed all:dist requires at least one file; dashboard assets are + # gitignored and built separately. A tracked .gitkeep is the placeholder; + # recreate it here so a bare checkout never fails the embed. + - name: Ensure embed placeholder + run: mkdir -p internal/server/dist && touch internal/server/dist/.gitkeep - name: Test run: go test ./... - name: Vet diff --git a/internal/server/dist/.gitkeep b/internal/server/dist/.gitkeep new file mode 100644 index 0000000..e69de29 From 5b21a86b0ac9cf66eff879383d39065c3d8cd15c Mon Sep 17 00:00:00 2001 From: Jihad Irfansyah Date: Sun, 2 Aug 2026 23:53:32 +0700 Subject: [PATCH 03/11] Fix terminal hang when adb/ssh consume the shell stdin pipe Persistent-shell scripts keep stdin open for the next tool call. Children like `adb shell` inherit that pipe, steal the completion sentinel or block on it, and the tool waits until timeout even though remote work finished. Run each command in a brace group with stdin from /dev/null so the sentinel can fire immediately; brace group preserves cd/export across calls. --- internal/tools/shell.go | 11 ++++- internal/tools/shell_test.go | 86 ++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/internal/tools/shell.go b/internal/tools/shell.go index 81dd3c4..f1ac001 100644 --- a/internal/tools/shell.go +++ b/internal/tools/shell.go @@ -312,7 +312,16 @@ func (s *shellSession) run(ctx context.Context, command string, timeout time.Dur // A terminal call may enable errexit (`set -e`). Shell options persist just // like cwd and exported variables, but errexit must not leak into the next // call and kill the shell before its completion sentinel can run. - script := "set +e\n" + command + "\nprintf '\\n" + marker + "%s\\n' \"$?\"\n" + // + // The user command must not inherit this pipe as its stdin. The pipe stays + // open for the life of the session (so the next tool call can write another + // script). Commands such as `adb shell …` and `ssh host …` read stdin by + // default: they either block forever on the open pipe or consume the + // completion sentinel meant for the parent shell, so the tool hangs until + // timeout even though the remote work already finished. A brace group with + // ` time.Second { + t.Fatalf("took %s, want prompt completion well under 1s (sentinel not stolen)", elapsed) + } + + // Session still works after a stdin-hungry command. + out, code, err = sess.run(context.Background(), "printf STILL_ALIVE", 2*time.Second, nil) + if err != nil || code != 0 || out != "STILL_ALIVE" { + t.Fatalf("follow-up = (%q, %d, %v)", out, code, err) + } +} + +// cd/export must survive the stdin redirect wrap (brace group, not subshell). +func TestPersistentShellStdinWrapPreservesCdAndExport(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX persistent shell protocol does not apply on Windows") + } + m := NewShellManager(config.Terminal{}) + t.Cleanup(m.CloseAll) + ws := t.TempDir() + sub := ws + "/sub" + if err := os.Mkdir(sub, 0o755); err != nil { + t.Fatal(err) + } + sess, err := m.session("persist-session", ws) + if err != nil { + t.Fatal(err) + } + if _, code, err := sess.run(context.Background(), "cd sub && export ANTARES_TEST_FLAG=1", 2*time.Second, nil); err != nil || code != 0 { + t.Fatalf("cd/export: code=%d err=%v", code, err) + } + out, code, err := sess.run(context.Background(), "pwd; printf '%s' \"$ANTARES_TEST_FLAG\"", 2*time.Second, nil) + if err != nil || code != 0 { + t.Fatalf("follow-up: %v code=%d", err, code) + } + if !strings.Contains(out, "sub") || !strings.Contains(out, "1") { + t.Fatalf("cwd/export not preserved: %q", out) + } +} From 52f8394189e9fc7c780349a09f65752550ad76f0 Mon Sep 17 00:00:00 2001 From: Jihad Irfansyah Date: Mon, 3 Aug 2026 00:07:46 +0700 Subject: [PATCH 04/11] =?UTF-8?q?Fix=20silent=20multi-minute=20"Working?= =?UTF-8?q?=E2=80=A6"=20after=20restart=20and=20on=20long=20sessions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dashboard login sessions lived only in memory, so after antares restart the browser cookie looked valid but /api/chat/attach returned 401 in a 3s loop — live tool/stream updates never arrived. Persist sessions to disk and accept allowlisted ?token= in dashboard auth. The chat UI ignored EventNotice, so multi-minute context compaction showed only "Working… · Ns". Surface notices in the streaming indicator, and stream GET attach via fetch+credentials instead of EventSource for reliable cookies. --- internal/agent/compact.go | 9 ++- internal/server/dash_session_test.go | 80 +++++++++++++++++++++++++ internal/server/handlers_auth.go | 87 ++++++++++++++++++++++++---- internal/server/server.go | 3 + web/src/lib/api.ts | 72 ++++++++++++++++++++--- web/src/lib/i18n.tsx | 2 + web/src/pages/ChatPage.tsx | 55 ++++++++++++++---- 7 files changed, 274 insertions(+), 34 deletions(-) create mode 100644 internal/server/dash_session_test.go diff --git a/internal/agent/compact.go b/internal/agent/compact.go index 6953aa5..a91ff86 100644 --- a/internal/agent/compact.go +++ b/internal/agent/compact.go @@ -76,8 +76,13 @@ func (a *Agent) maybeCompact(ctx context.Context, history []llm.Message, system, return history } - if cfg.ProgressNotices { - _ = emit(Event{Type: EventNotice, Message: fmt.Sprintf("compacting %d older messages to free context", len(middle))}) + // Always surface compaction to the UI: on long sessions this LLM call can + // take tens of seconds and without a notice the dashboard only shows + // "Working… · Ns", which looks like a hang. progress_notices used to gate + // this; we still emit here because silent multi-minute work is worse. + if emit != nil { + _ = emit(Event{Type: EventNotice, Message: fmt.Sprintf( + "compacting %d older messages to free context (~%d tokens)", len(middle), used)}) } summary, err := a.summarise(ctx, middle) diff --git a/internal/server/dash_session_test.go b/internal/server/dash_session_test.go new file mode 100644 index 0000000..6e259d2 --- /dev/null +++ b/internal/server/dash_session_test.go @@ -0,0 +1,80 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/enowdev/antares/internal/config" +) + +func TestDashSessionsSurviveRestart(t *testing.T) { + home := t.TempDir() + t.Setenv("ANTARES_HOME", home) + if err := config.EnsureHome(); err != nil { + t.Fatal(err) + } + + cfg := config.Default() + hash, err := config.HashPassword("test-pass") + if err != nil { + t.Fatal(err) + } + cfg.Server.DashboardPasswordHash = hash + + s1 := New(Options{Config: cfg}) + // Simulate login. + tok := newSessionToken() + exp := time.Now().Add(time.Hour) + s1.dashMu.Lock() + s1.dashSessions[tok] = exp + s1.persistDashSessionsLocked() + s1.dashMu.Unlock() + + path := filepath.Join(home, "dash_sessions.json") + if _, err := os.Stat(path); err != nil { + t.Fatalf("session file not written: %v", err) + } + raw, _ := os.ReadFile(path) + var m map[string]int64 + if err := json.Unmarshal(raw, &m); err != nil || m[tok] == 0 { + t.Fatalf("file content = %s err=%v", raw, err) + } + + // New server process after restart: map empty until load. + s2 := New(Options{Config: cfg}) + req := httptest.NewRequest(http.MethodGet, "/api/sessions", nil) + req.AddCookie(&http.Cookie{Name: dashCookie, Value: tok}) + if !s2.dashSessionValid(req) { + t.Fatal("dashboard session not restored after restart — attach would 401") + } +} + +func TestInvalidateDashSessionsClearsFile(t *testing.T) { + home := t.TempDir() + t.Setenv("ANTARES_HOME", home) + _ = config.EnsureHome() + + s := New(Options{Config: config.Default()}) + s.dashMu.Lock() + s.dashSessions["tok"] = time.Now().Add(time.Hour) + s.persistDashSessionsLocked() + s.dashMu.Unlock() + + s.invalidateDashSessions() + raw, err := os.ReadFile(dashSessionsFile()) + if err != nil { + t.Fatal(err) + } + if string(raw) != "{}" && string(raw) != "null" { + var m map[string]int64 + _ = json.Unmarshal(raw, &m) + if len(m) != 0 { + t.Fatalf("expected empty sessions file, got %s", raw) + } + } +} diff --git a/internal/server/handlers_auth.go b/internal/server/handlers_auth.go index e7c1561..a109f4a 100644 --- a/internal/server/handlers_auth.go +++ b/internal/server/handlers_auth.go @@ -3,8 +3,11 @@ package server import ( "crypto/rand" "encoding/hex" + "encoding/json" "errors" + "log/slog" "net/http" + "os" "strings" "time" @@ -20,10 +23,11 @@ const dashSessionTTL = 30 * 24 * time.Hour // withDashboardAuth gates the web dashboard behind the login password when one // is configured. It is web-only: it never applies to the TUI or gateways // (those talk to the agent in-process, not over HTTP), and any client that -// presents the configured server.auth_token as a bearer bypasses it — so the -// CLI and scripted API callers keep working. Requests without a valid session -// cookie get 401 on /api/* (except the auth and health endpoints the login -// page itself needs), which the dashboard turns into a redirect to /login. +// presents the configured server.auth_token as a bearer (or, for EventSource +// allowlisted paths, ?token=) bypasses it — so the CLI and scripted API +// callers keep working. Requests without a valid session cookie get 401 on +// /api/* (except the auth and health endpoints the login page itself needs), +// which the dashboard turns into a redirect to /login. func (s *Server) withDashboardAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { cfg := s.config() @@ -49,13 +53,10 @@ func (s *Server) withDashboardAuth(next http.Handler) http.Handler { next.ServeHTTP(w, r) return } - // A valid bearer auth_token bypasses the dashboard login (CLI, scripts). - if tok := strings.TrimSpace(cfg.Server.AuthToken); tok != "" { - if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") && - strings.TrimSpace(strings.TrimPrefix(h, "Bearer ")) == tok { - next.ServeHTTP(w, r) - return - } + // Bearer auth_token (header or allowlisted ?token= for EventSource). + if s.bearerAuthorizedOrQuery(r) { + next.ServeHTTP(w, r) + return } if s.dashSessionValid(r) { next.ServeHTTP(w, r) @@ -143,8 +144,10 @@ func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) { } tok := newSessionToken() + exp := time.Now().Add(dashSessionTTL) s.dashMu.Lock() - s.dashSessions[tok] = time.Now().Add(dashSessionTTL) + s.dashSessions[tok] = exp + s.persistDashSessionsLocked() s.dashMu.Unlock() http.SetCookie(w, &http.Cookie{ @@ -164,6 +167,7 @@ func (s *Server) handleAuthLogout(w http.ResponseWriter, r *http.Request) { if c, err := r.Cookie(dashCookie); err == nil && c.Value != "" { s.dashMu.Lock() delete(s.dashSessions, c.Value) + s.persistDashSessionsLocked() s.dashMu.Unlock() } http.SetCookie(w, &http.Cookie{ @@ -237,6 +241,7 @@ func (s *Server) handleAuthSetPassword(w http.ResponseWriter, r *http.Request) { func (s *Server) invalidateDashSessions() { s.dashMu.Lock() s.dashSessions = map[string]time.Time{} + s.persistDashSessionsLocked() s.dashMu.Unlock() } @@ -245,3 +250,61 @@ func newSessionToken() string { _, _ = rand.Read(b) return hex.EncodeToString(b) } + +// dashSessionsFile is where login sessions are written so a daemon restart +// does not force every browser to re-login. Without this, EventSource +// reattach (/api/chat/attach) returns 401 after every restart while the +// cookie still looks valid, and the UI sits on "Working…" without live events. +func dashSessionsFile() string { + return config.Path("dash_sessions.json") +} + +// loadDashSessions restores sessions from disk (if any), dropping expired ones. +// Called once at server construction. +func (s *Server) loadDashSessions() { + path := dashSessionsFile() + data, err := os.ReadFile(path) + if err != nil { + return + } + var raw map[string]int64 + if err := json.Unmarshal(data, &raw); err != nil { + slog.Warn("dash sessions: corrupt file, ignoring", "path", path, "error", err) + return + } + now := time.Now() + out := make(map[string]time.Time, len(raw)) + for tok, expMS := range raw { + exp := time.UnixMilli(expMS) + if exp.After(now) && strings.TrimSpace(tok) != "" { + out[tok] = exp + } + } + s.dashMu.Lock() + s.dashSessions = out + // Rewrite if we pruned expired entries. + if len(out) != len(raw) { + s.persistDashSessionsLocked() + } + s.dashMu.Unlock() +} + +// persistDashSessionsLocked writes the in-memory map to disk. Caller must hold +// dashMu. Failures are logged and non-fatal — login still works in-process. +func (s *Server) persistDashSessionsLocked() { + raw := make(map[string]int64, len(s.dashSessions)) + now := time.Now() + for tok, exp := range s.dashSessions { + if exp.After(now) { + raw[tok] = exp.UnixMilli() + } + } + data, err := json.Marshal(raw) + if err != nil { + return + } + path := dashSessionsFile() + if err := os.WriteFile(path, data, 0o600); err != nil { + slog.Warn("dash sessions: could not persist", "path", path, "error", err) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 80ea482..a8a6566 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -101,6 +101,9 @@ func New(o Options) *Server { dashSessions: map[string]time.Time{}, } + // Restore dashboard logins so a daemon restart does not break EventSource + // reattach (/api/chat/attach) for browsers that still hold a valid cookie. + s.loadDashSessions() // Finished background sub-agents resume (or wake) the delegating session // instead of the main agent polling for them. if s.agent != nil { diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 3fa5ac6..ff97042 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -133,6 +133,7 @@ export function streamPost( try { const res = await fetch(`/api${path}`, { method: 'POST', + credentials: 'include', headers: { 'Content-Type': 'application/json', ...authHeaders() }, body: JSON.stringify(data), signal: controller.signal, @@ -182,22 +183,75 @@ export function streamPost( return () => controller.abort() } -/** Subscribe to a GET SSE endpoint (logs, events). */ +/** + * Subscribe to a GET SSE endpoint (attach, logs, swarm, …). + * + * Uses fetch (not EventSource) so we can send the dashboard session cookie + * (`credentials: 'include'`) and an Authorization header. EventSource cannot + * set headers and after a daemon restart used to 401-loop when only a stale + * in-memory login map existed — the cookie was valid but attach failed. + */ export function streamGet( path: string, onEvent: (event: StreamEvent) => void, onError?: (err: Error) => void, + onDone?: () => void, ): () => void { + const controller = new AbortController() const token = getToken() + // Keep ?token= for allowlisted stream paths when a bearer is configured; + // cookie auth alone is enough for password-locked dashboards. const url = `/api${path}${path.includes('?') ? '&' : '?'}${token ? `token=${encodeURIComponent(token)}` : ''}` - const source = new EventSource(url) - source.onmessage = (e) => { + + ;(async () => { try { - onEvent(JSON.parse(e.data) as StreamEvent) - } catch { - /* ignore */ + const res = await fetch(url, { + method: 'GET', + credentials: 'include', + headers: { ...authHeaders(), Accept: 'text/event-stream' }, + signal: controller.signal, + }) + if (!res.ok || !res.body) { + const text = await res.text().catch(() => '') + throw new ApiError(res.status, text || res.statusText) + } + + const reader = res.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + + for (;;) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + + let idx: number + while ((idx = buffer.indexOf('\n\n')) !== -1) { + const frame = buffer.slice(0, idx) + buffer = buffer.slice(idx + 2) + // Ignore SSE comments (keepalives: ": keepalive"). + const payload = frame + .split('\n') + .filter((l) => l.startsWith('data:')) + .map((l) => l.slice(5).replace(/^ /, '')) + .join('\n') + if (!payload || payload === '[DONE]') continue + try { + onEvent(JSON.parse(payload) as StreamEvent) + } catch { + /* ignore malformed frame */ + } + } + } + onDone?.() + } catch (err) { + if ((err as Error).name === 'AbortError') { + onDone?.() + return + } + onError?.(err as Error) } - } - source.onerror = () => onError?.(new Error('stream disconnected')) - return () => source.close() + })() + + return () => controller.abort() } diff --git a/web/src/lib/i18n.tsx b/web/src/lib/i18n.tsx index 23bba83..451ca5f 100644 --- a/web/src/lib/i18n.tsx +++ b/web/src/lib/i18n.tsx @@ -449,6 +449,7 @@ const en = { 'chat.nothingToCopy': 'There is no reply to copy yet.', 'chat.reasoning': 'Reasoning', 'chat.working': 'Working…', + 'chat.attachAuthFailed': 'Dashboard login expired — refresh and sign in again.', 'chat.waitingAnswer': 'Paused — waiting for your answer', 'chat.workingStep': 'Working… · step {n}', 'chat.running': 'Running {tool}…', @@ -1459,6 +1460,7 @@ const id: Dict = { 'chat.nothingToCopy': 'Belum ada balasan untuk disalin.', 'chat.reasoning': 'Penalaran', 'chat.working': 'Sedang bekerja…', + 'chat.attachAuthFailed': 'Login dashboard kedaluwarsa — muat ulang dan masuk lagi.', 'chat.waitingAnswer': 'Dijeda — menunggu jawabanmu', 'chat.workingStep': 'Sedang bekerja… · langkah {n}', 'chat.running': 'Menjalankan {tool}…', diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index ab5cd58..0d5639f 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -230,7 +230,13 @@ export default function ChatPage() { const [streaming, setStreaming] = useState(false) // Live status for the streaming indicator: which step, and what tool (if any) // is running right now. Reset at the start of every send. - const [live, setLive] = useState<{ turn: number; tool?: string; waiting?: boolean }>({ turn: 1 }) + const [live, setLive] = useState<{ + turn: number + tool?: string + waiting?: boolean + /** Server notice (compacting, steering, …) shown while streaming. */ + notice?: string + }>({ turn: 1 }) const [input, setInput] = useState('') const [error, setError] = useState() const [title, setTitle] = useState('') @@ -515,7 +521,11 @@ export default function ChatPage() { enqueueDelta(assistantId, 'reasoning', String(event.delta ?? '')) break case 'tool_call': - setLive((s) => ({ turn: s.turn + 1, tool: String(event.name ?? '') })) + setLive((s) => ({ + turn: s.turn + 1, + tool: String(event.name ?? ''), + notice: undefined, + })) patchAssistant((m) => pushToolSeg(m, { id: String(event.id ?? ''), @@ -550,11 +560,19 @@ export default function ChatPage() { })), ) break + case 'notice': + // Compaction, steering, retries, … — without this the UI only shows + // "Working… · Ns" during multi-minute silent server work. + setLive((s) => ({ + ...s, + notice: String(event.message ?? event.content ?? '').trim() || undefined, + })) + break case 'ask': // The turn is now paused inside ask_user. Remember the id so the // answer card can resume it; the stream stays open (no 'done'). setAskId(String(event.id ?? '')) - setLive((s) => ({ ...s, tool: undefined, waiting: true })) + setLive((s) => ({ ...s, tool: undefined, waiting: true, notice: undefined })) break case 'usage': patchAssistant((m) => ({ @@ -679,9 +697,15 @@ export default function ChatPage() { if (evtTitle) setTitle(evtTitle) }) }, - () => { + (err) => { setStreaming(false) close?.() + // Auth failure will not fix itself with a retry — stop the 3s 401 + // loop that filled the daemon log after every restart. + if (err instanceof ApiError && err.status === 401) { + setError(t('chat.attachAuthFailed') || 'Dashboard login expired — refresh and sign in again.') + return + } if (alive) window.setTimeout(connect, 3000) }, ) @@ -1362,7 +1386,12 @@ export default function ChatPage() { /> ))} {streaming ? ( - + ) : null} {error ? : null} @@ -1716,10 +1745,12 @@ export function StreamingIndicator({ turn, tool, waiting, + notice, }: { turn?: number tool?: string waiting?: boolean + notice?: string }) { const { t } = useI18n() const [secs, setSecs] = useState(0) @@ -1728,7 +1759,7 @@ export function StreamingIndicator({ const start = Date.now() const id = setInterval(() => setSecs(Math.round((Date.now() - start) / 1000)), 1000) return () => clearInterval(id) - }, [turn, tool, waiting]) + }, [turn, tool, waiting, notice]) // Paused on a question: no timer, no pulsing "working" — the run is idle by // design, waiting on the person. Otherwise show the running tool / step. if (waiting) { @@ -1741,9 +1772,11 @@ export function StreamingIndicator({ } const label = tool ? t('chat.running', { tool }) - : turn && turn > 1 - ? t('chat.workingStep', { n: turn }) - : t('chat.working') + : notice + ? notice + : turn && turn > 1 + ? t('chat.workingStep', { n: turn }) + : t('chat.working') return (
@@ -1751,8 +1784,8 @@ export function StreamingIndicator({ - {label} - · {secs}s + {label} + · {secs}s
) } From b4c8cccb902480b5a989f4c0200992bdb71be35e Mon Sep 17 00:00:00 2001 From: Jihad Irfansyah Date: Mon, 3 Aug 2026 00:42:58 +0700 Subject: [PATCH 05/11] Persist context compaction so long sessions stop re-summarising every turn Compaction ran on every turn for oversized sessions because the summary lived only in memory while loadHistory always reloaded all raw messages. Store the summary and through_seq on session meta; subsequent turns load head+summary+tail and only compact again when that view exceeds the threshold. Clear the meta on edit-message so history rewrites stay consistent. --- internal/agent/agent.go | 4 +- internal/agent/compact.go | 72 ++++++++++++++- internal/agent/compact_persist_test.go | 116 ++++++++++++++++++++++++ internal/agent/session.go | 119 +++++++++++++++++++++++-- internal/server/handlers_chat.go | 8 ++ 5 files changed, 308 insertions(+), 11 deletions(-) create mode 100644 internal/agent/compact_persist_test.go diff --git a/internal/agent/agent.go b/internal/agent/agent.go index ab91103..27b0e1b 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -310,7 +310,7 @@ func (a *Agent) Run(ctx context.Context, req Request, emit Emit) (*Result, error return nil, err } - history, err := a.loadHistory(ctx, sess.ID, req) + history, err := a.loadHistory(ctx, sess, req) if err != nil { return nil, err } @@ -407,7 +407,7 @@ func (a *Agent) Run(ctx context.Context, req Request, emit Emit) (*Result, error } } - history = a.maybeCompact(runCtx, history, systemPrompt, modelName, toolSpecs, emit) + history = a.maybeCompact(runCtx, history, systemPrompt, modelName, toolSpecs, emit, sess) llmReq := llm.Request{ Model: modelName, diff --git a/internal/agent/compact.go b/internal/agent/compact.go index a91ff86..e72d03c 100644 --- a/internal/agent/compact.go +++ b/internal/agent/compact.go @@ -9,6 +9,7 @@ import ( "github.com/enowdev/antares/internal/llm" "github.com/enowdev/antares/internal/providers" + "github.com/enowdev/antares/internal/store" ) // contextWindowFor returns the active model's token budget for the usage event. @@ -35,8 +36,10 @@ func (a *Agent) contextWindowFor(model string) int { } // maybeCompact summarises older turns once the conversation approaches the -// model's context window, keeping recent turns verbatim. -func (a *Agent) maybeCompact(ctx context.Context, history []llm.Message, system, model string, tools []llm.Tool, emit Emit) []llm.Message { +// model's context window, keeping recent turns verbatim. On success the +// summary is persisted on the session so the next turn does not re-run a +// multi-minute summarise over thousands of raw messages. +func (a *Agent) maybeCompact(ctx context.Context, history []llm.Message, system, model string, tools []llm.Tool, emit Emit, sess *store.Session) []llm.Message { cfg := a.cfg.Compression if !cfg.Enabled || len(history) < 8 { return history @@ -78,8 +81,7 @@ func (a *Agent) maybeCompact(ctx context.Context, history []llm.Message, system, // Always surface compaction to the UI: on long sessions this LLM call can // take tens of seconds and without a notice the dashboard only shows - // "Working… · Ns", which looks like a hang. progress_notices used to gate - // this; we still emit here because silent multi-minute work is worse. + // "Working… · Ns", which looks like a hang. if emit != nil { _ = emit(Event{Type: EventNotice, Message: fmt.Sprintf( "compacting %d older messages to free context (~%d tokens)", len(middle), used)}) @@ -101,10 +103,72 @@ func (a *Agent) maybeCompact(ctx context.Context, history []llm.Message, system, }) compacted = append(compacted, tail...) + // Persist so the next turn loads head+summary+tail instead of re-summarising. + if sess != nil && !isQuietSession(sess) { + a.persistContextCompact(ctx, sess, summary, protectFirst, protectLast) + } + slog.Info("context compacted", "before", len(history), "after", len(compacted), "tokens_before", used) return compacted } +// isQuietSession is true for ephemeral sub-agent sessions we never persist. +func isQuietSession(sess *store.Session) bool { + return sess == nil || sess.ID == "" +} + +// persistContextCompact records the summary and the highest seq it covers so +// loadHistory can rebuild the compacted view without another LLM call. +func (a *Agent) persistContextCompact(ctx context.Context, sess *store.Session, summary string, protectFirst, protectLast int) { + if a.db == nil || sess == nil { + return + } + rows, err := a.db.ListMessages(ctx, sess.ID, 0, 0) + if err != nil { + slog.Warn("persist compact: list messages failed", "error", err) + return + } + visible := make([]store.Message, 0, len(rows)) + for _, r := range rows { + if r.Hidden { + continue + } + visible = append(visible, r) + } + if len(visible) <= protectFirst+protectLast { + return + } + // Middle ends at the last message before the protected tail. + middleEnd := visible[len(visible)-protectLast-1] + throughSeq := middleEnd.Seq + + if sess.Meta == nil { + sess.Meta = store.Meta{} + } + // Reload session to avoid clobbering concurrent meta updates with a stale + // struct, then merge our key. + fresh, err := a.db.GetSession(ctx, sess.ID) + if err != nil { + slog.Warn("persist compact: get session failed", "error", err) + return + } + if fresh.Meta == nil { + fresh.Meta = store.Meta{} + } + fresh.Meta[contextCompactMetaKey] = map[string]any{ + "summary": summary, + "through_seq": throughSeq, + "keep_first": protectFirst, + } + if err := a.db.UpdateSession(ctx, fresh); err != nil { + slog.Warn("persist compact: update session failed", "error", err) + return + } + // Keep the in-memory session in sync for the rest of this turn. + sess.Meta = fresh.Meta + slog.Info("context compact persisted", "session", sess.ID, "through_seq", throughSeq, "keep_first", protectFirst) +} + // estimateRequestTokens includes tool schemas as well as system/history. Large // agent tool packs are sent on every call and can consume a material part of the // context window; omitting them delays compaction until the provider rejects the diff --git a/internal/agent/compact_persist_test.go b/internal/agent/compact_persist_test.go new file mode 100644 index 0000000..098a340 --- /dev/null +++ b/internal/agent/compact_persist_test.go @@ -0,0 +1,116 @@ +package agent + +import ( + "context" + "strings" + "testing" + + "github.com/enowdev/antares/internal/config" + "github.com/enowdev/antares/internal/llm" + "github.com/enowdev/antares/internal/store" +) + +// stubStore is a minimal store for compact persistence tests. +type compactMemStore struct { + store.Store + sess *store.Session + msgs []store.Message + seq int64 +} + +func (m *compactMemStore) GetSession(ctx context.Context, id string) (*store.Session, error) { + if m.sess == nil || m.sess.ID != id { + return nil, store.ErrNotFound + } + // return a copy so UpdateSession mutations are visible on re-get after we reassign + cp := *m.sess + if m.sess.Meta != nil { + cp.Meta = store.Meta{} + for k, v := range m.sess.Meta { + cp.Meta[k] = v + } + } + return &cp, nil +} + +func (m *compactMemStore) UpdateSession(ctx context.Context, sess *store.Session) error { + m.sess = sess + return nil +} + +func (m *compactMemStore) ListMessages(ctx context.Context, sessionID string, limit, offset int) ([]store.Message, error) { + out := make([]store.Message, len(m.msgs)) + copy(out, m.msgs) + return out, nil +} + +func (m *compactMemStore) AppendMessage(ctx context.Context, msg *store.Message) error { + m.seq++ + msg.Seq = m.seq + m.msgs = append(m.msgs, *msg) + return nil +} + +func TestLoadHistoryAppliesPersistedCompact(t *testing.T) { + sess := &store.Session{ID: "s1", Meta: store.Meta{ + contextCompactMetaKey: map[string]any{ + "summary": "We fixed the skin bug and built the APK.", + "through_seq": int64(5), + "keep_first": 1, + }, + }} + db := &compactMemStore{sess: sess, msgs: []store.Message{ + {ID: "1", SessionID: "s1", Seq: 1, Role: store.RoleUser, Content: "hello"}, + {ID: "2", SessionID: "s1", Seq: 2, Role: store.RoleAssistant, Content: "hi"}, + {ID: "3", SessionID: "s1", Seq: 3, Role: store.RoleUser, Content: "do work"}, + {ID: "4", SessionID: "s1", Seq: 4, Role: store.RoleAssistant, Content: "working"}, + {ID: "5", SessionID: "s1", Seq: 5, Role: store.RoleTool, Content: "huge tool output " + strings.Repeat("x", 1000), ToolName: "terminal", ToolCallID: "t1"}, + {ID: "6", SessionID: "s1", Seq: 6, Role: store.RoleUser, Content: "continue"}, + {ID: "7", SessionID: "s1", Seq: 7, Role: store.RoleAssistant, Content: "ok"}, + }} + a := &Agent{cfg: config.Default(), db: db} + hist, err := a.loadHistory(context.Background(), sess, Request{}) + if err != nil { + t.Fatal(err) + } + // head(1) + summary + tail(seq>5) = continue + ok + if len(hist) < 3 { + t.Fatalf("history len=%d, want at least 3 (head+summary+tail)", len(hist)) + } + if hist[0].Content != "hello" { + t.Fatalf("head = %q", hist[0].Content) + } + if !strings.Contains(hist[1].Content, "We fixed the skin bug") { + t.Fatalf("summary missing: %q", hist[1].Content) + } + // Must NOT include the huge tool output (seq 5 covered by compact) + for _, m := range hist { + if strings.Contains(m.Content, "huge tool output") { + t.Fatal("compacted middle still present in history") + } + } + joined := "" + for _, m := range hist { + joined += m.Content + } + if !strings.Contains(joined, "continue") { + t.Fatalf("tail missing: %#v", hist) + } +} + +func TestMaybeCompactSkipsWhenUnderThreshold(t *testing.T) { + a := &Agent{cfg: config.Default()} + a.cfg.Model.ContextWindow = 200000 + hist := []llm.Message{ + {Role: llm.RoleUser, Content: "hi"}, + {Role: llm.RoleAssistant, Content: "hello"}, + } + // pad a bit but stay under 80% of 200k + for i := 0; i < 10; i++ { + hist = append(hist, llm.Message{Role: llm.RoleUser, Content: "x"}) + } + out := a.maybeCompact(context.Background(), hist, "sys", "m", nil, nil, nil) + if len(out) != len(hist) { + t.Fatalf("should not compact small history") + } +} diff --git a/internal/agent/session.go b/internal/agent/session.go index 5821450..bdadd56 100644 --- a/internal/agent/session.go +++ b/internal/agent/session.go @@ -74,20 +74,54 @@ func defaultTitle(msg string) string { return msg } +// contextCompactMetaKey is stored on session.Meta after a successful +// compaction. Subsequent turns rebuild history as head + summary + tail so +// we do not re-summarise thousands of messages on every turn. +const contextCompactMetaKey = "context_compact" + // loadHistory rebuilds the model-facing message list from storage. -func (a *Agent) loadHistory(ctx context.Context, sessionID string, req Request) ([]llm.Message, error) { +// When a prior compaction was persisted on the session, messages with +// seq ≤ through_seq (except the first keep_first) are replaced by the stored +// summary — matching what maybeCompact produced in memory. +func (a *Agent) loadHistory(ctx context.Context, sess *store.Session, req Request) ([]llm.Message, error) { if req.Quiet { return nil, nil } - rows, err := a.db.ListMessages(ctx, sessionID, 0, 0) + rows, err := a.db.ListMessages(ctx, sess.ID, 0, 0) if err != nil { return nil, err } - out := make([]llm.Message, 0, len(rows)) + + // Optional persisted compaction (written by maybeCompact). + var ( + summary string + throughSeq int64 + keepFirst int + hasCompact bool + ) + if sess != nil && sess.Meta != nil { + if raw, ok := sess.Meta[contextCompactMetaKey]; ok { + if m, ok := raw.(map[string]any); ok { + summary, _ = m["summary"].(string) + throughSeq = metaInt64(m["through_seq"]) + keepFirst = int(metaInt64(m["keep_first"])) + hasCompact = summary != "" && throughSeq > 0 + if keepFirst <= 0 { + keepFirst = 1 + } + } + } + } + + visible := make([]store.Message, 0, len(rows)) for _, r := range rows { - if r.Hidden { + if r.Hidden || r.Compacted { continue } + visible = append(visible, r) + } + + toLLM := func(r store.Message) (llm.Message, bool) { m := llm.Message{Content: r.Content, Reasoning: r.Reasoning} switch r.Role { case store.RoleUser: @@ -111,13 +145,88 @@ func (a *Agent) loadHistory(ctx context.Context, sessionID string, req Request) m.ToolCallID = r.ToolCallID m.Name = r.ToolName default: + return llm.Message{}, false + } + return m, true + } + + if !hasCompact { + out := make([]llm.Message, 0, len(visible)) + for _, r := range visible { + if m, ok := toLLM(r); ok { + out = append(out, m) + } + } + return out, nil + } + + // head: first keepFirst visible messages (by order, regardless of seq holes) + headN := keepFirst + if headN > len(visible) { + headN = len(visible) + } + out := make([]llm.Message, 0, headN+1+len(visible)) + for i := 0; i < headN; i++ { + if m, ok := toLLM(visible[i]); ok { + out = append(out, m) + } + } + out = append(out, llm.Message{ + Role: llm.RoleUser, + Content: "[Compacted summary of the earlier conversation]\n\n" + summary + + "\n\n[Continue from here. This summary replaces the older messages.]", + }) + // tail: everything after the last seq covered by the summary + for _, r := range visible { + if r.Seq <= throughSeq { + continue + } + // Skip rows already included in head (keep_first may overlap low seqs) + if headN > 0 && r.Seq <= visible[headN-1].Seq { continue } - out = append(out, m) + if m, ok := toLLM(r); ok { + out = append(out, m) + } } return out, nil } +func metaInt64(v any) int64 { + switch n := v.(type) { + case float64: + return int64(n) + case int64: + return n + case int: + return int64(n) + case json.Number: + i, _ := n.Int64() + return i + default: + return 0 + } +} + +// clearContextCompact drops a persisted summary (e.g. after edit-message +// rewrites history so the summary would be stale). +func (a *Agent) clearContextCompact(ctx context.Context, sessionID string) { + if a.db == nil || sessionID == "" { + return + } + sess, err := a.db.GetSession(ctx, sessionID) + if err != nil || sess.Meta == nil { + return + } + if _, ok := sess.Meta[contextCompactMetaKey]; !ok { + return + } + delete(sess.Meta, contextCompactMetaKey) + if err := a.db.UpdateSession(ctx, sess); err != nil { + slog.Warn("clear context compact failed", "session", sessionID, "error", err) + } +} + // persistAssistant stores an assistant turn including any tool calls. func (a *Agent) persistAssistant(ctx context.Context, sessionID, model string, resp *llm.Response) { toolCalls := "" diff --git a/internal/server/handlers_chat.go b/internal/server/handlers_chat.go index ebef935..d8649ad 100644 --- a/internal/server/handlers_chat.go +++ b/internal/server/handlers_chat.go @@ -407,6 +407,14 @@ func (s *Server) handleEditMessage(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, err) return } + // Drop any persisted context summary — history was rewritten and the + // old through_seq would hide live messages or re-apply a stale summary. + if sess, err := s.db.GetSession(r.Context(), sessionID); err == nil && sess.Meta != nil { + if _, ok := sess.Meta["context_compact"]; ok { + delete(sess.Meta, "context_compact") + _ = s.db.UpdateSession(r.Context(), sess) + } + } writeJSON(w, http.StatusOK, map[string]any{ "ok": true, "reverted": reverted, From 3f859a58efdf802254f677c939b8b8fd0d8185f1 Mon Sep 17 00:00:00 2001 From: Jihad Irfansyah Date: Mon, 3 Aug 2026 00:46:14 +0700 Subject: [PATCH 06/11] Harness VPS transfers so agents prefer vps_upload/download over rsync Tools existed but system prompt never steered models away from terminal rsync/scp. Add tool-notes guidance whenever the VPS transfer tools are active, and tighten tool descriptions plus the vps-manager skill/role. --- internal/agent/prompt.go | 5 +++++ internal/hub/catalog/skills/vps-manager.md | 5 +++++ internal/roles/catalog/vps-manager.md | 11 +++++++---- internal/tools/vps.go | 10 ++++++---- 4 files changed, 23 insertions(+), 8 deletions(-) diff --git a/internal/agent/prompt.go b/internal/agent/prompt.go index a971e5f..94cb1d1 100644 --- a/internal/agent/prompt.go +++ b/internal/agent/prompt.go @@ -93,6 +93,11 @@ help them now — do not block them. // the exact match fails repeatedly. b.WriteString("- read_file returns lines as `NUMBER|CONTENT`. The `|` is metadata only. When calling edit_file, copy **only** the content after `|` into old_string/new_string — never the line number. Preserve tabs and spaces exactly (do not expand tabs to spaces). Line endings are matched automatically.\n") } + if hasTool(active, "vps_upload") || hasTool(active, "vps_download") || hasTool(active, "vps_run") { + // Without this, models fall back to terminal rsync/scp and never use + // the saved-host SFTP tools (credentials and TOFU stay unused). + b.WriteString("- VPS file transfer: use **vps_upload** (local → server) and **vps_download** (server → local) over SFTP on dashboard-saved hosts. Do **not** use terminal `rsync`, `scp`, or `sftp` CLI for those hosts when these tools are available — they already hold the SSH credentials. Use **vps_run** for remote shell commands (systemctl, logs, apt). Call vps_run with no command first to list server ids/labels. Single files only for upload/download (max 256 MiB); for huge trees say so and use vps_run only if the user explicitly wants remote-side pull/rsync.\n") + } b.WriteString("- The terminal keeps state between calls: `cd`, exports, and activated environments persist.\n") if hasTool(active, "memory") && cfg.Memory.Enabled { b.WriteString("- Save durable facts about the user or project with the memory tool. Save only what stays true across sessions.\n") diff --git a/internal/hub/catalog/skills/vps-manager.md b/internal/hub/catalog/skills/vps-manager.md index c61bd57..392dbc6 100644 --- a/internal/hub/catalog/skills/vps-manager.md +++ b/internal/hub/catalog/skills/vps-manager.md @@ -62,6 +62,11 @@ Once you know the state, operate deliberately: ## Rules - **Only servers the user owns.** These are their machines, added on purpose. +- **Files go through SFTP tools.** For copy to/from a saved host, use + `vps_upload` / `vps_download` — never `rsync`, `scp`, or interactive `sftp` in + the terminal for those hosts (credentials and host-key pinning live in the + tools). Use `vps_run` only for remote shell work, or if the user explicitly + wants a remote-side pull of a huge tree. - **Read before write.** Never restart, delete, or upgrade without first showing what you found and, for anything risky, saying what you're about to do. - **Destructive commands need care.** `rm -rf`, `mkfs`, `dd`, dropping a diff --git a/internal/roles/catalog/vps-manager.md b/internal/roles/catalog/vps-manager.md index cb69227..b349310 100644 --- a/internal/roles/catalog/vps-manager.md +++ b/internal/roles/catalog/vps-manager.md @@ -13,10 +13,13 @@ fix what's broken — carefully, on machines the user owns. You reach a server with **`vps_run`** (SSH command), **`vps_upload`** and **`vps_download`** (SFTP file copy). There is no agent on the box — just -ordinary SSH/SFTP. The dashboard's VPS page shows CPU/RAM/disk/uptime and a -process list at a glance; use the tools when you need something specific, need -to change something, or need to move files. Default command timeout is 120s — -raise `timeout_seconds` for `systemctl restart` and package upgrades (max 900). +ordinary SSH/SFTP. Prefer **vps_upload / vps_download** for any file copy to or +from a saved host; do not fall back to terminal `rsync`/`scp` unless the user +explicitly asks for a bulk remote-side transfer. The dashboard's VPS page shows +CPU/RAM/disk/uptime and a process list at a glance; use the tools when you need +something specific, need to change something, or need to move files. Default +command timeout is 120s — raise `timeout_seconds` for `systemctl restart` and +package upgrades (max 900). ## Work the problem, don't guess diff --git a/internal/tools/vps.go b/internal/tools/vps.go index b40fc6e..54af544 100644 --- a/internal/tools/vps.go +++ b/internal/tools/vps.go @@ -184,10 +184,11 @@ type vpsUploadTool struct{} func (vpsUploadTool) Name() string { return "vps_upload" } func (vpsUploadTool) Description() string { - return "Upload a local file to a saved VPS over SFTP. " + + return "Upload a local workspace file to a dashboard-saved VPS over SFTP (preferred over rsync/scp/terminal). " + "`local_path` is relative to the workspace (or absolute inside write roots); " + "`remote_path` is the destination on the server. Creates remote parent dirs. " + - "Max 256 MiB. Call vps_run with no command first if you need the server id/label." + "Max 256 MiB, one file per call. Call vps_run with no command first if you need the server id/label. " + + "Do not use terminal rsync/scp for saved VPS hosts when this tool is available." } func (vpsUploadTool) Schema() map[string]any { return schema(map[string]any{ @@ -254,9 +255,10 @@ type vpsDownloadTool struct{} func (vpsDownloadTool) Name() string { return "vps_download" } func (vpsDownloadTool) Description() string { - return "Download a file from a saved VPS over SFTP into the local workspace. " + + return "Download a file from a dashboard-saved VPS over SFTP into the local workspace (preferred over rsync/scp/terminal). " + "`remote_path` is on the server; `local_path` is the destination (workspace-relative). " + - "Creates local parent dirs. Max 256 MiB." + "Creates local parent dirs. Max 256 MiB, one file per call. " + + "Do not use terminal rsync/scp for saved VPS hosts when this tool is available." } func (vpsDownloadTool) Schema() map[string]any { return schema(map[string]any{ From bf3ee7cf6e27714692fb08ef8cd0534068f67a70 Mon Sep 17 00:00:00 2001 From: Jihad Irfansyah Date: Mon, 3 Aug 2026 00:51:08 +0700 Subject: [PATCH 07/11] Add composer prompt history with arrow up/down on the web UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arrow keys previously only navigated the slash-command palette. Store recent sends in localStorage and recall them with ↑/↓ when the caret is on the first/last line, matching shell-style prompt history without breaking multi-line editing. --- web/src/pages/ChatPage.tsx | 102 ++++++++++++++++++++++++++++++++++++- 1 file changed, 101 insertions(+), 1 deletion(-) diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 0d5639f..1b21177 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -219,6 +219,42 @@ const SUGGESTION_KEYS: MessageKey[] = [ 'chat.suggest4', ] +/** Composer ↑/↓ recall — most recent first, de-duped consecutive, capped. */ +const INPUT_HISTORY_KEY = 'antares:composer-history' +const INPUT_HISTORY_MAX = 50 + +function loadInputHistory(): string[] { + try { + const raw = localStorage.getItem(INPUT_HISTORY_KEY) + if (!raw) return [] + const parsed = JSON.parse(raw) as unknown + if (!Array.isArray(parsed)) return [] + return parsed.filter((x): x is string => typeof x === 'string' && x.trim() !== '').slice(0, INPUT_HISTORY_MAX) + } catch { + return [] + } +} + +function pushInputHistory(entry: string, prev: string[]): string[] { + const text = entry.trim() + if (!text) return prev + // Drop consecutive duplicate of the most recent entry. + const next = prev[0] === text ? prev : [text, ...prev.filter((x) => x !== text)] + return next.slice(0, INPUT_HISTORY_MAX) +} + +/** Caret is on the first visual line of a textarea (for shell-style history ↑). */ +function caretOnFirstLine(el: HTMLTextAreaElement): boolean { + const pos = el.selectionStart ?? 0 + return !el.value.slice(0, pos).includes('\n') +} + +/** Caret is on the last visual line (for history ↓). */ +function caretOnLastLine(el: HTMLTextAreaElement): boolean { + const pos = el.selectionStart ?? 0 + return !el.value.slice(pos).includes('\n') +} + export default function ChatPage() { const { sessionId } = useParams<{ sessionId: string }>() const navigate = useNavigate() @@ -238,6 +274,12 @@ export default function ChatPage() { notice?: string }>({ turn: 1 }) const [input, setInput] = useState('') + // Recent composer prompts (shell-style ↑/↓). Persisted across reloads. + const [inputHistory, setInputHistory] = useState(() => loadInputHistory()) + // -1 = editing a live draft (not browsing history). ≥0 = index into inputHistory + // from the end (0 = most recent). + const [historyPos, setHistoryPos] = useState(-1) + const draftRef = useRef('') // draft saved when first leaving with ↑ const [error, setError] = useState() const [title, setTitle] = useState('') const [approvals, setApprovals] = useState([]) @@ -873,7 +915,18 @@ export default function ChatPage() { const text = raw.trim() if ((!text && attached.length === 0 && attachedDocs.length === 0) || streaming) return if (text.startsWith('/') && text.length > 1) { + // Still record slash commands so ↑ recalls them. + if (text) { + setInputHistory((prev) => { + const next = pushInputHistory(text, prev) + localStorage.setItem(INPUT_HISTORY_KEY, JSON.stringify(next)) + return next + }) + } + setHistoryPos(-1) + draftRef.current = '' void runCommand(text) + setInput('') return } @@ -894,6 +947,16 @@ export default function ChatPage() { } const assistantId = `local_${Date.now()}_a` setMessages((prev) => [...prev, userMsg, { id: assistantId, role: 'assistant', content: '' }]) + // Remember what was sent for ↑/↓ (composer history). + if (text) { + setInputHistory((prev) => { + const next = pushInputHistory(text, prev) + localStorage.setItem(INPUT_HISTORY_KEY, JSON.stringify(next)) + return next + }) + } + setHistoryPos(-1) + draftRef.current = '' setInput('') setImages([]) setDocs([]) @@ -1138,12 +1201,49 @@ export default function ChatPage() { } } } + // Shell-style prompt history: ↑ older, ↓ newer. Only when the caret is on + // the first/last line so multi-line editing still moves the cursor normally. + if (e.key === 'ArrowUp' && !e.shiftKey && !e.altKey && !e.metaKey && !e.ctrlKey) { + const el = e.currentTarget + if (inputHistory.length > 0 && caretOnFirstLine(el)) { + e.preventDefault() + if (historyPos === -1) draftRef.current = input + const idx = historyPos === -1 ? 0 : Math.min(historyPos + 1, inputHistory.length - 1) + setHistoryPos(idx) + setInput(inputHistory[idx] ?? '') + return + } + } + if (e.key === 'ArrowDown' && !e.shiftKey && !e.altKey && !e.metaKey && !e.ctrlKey) { + const el = e.currentTarget + if (historyPos >= 0 && caretOnLastLine(el)) { + e.preventDefault() + if (historyPos <= 0) { + setHistoryPos(-1) + setInput(draftRef.current) + } else { + const idx = historyPos - 1 + setHistoryPos(idx) + setInput(inputHistory[idx] ?? '') + } + return + } + } if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault() send() } } + // Typing while browsing history leaves history mode (treat as new draft). + const onInputChange = useCallback((value: string) => { + if (historyPos !== -1) { + setHistoryPos(-1) + draftRef.current = '' + } + setInput(value) + }, [historyPos]) + const newChat = () => { stop() localStorage.removeItem('antares:last-session') @@ -1175,7 +1275,7 @@ export default function ChatPage() { onRemoveImage={(i) => setImages((prev) => prev.filter((_, x) => x !== i))} onRemoveDoc={(i) => setDocs((prev) => prev.filter((_, x) => x !== i))} onPaste={onPaste} - onChange={setInput} + onChange={onInputChange} onKeyDown={onKeyDown} onSend={send} onStop={stop} From c463b3548276e737850fdd67c64a4293b2bc413b Mon Sep 17 00:00:00 2001 From: Jihad Irfansyah Date: Mon, 3 Aug 2026 16:04:31 +0700 Subject: [PATCH 08/11] Prevent MCP page crash on empty tool lists Re-apply the missing fix: Go's append(nil, empty...) encodes tools as JSON null, and the MCP page called .length on it. Return a non-nil empty slice and normalize tools on the client with mcpToolsOrEmpty. --- internal/mcp/client.go | 2 +- internal/mcp/client_test.go | 17 +++++++++++++++++ web/src/lib/mcpPayload.test.mjs | 15 +++++++++++++++ web/src/lib/mcpPayload.ts | 8 ++++++++ web/src/pages/McpPage.tsx | 8 ++++++-- 5 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 web/src/lib/mcpPayload.test.mjs create mode 100644 web/src/lib/mcpPayload.ts diff --git a/internal/mcp/client.go b/internal/mcp/client.go index edce43c..3c3ad98 100644 --- a/internal/mcp/client.go +++ b/internal/mcp/client.go @@ -205,7 +205,7 @@ func (c *Client) refreshTools(ctx context.Context) error { func (c *Client) Tools() []ToolDef { c.mu.RLock() defer c.mu.RUnlock() - return append([]ToolDef(nil), c.tools...) + return append([]ToolDef{}, c.tools...) } // Name returns the configured server name. diff --git a/internal/mcp/client_test.go b/internal/mcp/client_test.go index aa2ccd2..7b73ceb 100644 --- a/internal/mcp/client_test.go +++ b/internal/mcp/client_test.go @@ -68,6 +68,23 @@ func TestStdioReportsToolErrors(t *testing.T) { } } +func TestEmptyToolListEncodesAsArray(t *testing.T) { + client := &Client{} + got := client.Tools() + if got == nil { + t.Fatal("Tools() returned nil, want a non-nil empty slice") + } + payload, err := json.Marshal(struct { + Tools []ToolDef `json:"tools"` + }{Tools: got}) + if err != nil { + t.Fatal(err) + } + if string(payload) != `{"tools":[]}` { + t.Fatalf("empty tool payload = %s, want tools encoded as []", payload) + } +} + func TestUnknownTransport(t *testing.T) { if _, err := Connect(context.Background(), "x", ServerConfig{Transport: "carrier-pigeon"}); err == nil { t.Fatal("expected an unknown transport to fail") diff --git a/web/src/lib/mcpPayload.test.mjs b/web/src/lib/mcpPayload.test.mjs new file mode 100644 index 0000000..fac4ec9 --- /dev/null +++ b/web/src/lib/mcpPayload.test.mjs @@ -0,0 +1,15 @@ +import { describe, expect, test } from 'bun:test' +import { mcpToolsOrEmpty } from './mcpPayload.ts' + +describe('MCP payload normalization', () => { + test('turns null or missing tools into an empty array', () => { + expect(mcpToolsOrEmpty(null)).toEqual([]) + expect(mcpToolsOrEmpty(undefined)).toEqual([]) + expect(mcpToolsOrEmpty(null).length).toBe(0) + }) + + test('keeps valid tool arrays', () => { + const tools = [{ name: 'server_health', description: 'Read server health' }] + expect(mcpToolsOrEmpty(tools)).toBe(tools) + }) +}) diff --git a/web/src/lib/mcpPayload.ts b/web/src/lib/mcpPayload.ts new file mode 100644 index 0000000..9b637a5 --- /dev/null +++ b/web/src/lib/mcpPayload.ts @@ -0,0 +1,8 @@ +export interface McpPayloadTool { + name: string + description: string +} + +export function mcpToolsOrEmpty(value: T[] | null | undefined): T[] { + return Array.isArray(value) ? value : [] +} diff --git a/web/src/pages/McpPage.tsx b/web/src/pages/McpPage.tsx index 702dfff..3fd059e 100644 --- a/web/src/pages/McpPage.tsx +++ b/web/src/pages/McpPage.tsx @@ -11,6 +11,7 @@ import { import { del, post } from '@/lib/api' import { useApi } from '@/lib/hooks' import { useI18n } from '@/lib/i18n' +import { mcpToolsOrEmpty } from '@/lib/mcpPayload' import { cn } from '@/lib/utils' import { PageLayout } from '@/components/layout/PageLayout' import { @@ -51,7 +52,7 @@ interface McpServer { name: string connected: boolean error?: string - tools: McpTool[] + tools: McpTool[] | null } export default function McpPage() { @@ -93,7 +94,10 @@ export default function McpPage() { if (loading && !data) return - const servers = data?.servers ?? [] + const servers = (data?.servers ?? []).map((server) => ({ + ...server, + tools: mcpToolsOrEmpty(server.tools), + })) const header = ( setTab(v as 'servers' | 'docs')}> From 609d7eea585dbfc2e57525ad2ede1733a0c0c0ef Mon Sep 17 00:00:00 2001 From: Jihad Irfansyah Date: Sat, 1 Aug 2026 14:34:46 +0700 Subject: [PATCH 09/11] Refresh MCP tools and report backing app readiness --- internal/mcp/client.go | 28 ++++-- internal/mcp/client_test.go | 69 +++++++++++++++ internal/mcp/manager.go | 111 +++++++++++++++++++----- internal/server/handlers_subsystems.go | 26 ++++++ internal/server/mcp_refresh_test.go | 66 ++++++++++++++ internal/server/routes.go | 1 + internal/tools/registry.go | 20 +++++ internal/tools/registry_refresh_test.go | 30 +++++++ web/src/lib/i18n.tsx | 15 ++++ web/src/pages/McpPage.tsx | 45 +++++++++- 10 files changed, 379 insertions(+), 32 deletions(-) create mode 100644 internal/server/mcp_refresh_test.go create mode 100644 internal/tools/registry_refresh_test.go diff --git a/internal/mcp/client.go b/internal/mcp/client.go index 3c3ad98..0530c85 100644 --- a/internal/mcp/client.go +++ b/internal/mcp/client.go @@ -83,10 +83,12 @@ type Client struct { name string transport transport - mu sync.RWMutex - tools []ToolDef - seq int64 - srvName string + mu sync.RWMutex + tools []ToolDef + seq int64 + srvName string + toolsOK bool + toolsErr string } // Connect starts a server and performs the initialise handshake. @@ -113,6 +115,11 @@ func Connect(ctx context.Context, name string, cfg ServerConfig) (*Client, error return nil, err } if err := c.refreshTools(ctx); err != nil { + c.mu.Lock() + c.tools = []ToolDef{} + c.toolsOK = false + c.toolsErr = err.Error() + c.mu.Unlock() slog.Warn("mcp: cannot list tools", "server", name, "error", err) } return c, nil @@ -196,11 +203,22 @@ func (c *Client) refreshTools(ctx context.Context) error { return err } c.mu.Lock() - c.tools = out.Tools + c.tools = append([]ToolDef{}, out.Tools...) + c.toolsOK = true + c.toolsErr = "" c.mu.Unlock() return nil } +// ToolState returns whether tool discovery reached the backing application and +// the last discovery error. A proxy can initialize successfully while its +// backing application (for example IDA Pro) is still offline. +func (c *Client) ToolState() (bool, string) { + c.mu.RLock() + defer c.mu.RUnlock() + return c.toolsOK, c.toolsErr +} + // Tools returns the cached tool list. func (c *Client) Tools() []ToolDef { c.mu.RLock() diff --git a/internal/mcp/client_test.go b/internal/mcp/client_test.go index 7b73ceb..70e417e 100644 --- a/internal/mcp/client_test.go +++ b/internal/mcp/client_test.go @@ -8,6 +8,9 @@ import ( "os/exec" "testing" "time" + + "github.com/enowdev/antares/internal/config" + "github.com/enowdev/antares/internal/tools" ) // TestStdioRoundTrip runs this test binary as a fake MCP server (see @@ -85,6 +88,62 @@ func TestEmptyToolListEncodesAsArray(t *testing.T) { } } +func TestRefreshReplacesToolsAndReadiness(t *testing.T) { + cfg := helperConfig("offline") + manager := NewManager() + registry := tools.NewRegistry() + manager.Connect(context.Background(), cfg) + manager.Register(registry) + defer manager.Close() + + status := manager.Status(cfg) + if len(status) != 1 || !status[0].Started || status[0].Connected { + t.Fatalf("offline status = %+v, want started but not connected", status) + } + if status[0].Error == "" { + t.Fatal("offline server did not retain its tool discovery error") + } + if _, ok := registry.Get("mcp__fake__echo"); ok { + t.Fatal("offline server registered a remote tool") + } + + cfg = helperConfig("online") + status = manager.Refresh(context.Background(), cfg) + if len(status) != 1 || !status[0].Started || !status[0].Connected || len(status[0].Tools) != 1 { + t.Fatalf("online status = %+v, want connected with one tool", status) + } + if _, ok := registry.Get("mcp__fake__echo"); !ok { + t.Fatal("refresh did not register the newly available tool") + } + + cfg = helperConfig("offline") + status = manager.Refresh(context.Background(), cfg) + if len(status) != 1 || status[0].Connected { + t.Fatalf("second offline status = %+v, want disconnected", status) + } + if _, ok := registry.Get("mcp__fake__echo"); ok { + t.Fatal("refresh left a stale remote tool registered") + } +} + +func helperConfig(mode string) *config.Config { + return &config.Config{MCP: config.MCP{ + Enabled: true, + Servers: map[string]config.MCPServer{ + "fake": { + Transport: "stdio", + Command: os.Args[0], + Args: []string{"-test.run=TestHelperServer"}, + Env: map[string]string{ + "ANTARES_MCP_HELPER": "1", + "ANTARES_MCP_HELPER_MODE": mode, + }, + Enabled: true, + }, + }, + }} +} + func TestUnknownTransport(t *testing.T) { if _, err := Connect(context.Background(), "x", ServerConfig{Transport: "carrier-pigeon"}); err == nil { t.Fatal("expected an unknown transport to fail") @@ -132,6 +191,16 @@ func TestHelperServer(t *testing.T) { case "notifications/initialized": // no response for notifications case "tools/list": + if os.Getenv("ANTARES_MCP_HELPER_MODE") == "offline" { + out := map[string]any{ + "jsonrpc": "2.0", + "id": req.ID, + "error": map[string]any{"code": -32000, "message": "backing application is offline"}, + } + b, _ := json.Marshal(out) + os.Stdout.Write(append(b, '\n')) + continue + } reply(req.ID, map[string]any{ "tools": []map[string]any{{ "name": "echo", diff --git a/internal/mcp/manager.go b/internal/mcp/manager.go index b50571d..ee1865e 100644 --- a/internal/mcp/manager.go +++ b/internal/mcp/manager.go @@ -16,9 +16,12 @@ import ( // Manager owns the configured MCP servers and exposes their tools to the agent. type Manager struct { - mu sync.RWMutex - clients map[string]*Client - errs map[string]string + mu sync.RWMutex + refreshMu sync.Mutex + clients map[string]*Client + errs map[string]string + registry *tools.Registry + registered []string } // NewManager returns an empty manager. @@ -29,8 +32,18 @@ func NewManager() *Manager { // Connect brings up every enabled server, recording rather than propagating // individual failures so one bad server cannot block startup. func (m *Manager) Connect(ctx context.Context, cfg *config.Config) { + clients, errs := connectAll(ctx, cfg) + m.mu.Lock() + m.clients = clients + m.errs = errs + m.mu.Unlock() +} + +func connectAll(ctx context.Context, cfg *config.Config) (map[string]*Client, map[string]string) { + clients := map[string]*Client{} + errs := map[string]string{} if !cfg.MCP.Enabled { - return + return clients, errs } names := make([]string, 0, len(cfg.MCP.Servers)) for name := range cfg.MCP.Servers { @@ -39,6 +52,7 @@ func (m *Manager) Connect(ctx context.Context, cfg *config.Config) { sort.Strings(names) var wg sync.WaitGroup + var mu sync.Mutex for _, name := range names { sc := cfg.MCP.Servers[name] if !sc.Enabled { @@ -54,33 +68,44 @@ func (m *Manager) Connect(ctx context.Context, cfg *config.Config) { Transport: sc.Transport, Command: sc.Command, Args: sc.Args, Env: sc.Env, URL: sc.URL, Headers: sc.Headers, }) - m.mu.Lock() - defer m.mu.Unlock() + mu.Lock() + defer mu.Unlock() if err != nil { - m.errs[name] = err.Error() - slog.Warn("mcp server unavailable", "server", name, "error", err) + errs[name] = err.Error() + slog.Warn("mcp: cannot connect", "server", name, "error", err) return } - delete(m.errs, name) - m.clients[name] = client + clients[name] = client }(name, sc) } wg.Wait() + return clients, errs } -// Close shuts every server down. +// Close shuts every server down and removes its tools from the registry. func (m *Manager) Close() { + m.refreshMu.Lock() + defer m.refreshMu.Unlock() + m.mu.Lock() - defer m.mu.Unlock() - for name, c := range m.clients { - _ = c.Close() - delete(m.clients, name) + clients := m.clients + m.clients = map[string]*Client{} + m.errs = map[string]string{} + if m.registry != nil { + m.registry.Replace(m.registered, nil) + m.registered = nil + } + m.mu.Unlock() + + for _, client := range clients { + _ = client.Close() } } // ServerStatus reports one server for the dashboard. type ServerStatus struct { Name string `json:"name"` + Started bool `json:"started"` Connected bool `json:"connected"` Error string `json:"error,omitempty"` Tools []ToolDef `json:"tools"` @@ -101,7 +126,8 @@ func (m *Manager) Status(cfg *config.Config) []ServerStatus { for _, name := range names { st := ServerStatus{Name: name, Error: m.errs[name], Tools: []ToolDef{}} if c, ok := m.clients[name]; ok { - st.Connected = true + st.Started = true + st.Connected, st.Error = c.ToolState() st.Tools = c.Tools() } out = append(out, st) @@ -127,13 +153,52 @@ func sanitize(s string) string { return b.String() } -// Register publishes every connected server's tools into the registry. +// Register publishes every connected server's tools into the registry and keeps +// the registry binding for later refreshes. func (m *Manager) Register(reg *tools.Registry) []string { - m.mu.RLock() - defer m.mu.RUnlock() + m.mu.Lock() + defer m.mu.Unlock() + + m.registry = reg + replacements, registered := m.registryToolsLocked() + reg.Replace(m.registered, replacements) + m.registered = append([]string{}, registered...) + return registered +} + +// Refresh reconnects every configured server, atomically replaces the MCP +// tools visible to agents, and then closes the old transports. +func (m *Manager) Refresh(ctx context.Context, cfg *config.Config) []ServerStatus { + m.refreshMu.Lock() + defer m.refreshMu.Unlock() + + clients, errs := connectAll(ctx, cfg) + + m.mu.Lock() + oldClients := m.clients + m.clients = clients + m.errs = errs + if m.registry != nil { + replacements, registered := m.registryToolsLocked() + m.registry.Replace(m.registered, replacements) + m.registered = append([]string{}, registered...) + } + m.mu.Unlock() + for _, client := range oldClients { + _ = client.Close() + } + return m.Status(cfg) +} + +func (m *Manager) registryToolsLocked() ([]tools.Tool, []string) { + var replacements []tools.Tool var registered []string for serverName, client := range m.clients { + ready, _ := client.ToolState() + if !ready { + continue + } for _, def := range client.Tools() { t := &remoteTool{ name: mcpToolName(serverName, def.Name), @@ -143,19 +208,19 @@ func (m *Manager) Register(reg *tools.Registry) []string { schema: def.InputSchema, client: client, } - reg.Register(t) + replacements = append(replacements, t) registered = append(registered, t.name) } } - // One manager-level tool surfaces resources across every server. The + // One manager-level tool surfaces resources across every started server. The // mcp__ prefix means the toolset resolver includes it opt-out, like the // remote tools. if len(m.clients) > 0 { - reg.Register(&resourceTool{m: m}) + replacements = append(replacements, &resourceTool{m: m}) registered = append(registered, "mcp__resource") } sort.Strings(registered) - return registered + return replacements, registered } // resourceTool exposes MCP resources (list/read) across all connected servers. diff --git a/internal/server/handlers_subsystems.go b/internal/server/handlers_subsystems.go index 54f05ec..794b86c 100644 --- a/internal/server/handlers_subsystems.go +++ b/internal/server/handlers_subsystems.go @@ -1,6 +1,7 @@ package server import ( + "context" "errors" "net/http" "strings" @@ -8,6 +9,7 @@ import ( "github.com/enowdev/antares/internal/config" "github.com/enowdev/antares/internal/cron" + "github.com/enowdev/antares/internal/mcp" "github.com/enowdev/antares/internal/skills" "github.com/enowdev/antares/internal/store" ) @@ -363,6 +365,30 @@ func (s *Server) handleMCPStatus(w http.ResponseWriter, r *http.Request) { }) } +func (s *Server) handleMCPRefresh(w http.ResponseWriter, r *http.Request) { + s.refreshMCP(w, r, s.mcp) +} + +type mcpRefresher interface { + Refresh(context.Context, *config.Config) []mcp.ServerStatus +} + +func (s *Server) refreshMCP(w http.ResponseWriter, r *http.Request, refresher mcpRefresher) { + cfg := s.config() + if refresher == nil || !cfg.MCP.Enabled { + writeJSON(w, http.StatusServiceUnavailable, map[string]any{ + "enabled": false, + "servers": []any{}, + }) + return + } + servers := refresher.Refresh(r.Context(), cfg) + writeJSON(w, http.StatusOK, map[string]any{ + "enabled": true, + "servers": servers, + }) +} + // handleSkillLibrary browses the bundled security skill library — paged, by // category — so thousands of skills are explorable without searching blind. func (s *Server) handleSkillLibrary(w http.ResponseWriter, r *http.Request) { diff --git a/internal/server/mcp_refresh_test.go b/internal/server/mcp_refresh_test.go new file mode 100644 index 0000000..3c56347 --- /dev/null +++ b/internal/server/mcp_refresh_test.go @@ -0,0 +1,66 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/enowdev/antares/internal/config" + "github.com/enowdev/antares/internal/mcp" +) + +type fakeMCPRefresher struct { + called bool +} + +func (f *fakeMCPRefresher) Refresh(context.Context, *config.Config) []mcp.ServerStatus { + f.called = true + return []mcp.ServerStatus{{ + Name: "ida", + Started: true, + Connected: true, + Tools: []mcp.ToolDef{{Name: "server_health"}}, + }} +} + +func TestMCPRefreshHandlerReturnsFreshStatus(t *testing.T) { + s := &Server{cfg: &config.Config{MCP: config.MCP{Enabled: true}}} + refresher := &fakeMCPRefresher{} + req := httptest.NewRequest(http.MethodPost, "/api/mcp/refresh", nil) + w := httptest.NewRecorder() + + s.refreshMCP(w, req, refresher) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + if !refresher.called { + t.Fatal("handler did not invoke MCP refresh") + } + var body struct { + Enabled bool `json:"enabled"` + Servers []mcp.ServerStatus `json:"servers"` + } + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if !body.Enabled || len(body.Servers) != 1 || !body.Servers[0].Connected || len(body.Servers[0].Tools) != 1 { + t.Fatalf("response = %+v, want refreshed connected server", body) + } +} + +func TestMCPRefreshHandlerRejectsDisabledMCP(t *testing.T) { + s := &Server{cfg: &config.Config{MCP: config.MCP{Enabled: false}}} + refresher := &fakeMCPRefresher{} + req := httptest.NewRequest(http.MethodPost, "/api/mcp/refresh", nil) + w := httptest.NewRecorder() + + s.refreshMCP(w, req, refresher) + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want %d", w.Code, http.StatusServiceUnavailable) + } + if refresher.called { + t.Fatal("disabled MCP unexpectedly refreshed") + } +} diff --git a/internal/server/routes.go b/internal/server/routes.go index 1822b58..ca156e4 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -177,6 +177,7 @@ func (s *Server) routes() { // MCP m.HandleFunc("GET /api/mcp", s.handleMCPStatus) + m.HandleFunc("POST /api/mcp/refresh", s.handleMCPRefresh) m.HandleFunc("POST /api/mcp/servers", s.handleAddMCPServer) m.HandleFunc("DELETE /api/mcp/servers/{name}", s.handleDeleteMCPServer) diff --git a/internal/tools/registry.go b/internal/tools/registry.go index b058974..c8cf235 100644 --- a/internal/tools/registry.go +++ b/internal/tools/registry.go @@ -114,6 +114,26 @@ func (r *Registry) Register(t Tool) { r.tools[t.Name()] = t } +// Unregister removes a tool by name. It is a no-op when the name is absent. +func (r *Registry) Unregister(name string) { + r.mu.Lock() + defer r.mu.Unlock() + delete(r.tools, name) +} + +// Replace removes oldNames and registers replacements while holding one lock, +// so concurrent tool resolution never observes a partially refreshed MCP set. +func (r *Registry) Replace(oldNames []string, replacements []Tool) { + r.mu.Lock() + defer r.mu.Unlock() + for _, name := range oldNames { + delete(r.tools, name) + } + for _, tool := range replacements { + r.tools[tool.Name()] = tool + } +} + // Get looks up a tool by name. func (r *Registry) Get(name string) (Tool, bool) { r.mu.RLock() diff --git a/internal/tools/registry_refresh_test.go b/internal/tools/registry_refresh_test.go new file mode 100644 index 0000000..81b324f --- /dev/null +++ b/internal/tools/registry_refresh_test.go @@ -0,0 +1,30 @@ +package tools + +import ( + "context" + "testing" +) + +func TestRegistryReplacePreservesUnrelatedTools(t *testing.T) { + r := NewRegistry() + r.Register(namedTestTool("native")) + r.Register(namedTestTool("mcp__old")) + + r.Replace([]string{"mcp__old"}, []Tool{namedTestTool("mcp__new")}) + if _, ok := r.Get("native"); !ok { + t.Fatal("Replace removed an unrelated native tool") + } + if _, ok := r.Get("mcp__old"); ok { + t.Fatal("Replace retained an old tool") + } + if _, ok := r.Get("mcp__new"); !ok { + t.Fatal("Replace did not add the replacement tool") + } +} + +type namedTestTool string + +func (t namedTestTool) Name() string { return string(t) } +func (namedTestTool) Description() string { return "test" } +func (namedTestTool) Schema() map[string]any { return map[string]any{} } +func (namedTestTool) Execute(context.Context, Input) Result { return Text("ok") } diff --git a/web/src/lib/i18n.tsx b/web/src/lib/i18n.tsx index 451ca5f..2256c06 100644 --- a/web/src/lib/i18n.tsx +++ b/web/src/lib/i18n.tsx @@ -185,6 +185,9 @@ const en = { 'mcp.none': 'No MCP servers configured', 'mcp.noneDesc': 'Add servers under mcp.servers in Settings, then restart Antares.', 'mcp.connected': 'connected', + 'mcp.waiting': 'app not ready', + 'mcp.refresh': 'Refresh', + 'mcp.refreshing': 'Refreshing…', 'mcp.failed': 'unavailable', 'mcp.toolCount': '{n} tools', 'mcp.showTools': 'Show tools', @@ -1193,6 +1196,9 @@ const id: Dict = { 'mcp.none': 'Belum ada server MCP', 'mcp.noneDesc': 'Tambahkan di mcp.servers pada Pengaturan, lalu restart Antares.', 'mcp.connected': 'terhubung', + 'mcp.waiting': 'aplikasi belum siap', + 'mcp.refresh': 'Segarkan', + 'mcp.refreshing': 'Menyegarkan…', 'mcp.failed': 'tidak tersedia', 'mcp.toolCount': '{n} alat', 'mcp.showTools': 'Tampilkan alat', @@ -2068,6 +2074,9 @@ const ja: Dict = { 'mcp.none': 'MCP サーバーがありません', 'mcp.noneDesc': '設定の mcp.servers に追加し、Antares を再起動してください。', 'mcp.connected': '接続済み', + 'mcp.waiting': 'アプリ未準備', + 'mcp.refresh': '更新', + 'mcp.refreshing': '更新中…', 'mcp.failed': '利用不可', 'mcp.toolCount': 'ツール {n} 個', 'mcp.showTools': 'ツールを表示', @@ -2766,6 +2775,9 @@ const zh: Dict = { 'mcp.none': '尚未配置 MCP 服务器', 'mcp.noneDesc': '在设置的 mcp.servers 中添加,然后重启 Antares。', 'mcp.connected': '已连接', + 'mcp.waiting': '应用尚未就绪', + 'mcp.refresh': '刷新', + 'mcp.refreshing': '正在刷新…', 'mcp.failed': '不可用', 'mcp.toolCount': '{n} 个工具', 'mcp.showTools': '显示工具', @@ -3462,6 +3474,9 @@ const ru: Dict = { 'mcp.none': 'Серверы MCP не настроены', 'mcp.noneDesc': 'Добавьте их в mcp.servers в настройках и перезапустите Antares.', 'mcp.connected': 'подключён', + 'mcp.waiting': 'приложение не готово', + 'mcp.refresh': 'Обновить', + 'mcp.refreshing': 'Обновление…', 'mcp.failed': 'недоступен', 'mcp.toolCount': 'инструментов: {n}', 'mcp.showTools': 'Показать инструменты', diff --git a/web/src/pages/McpPage.tsx b/web/src/pages/McpPage.tsx index 3fd059e..abb4ca0 100644 --- a/web/src/pages/McpPage.tsx +++ b/web/src/pages/McpPage.tsx @@ -1,5 +1,6 @@ import { useState } from 'react' import { + ArrowClockwise, CaretDown, CheckCircle, PlugsConnected, @@ -50,6 +51,7 @@ interface McpTool { interface McpServer { name: string + started: boolean connected: boolean error?: string tools: McpTool[] | null @@ -57,7 +59,9 @@ interface McpServer { export default function McpPage() { const { t } = useI18n() - const { data, loading, reload } = useApi<{ enabled: boolean; servers: McpServer[] }>('/mcp') + const { data, loading, reload, setData } = useApi<{ enabled: boolean; servers: McpServer[] }>('/mcp') + const [refreshing, setRefreshing] = useState(false) + const [refreshError, setRefreshError] = useState('') const [open, setOpen] = useState(null) const [browsing, setBrowsing] = useState(false) const [adding, setAdding] = useState(false) @@ -65,8 +69,31 @@ export default function McpPage() { const [toRemove, setToRemove] = useState(null) const [tab, setTab] = useState<'servers' | 'docs'>('servers') + const refresh = async () => { + setRefreshing(true) + setRefreshError('') + try { + const result = await post<{ enabled: boolean; servers: McpServer[] }>('/mcp/refresh') + setData(result) + } catch (error) { + setRefreshError(error instanceof Error ? error.message : String(error)) + } finally { + setRefreshing(false) + } + } + usePageActions( <> + , - [t], + [t, refreshing], ) const confirmRemove = async () => { @@ -125,6 +152,12 @@ export default function McpPage() { ) : ( <> + {refreshError ? ( + + {refreshError} + + ) : null} + {!data?.enabled ? ( {t('mcp.disabled')} @@ -167,8 +200,12 @@ export default function McpPage() {
- - {s.connected ? t('mcp.connected') : t('mcp.failed')} + + {s.connected + ? t('mcp.connected') + : s.started + ? t('mcp.waiting') + : t('mcp.failed')} {s.connected ? ( {t('mcp.toolCount', { n: s.tools.length })} From 1f505a3a32130f7aad5acbb0230c21a8377be619 Mon Sep 17 00:00:00 2001 From: Jihad Irfansyah Date: Mon, 3 Aug 2026 16:15:06 +0700 Subject: [PATCH 10/11] Port setsid daemon-fork regression test for persistent shell stdin Brings the remaining coverage from the closed daemon-stdin fix so a background setsid/adb-style child cannot pin the shell's completion sentinel. --- internal/tools/shell_test.go | 51 ++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/internal/tools/shell_test.go b/internal/tools/shell_test.go index 9d9a6c7..7150353 100644 --- a/internal/tools/shell_test.go +++ b/internal/tools/shell_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "os/exec" + "path/filepath" "runtime" "strings" "testing" @@ -211,3 +212,53 @@ func TestPersistentShellStdinWrapPreservesCdAndExport(t *testing.T) { t.Fatalf("cwd/export not preserved: %q", out) } } + +// TestPersistentShellClosesInheritedStdinForDaemonForkingClients guards against +// the adb regression: a client like `adb` forks a background daemon at first +// invocation, and that daemon inherits every fd of the persistent shell it was +// launched from. Without redirecting stdin to /dev/null, bash then blocks on +// the completion sentinel forever because a reader on its stdin pipe is still +// alive, so a command that finished in milliseconds times out at +// terminal.timeout (300 s) instead. +func TestPersistentShellClosesInheritedStdinForDaemonForkingClients(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX persistent shell protocol does not apply on Windows") + } + if _, err := exec.LookPath("setsid"); err != nil { + t.Skip("setsid unavailable — cannot simulate a daemon-forking client") + } + + m := NewShellManager(config.Terminal{Shell: "/bin/bash"}) + t.Cleanup(m.CloseAll) + sess, err := m.session("daemon-session", t.TempDir()) + if err != nil { + t.Fatal(err) + } + + pidFile := filepath.Join(t.TempDir(), "daemon.pid") + command := "setsid /bin/sh -c 'sleep 60' >/dev/null 2>&1 & echo $! >" + pidFile + "; echo parent-done" + start := time.Now() + out, code, err := sess.run(context.Background(), command, 3*time.Second, nil) + elapsed := time.Since(start) + + // Clean up the lingering child before asserting so a slow assertion path + // does not leak a stray process into the test environment. + if raw, readErr := os.ReadFile(pidFile); readErr == nil { + if pid := strings.TrimSpace(string(raw)); pid != "" { + _ = exec.Command("/bin/sh", "-c", "kill -TERM "+pid+" 2>/dev/null || true").Run() + } + } + + if err != nil { + t.Fatalf("daemon-forking command hung/failed: %v (after %s)\nout=%q", err, elapsed, out) + } + if code != 0 { + t.Fatalf("exit code = %d, want 0; out=%q", code, out) + } + if !strings.Contains(out, "parent-done") { + t.Fatalf("output = %q, want parent-done", out) + } + if elapsed > time.Second { + t.Fatalf("took %s, want completion well under 1s (daemon must not hold shell stdin)", elapsed) + } +} From 1fc0489dd411f672807154aaae8ee58d0a7cdb42 Mon Sep 17 00:00:00 2001 From: Jihad Irfansyah Date: Mon, 3 Aug 2026 20:32:23 +0700 Subject: [PATCH 11/11] Fix Antares Gemini/Antigravity routing, tools, and sticky cache affinity Prevent top-level model credentials from clobbering named providers, rename web_search on Antigravity wires, preserve Gemini thought signatures, accept CLI-style /antigravity base URLs (append /v1beta), and pin sticky session signals so multi-account gateways keep account affinity across agent turns. --- cmd/antares/main.go | 16 +- internal/agent/agent.go | 10 +- internal/agent/client.go | 78 +++--- internal/agent/compact.go | 2 +- internal/agent/harness.go | 10 +- internal/agent/learn.go | 2 +- internal/agent/provider_tools.go | 50 ++++ internal/agent/provider_tools_test.go | 62 +++++ internal/agent/session.go | 2 +- internal/config/load.go | 26 +- internal/config/resolve_provider_test.go | 54 +++++ internal/llm/client.go | 9 + internal/llm/gemini.go | 292 ++++++++++++++++++++--- internal/llm/gemini_test.go | 185 ++++++++++++++ internal/llm/types.go | 22 +- internal/server/handlers_config.go | 15 ++ internal/tui/pickers.go | 23 +- 17 files changed, 755 insertions(+), 103 deletions(-) create mode 100644 internal/agent/provider_tools.go create mode 100644 internal/agent/provider_tools_test.go create mode 100644 internal/config/resolve_provider_test.go create mode 100644 internal/llm/gemini_test.go diff --git a/cmd/antares/main.go b/cmd/antares/main.go index 913cde7..b2ded1d 100644 --- a/cmd/antares/main.go +++ b/cmd/antares/main.go @@ -741,12 +741,13 @@ func cmdModel(args []string) error { fmt.Printf("%s%-40s %s\n", mark, mid, pid) } for _, pid := range provIDs { - // static models from config - for _, mid := range cfg.Providers[pid].Models { + // static models from config — when set, treat as whitelist only + static := cfg.Providers[pid].Models + for _, mid := range static { print(mid, pid) } - // plus whatever the provider's /models endpoint returns live - if providers.Connected(cfg, pid) || cfg.Providers[pid].BaseURL != "" { + // live /models only when no curated list (empty providers..models) + if len(static) == 0 && (providers.Connected(cfg, pid) || cfg.Providers[pid].BaseURL != "") { if ids, err := providers.FetchModels(context.Background(), cfg, pid); err == nil { for _, mid := range ids { print(mid, pid) @@ -759,10 +760,17 @@ func cmdModel(args []string) error { } return nil } + prevProvider := cfg.Model.Provider cfg.Model.Default = args[0] if len(args) > 1 { cfg.Model.Provider = args[1] } + if cfg.Model.Provider != prevProvider { + cfg.ClearInlineModelCredentials() + } else if p, ok := cfg.Providers[cfg.Model.Provider]; ok && + (strings.TrimSpace(p.BaseURL) != "" || strings.TrimSpace(p.APIKey) != "") { + cfg.ClearInlineModelCredentials() + } if err := config.Save(cfg); err != nil { return err } diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 27b0e1b..0e2b72b 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -303,7 +303,7 @@ func (a *Agent) Run(ctx context.Context, req Request, emit Emit) (*Result, error a.mu.Unlock() }() - client, modelName, providerName, err := a.newClient(req.Model) + client, modelName, providerName, err := a.newClient(req.Model, sess.ID) if err != nil { _ = emit(Event{Type: EventError, Err: err.Error()}) _ = emit(Event{Type: EventDone}) @@ -367,6 +367,11 @@ func (a *Agent) Run(ctx context.Context, req Request, emit Emit) (*Result, error toolSpecs = append(toolSpecs, llm.Tool{Name: t.Name(), Description: t.Description(), Parameters: t.Schema()}) byName[t.Name()] = t } + // Sub2API Antigravity treats a tool literally named "web_search" as Google's + // built-in search and rejects mixing it with functionDeclarations. Rename + // only on the wire for those routes; execution still resolves to web_search. + _, prov := a.cfg.ResolveProvider(providerName) + toolSpecs, byName = sanitizeToolsForProvider(toolSpecs, byName, providerName, prov.BaseURL) systemPrompt := a.buildSystemPrompt(ctx, req, sess, activeTools) @@ -467,6 +472,9 @@ func (a *Agent) Run(ctx context.Context, req Request, emit Emit) (*Result, error assistant := llm.Message{ Role: llm.RoleAssistant, Content: resp.Content, Reasoning: resp.Reasoning, ToolCalls: resp.ToolCalls, + // Gemini multi-turn tool use requires echoing thoughtSignature on + // the same functionCall/text parts in the next request. + ThoughtSignature: resp.ThoughtSignature, } history = append(history, assistant) if !req.Quiet { diff --git a/internal/agent/client.go b/internal/agent/client.go index 03592d1..1829313 100644 --- a/internal/agent/client.go +++ b/internal/agent/client.go @@ -14,9 +14,9 @@ import ( // newClient builds the provider adapter for a model. When the model name is // qualified as "provider/model", the prefix selects the provider. When fallback // models are configured, the returned client tries each in turn on a hard -// failure. -func (a *Agent) newClient(modelOverride string) (client llm.Client, model, provider string, err error) { - primary, model, provider, err := a.resolveClient(modelOverride) +// failure. sessionID pins gateway sticky routing (Gemini CLI–compatible) when set. +func (a *Agent) newClient(modelOverride, sessionID string) (client llm.Client, model, provider string, err error) { + primary, model, provider, err := a.resolveClient(modelOverride, sessionID) if err != nil { return nil, "", "", err } @@ -30,7 +30,7 @@ func (a *Agent) newClient(modelOverride string) (client llm.Client, model, provi if spec == "" { continue } - fc, fm, _, ferr := a.resolveClient(spec) + fc, fm, _, ferr := a.resolveClient(spec, sessionID) if ferr != nil { slog.Debug("fallback model unavailable", "spec", spec, "error", ferr) continue @@ -42,7 +42,7 @@ func (a *Agent) newClient(modelOverride string) (client llm.Client, model, provi } // resolveClient builds one provider adapter for a model spec. -func (a *Agent) resolveClient(modelOverride string) (client llm.Client, model, provider string, err error) { +func (a *Agent) resolveClient(modelOverride, sessionID string) (client llm.Client, model, provider string, err error) { cfg := a.cfg model = firstNonEmpty(modelOverride, cfg.Model.Default) provider = cfg.Model.Provider @@ -86,6 +86,7 @@ func (a *Agent) resolveClient(modelOverride string) (client llm.Client, model, p Headers: p.Headers, Timeout: timeout, ProviderID: id, Retries: retries, APIVersion: p.APIVersion, Region: p.Region, + SessionID: sessionID, }) if err != nil { return nil, "", "", err @@ -95,16 +96,16 @@ func (a *Agent) resolveClient(modelOverride string) (client llm.Client, model, p // newAuxClient returns the auxiliary model used for summarisation and other // background work, falling back to the main model. -func (a *Agent) newAuxClient() (llm.Client, string, string, error) { +func (a *Agent) newAuxClient(sessionID string) (llm.Client, string, string, error) { if aux := strings.TrimSpace(a.cfg.Model.Auxiliary); aux != "" { - return a.newClient(aux) + return a.newClient(aux, sessionID) } - return a.newClient("") + return a.newClient("", sessionID) } // Probe checks whether the configured provider answers, for /api/status. func (a *Agent) Probe(ctx context.Context) (bool, string) { - client, model, provider, err := a.newClient("") + client, model, provider, err := a.newClient("", "") if err != nil { return false, err.Error() } @@ -129,13 +130,37 @@ func (a *Agent) Probe(ctx context.Context) (bool, string) { return true, fmt.Sprintf("%s · %s ready", provider, model) } -// Models lists the models a provider offers: whatever its /models endpoint -// reports, merged with any models added by hand in config (providers.. -// models). Manually added ids that the endpoint does not return are appended, -// carrying a context window from providers..model_meta when set. A live -// fetch that fails still yields the manual list rather than nothing. +// Models lists the models a provider offers. +// +// If providers..models is non-empty it is treated as a whitelist: only +// those ids are returned (no live /models merge). This keeps curated local +// gateways (e.g. Sub2API antigravity) from flooding the UI with broken or +// deprecated upstream catalog entries. +// +// If the list is empty, the provider's /models endpoint is queried live. +// A live fetch that fails still yields any manual list rather than nothing. func (a *Agent) Models(ctx context.Context, providerID string) ([]llm.ModelInfo, error) { id, p := a.cfg.ResolveProvider(providerID) + + // Curated whitelist: skip live catalog entirely. + if len(p.Models) > 0 { + out := make([]llm.ModelInfo, 0, len(p.Models)) + seen := make(map[string]bool, len(p.Models)) + for _, mid := range p.Models { + if mid == "" || seen[mid] { + continue + } + seen[mid] = true + out = append(out, llm.ModelInfo{ + ID: mid, + Name: mid, + Provider: id, + ContextWindow: p.ModelMeta[mid].ContextWindow, + }) + } + return out, nil + } + client, err := llm.New(llm.Options{ Kind: p.Kind, BaseURL: p.BaseURL, APIKey: p.APIKey, Headers: p.Headers, ProviderID: id, Timeout: 60 * time.Second, APIVersion: p.APIVersion, Region: p.Region, @@ -147,29 +172,8 @@ func (a *Agent) Models(ctx context.Context, providerID string) ([]llm.ModelInfo, defer cancel() live, ferr := client.Models(fetchCtx) - - // Fold in manually configured models the endpoint did not return. - seen := make(map[string]bool, len(live)) - for _, m := range live { - seen[m.ID] = true - } - out := live - for _, mid := range p.Models { - if mid == "" || seen[mid] { - continue - } - seen[mid] = true - out = append(out, llm.ModelInfo{ - ID: mid, - Name: mid, - Provider: id, - ContextWindow: p.ModelMeta[mid].ContextWindow, - }) - } - - // A failed fetch is only an error when it left us with nothing to show. - if ferr != nil && len(out) == 0 { + if ferr != nil && len(live) == 0 { return nil, ferr } - return out, nil + return live, nil } diff --git a/internal/agent/compact.go b/internal/agent/compact.go index e72d03c..027c7a8 100644 --- a/internal/agent/compact.go +++ b/internal/agent/compact.go @@ -207,7 +207,7 @@ func rebalanceToolBoundary(middle, tail []llm.Message) ([]llm.Message, []llm.Mes // summarise asks the auxiliary (or main) model to condense a message span. func (a *Agent) summarise(ctx context.Context, msgs []llm.Message) (string, error) { - client, model, _, err := a.newAuxClient() + client, model, _, err := a.newAuxClient("") if err != nil { return "", err } diff --git a/internal/agent/harness.go b/internal/agent/harness.go index 1468532..5b7ba58 100644 --- a/internal/agent/harness.go +++ b/internal/agent/harness.go @@ -180,7 +180,7 @@ func (a *Agent) verify(ctx context.Context, request, reply string, transcript [] if !a.cfg.Agent.VerifyReplies || strings.TrimSpace(reply) == "" { return nil } - client, model, _, err := a.newAuxClient() + client, model, _, err := a.newAuxClient("") if err != nil { slog.Debug("verification unavailable", "error", err) return nil @@ -302,7 +302,7 @@ type judgement struct { // judgeGoal decides whether a standing goal is finished, and what to do next // when it is not. func (a *Agent) judgeGoal(ctx context.Context, g *Goal, reply string, transcript []llm.Message) judgement { - client, model, _, err := a.newAuxClient() + client, model, _, err := a.newAuxClient("") if err != nil { // Without a judge the loop must not run forever. return judgement{Done: true} @@ -388,7 +388,7 @@ func (a *Agent) Distil(ctx context.Context, sessionID, hint string) (string, err fmt.Fprintf(&b, "\nThe user asked that the skill focus on: %s\n", hint) } - client, model, _, err := a.newAuxClient() + client, model, _, err := a.newAuxClient("") if err != nil { return "", err } @@ -705,7 +705,7 @@ func (a *Agent) Panel(ctx context.Context, question string, models []string, emi return usable[0].Answer, answers, nil } - client, model, _, err := a.newAuxClient() + client, model, _, err := a.newAuxClient("") if err != nil { return usable[0].Answer, answers, nil } @@ -869,7 +869,7 @@ var nowFunc = time.Now // eyes. func (a *Agent) describeImage(ctx context.Context, data []byte, mime, question string) (string, error) { model := strings.TrimSpace(a.cfg.Model.Vision) - client, resolved, _, err := a.newClient(model) + client, resolved, _, err := a.newClient(model, "") if err != nil { return "", err } diff --git a/internal/agent/learn.go b/internal/agent/learn.go index 64c66e5..3873f73 100644 --- a/internal/agent/learn.go +++ b/internal/agent/learn.go @@ -30,7 +30,7 @@ func (a *Agent) learnFromErrors(ctx context.Context, userMsg, reply string, fail if len(failures) == 0 || a.db == nil || !a.cfg.Memory.Enabled { return } - client, model, _, err := a.newAuxClient() + client, model, _, err := a.newAuxClient("") if err != nil { return } diff --git a/internal/agent/provider_tools.go b/internal/agent/provider_tools.go new file mode 100644 index 0000000..ea33c30 --- /dev/null +++ b/internal/agent/provider_tools.go @@ -0,0 +1,50 @@ +package agent + +import ( + "strings" + + "github.com/enowdev/antares/internal/llm" + "github.com/enowdev/antares/internal/tools" +) + +// antigravity renames tools that collide with Google built-ins on Sub2API's +// Antigravity transformer. The wire name is what the model sees; byName keeps +// a lookup for the real Antares tool so Execute still works. +const ( + wireWebSearchAlias = "search_web" + nativeWebSearch = "web_search" +) + +// isAntigravityRoute reports whether this provider routes through Sub2API +// Antigravity (Claude or Gemini under /antigravity/…). +func isAntigravityRoute(providerID, baseURL string) bool { + s := strings.ToLower(providerID + " " + baseURL) + return strings.Contains(s, "antigravity") +} + +// sanitizeToolsForProvider rewrites tool specs that upstream gateways mishandle. +// For Antigravity, a tool literally named web_search is treated as Google's +// built-in search and cannot be mixed with functionDeclarations — Sub2API +// returns 400. We rename it on the wire to search_web and map the alias back +// into byName so tool execution still hits the Antares web_search tool. +func sanitizeToolsForProvider(specs []llm.Tool, byName map[string]tools.Tool, providerID, baseURL string) ([]llm.Tool, map[string]tools.Tool) { + if !isAntigravityRoute(providerID, baseURL) { + return specs, byName + } + out := make([]llm.Tool, len(specs)) + copy(out, specs) + // Copy byName so we do not mutate the caller's map unexpectedly. + mapped := make(map[string]tools.Tool, len(byName)+1) + for k, v := range byName { + mapped[k] = v + } + for i := range out { + if out[i].Name == nativeWebSearch { + out[i].Name = wireWebSearchAlias + if t, ok := byName[nativeWebSearch]; ok { + mapped[wireWebSearchAlias] = t + } + } + } + return out, mapped +} diff --git a/internal/agent/provider_tools_test.go b/internal/agent/provider_tools_test.go new file mode 100644 index 0000000..055a4cc --- /dev/null +++ b/internal/agent/provider_tools_test.go @@ -0,0 +1,62 @@ +package agent + +import ( + "context" + "testing" + + "github.com/enowdev/antares/internal/llm" + "github.com/enowdev/antares/internal/tools" +) + +type stubTool struct{ name string } + +func (s stubTool) Name() string { return s.name } +func (s stubTool) Description() string { return s.name } +func (s stubTool) Schema() map[string]any { return map[string]any{} } +func (s stubTool) Execute(context.Context, tools.Input) tools.Result { + return tools.Text("ok") +} + +func TestSanitizeToolsRenamesWebSearchForAntigravity(t *testing.T) { + ws := stubTool{name: "web_search"} + specs := []llm.Tool{ + {Name: "web_search", Description: "search"}, + {Name: "read_file", Description: "read"}, + } + byName := map[string]tools.Tool{"web_search": ws, "read_file": stubTool{name: "read_file"}} + + out, mapped := sanitizeToolsForProvider(specs, byName, "antigravity", "http://localhost:8080/antigravity/v1") + if out[0].Name != "search_web" { + t.Fatalf("wire name = %q, want search_web", out[0].Name) + } + if out[1].Name != "read_file" { + t.Fatalf("read_file must stay") + } + if _, ok := mapped["search_web"]; !ok { + t.Fatal("search_web must map back to a tool") + } + if mapped["search_web"].Name() != "web_search" { + t.Fatalf("alias should resolve to web_search tool, got %q", mapped["search_web"].Name()) + } +} + +func TestSanitizeToolsLeavesOthersAlone(t *testing.T) { + specs := []llm.Tool{{Name: "web_search"}} + byName := map[string]tools.Tool{"web_search": stubTool{name: "web_search"}} + out, _ := sanitizeToolsForProvider(specs, byName, "openai", "https://api.openai.com/v1") + if out[0].Name != "web_search" { + t.Fatalf("non-antigravity must keep web_search, got %q", out[0].Name) + } +} + +func TestIsAntigravityRoute(t *testing.T) { + if !isAntigravityRoute("antigravity", "http://x") { + t.Fatal("provider id") + } + if !isAntigravityRoute("gemini", "http://localhost:8080/antigravity/v1beta") { + t.Fatal("base url") + } + if isAntigravityRoute("custom", "http://localhost:8080/v1") { + t.Fatal("codebuddy must not match") + } +} diff --git a/internal/agent/session.go b/internal/agent/session.go index bdadd56..fa978f6 100644 --- a/internal/agent/session.go +++ b/internal/agent/session.go @@ -285,7 +285,7 @@ func (a *Agent) maybeTitle(ctx context.Context, sess *store.Session, userMsg, re // any error so the caller falls back to the heuristic — a title is never worth // failing a turn over. func (a *Agent) llmTitle(ctx context.Context, userMsg, reply string) string { - client, model, _, err := a.newAuxClient() + client, model, _, err := a.newAuxClient("") if err != nil { return "" } diff --git a/internal/config/load.go b/internal/config/load.go index 090e66b..697a5fb 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -250,16 +250,24 @@ func normalize(c *Config) { // ResolveProvider returns the provider entry used for a model call, falling back // to the inline model.* fields when no named provider matches. +// +// Top-level model.base_url / model.api_key are legacy "inline provider" overrides. +// They must NOT clobber a named provider that already has its own base_url or +// api_key — otherwise switching the UI to antigravity/gemini while stale +// CodeBuddy values remain in model.* silently routes Claude to /v1 with the +// wrong key (Sub2API platform=codebuddy, cascading 401s). func (c *Config) ResolveProvider(name string) (string, Provider) { if name == "" { name = c.Model.Provider } if p, ok := c.Providers[name]; ok { - if c.Model.BaseURL != "" && name == c.Model.Provider { - p.BaseURL = c.Model.BaseURL - } - if c.Model.APIKey != "" && name == c.Model.Provider { - p.APIKey = c.Model.APIKey + if name == c.Model.Provider { + if strings.TrimSpace(c.Model.BaseURL) != "" && strings.TrimSpace(p.BaseURL) == "" { + p.BaseURL = c.Model.BaseURL + } + if strings.TrimSpace(c.Model.APIKey) != "" && strings.TrimSpace(p.APIKey) == "" { + p.APIKey = c.Model.APIKey + } } return name, p } @@ -272,3 +280,11 @@ func (c *Config) ResolveProvider(name string) (string, Provider) { TimeoutSecs: 300, } } + +// ClearInlineModelCredentials wipes top-level model.base_url and model.api_key. +// Call this whenever the active provider changes so a previous provider's +// credentials cannot leak into ResolveProvider for the next one. +func (c *Config) ClearInlineModelCredentials() { + c.Model.BaseURL = "" + c.Model.APIKey = "" +} diff --git a/internal/config/resolve_provider_test.go b/internal/config/resolve_provider_test.go new file mode 100644 index 0000000..ba76adc --- /dev/null +++ b/internal/config/resolve_provider_test.go @@ -0,0 +1,54 @@ +package config + +import "testing" + +func TestResolveProviderDoesNotClobberNamedProviderCredentials(t *testing.T) { + cfg := Default() + cfg.Model.Provider = "antigravity" + cfg.Model.BaseURL = "http://localhost:8080/v1" // stale CodeBuddy + cfg.Model.APIKey = "sk-codebuddy-stale" + cfg.Providers = map[string]Provider{ + "antigravity": { + Kind: "anthropic", Enabled: true, + BaseURL: "http://localhost:8080/antigravity/v1", + APIKey: "sk-antigravity-real", + }, + "custom": { + Kind: "openai-compatible", Enabled: true, + BaseURL: "http://localhost:8080/v1", + APIKey: "sk-codebuddy-stale", + }, + } + + _, p := cfg.ResolveProvider("antigravity") + if p.BaseURL != "http://localhost:8080/antigravity/v1" { + t.Fatalf("BaseURL = %q, want antigravity path (must not inherit model.base_url)", p.BaseURL) + } + if p.APIKey != "sk-antigravity-real" { + t.Fatalf("APIKey clobbered by top-level model.api_key") + } +} + +func TestResolveProviderAllowsInlineWhenProviderHasNoCredentials(t *testing.T) { + cfg := Default() + cfg.Model.Provider = "inline" + cfg.Model.BaseURL = "http://localhost:9/v1" + cfg.Model.APIKey = "sk-inline" + cfg.Providers = map[string]Provider{ + "inline": {Kind: "openai-compatible", Enabled: true}, + } + _, p := cfg.ResolveProvider("inline") + if p.BaseURL != "http://localhost:9/v1" || p.APIKey != "sk-inline" { + t.Fatalf("expected inline fallback, got base=%q key=%q", p.BaseURL, p.APIKey) + } +} + +func TestClearInlineModelCredentials(t *testing.T) { + cfg := Default() + cfg.Model.BaseURL = "http://x" + cfg.Model.APIKey = "sk-x" + cfg.ClearInlineModelCredentials() + if cfg.Model.BaseURL != "" || cfg.Model.APIKey != "" { + t.Fatalf("not cleared: %+v", cfg.Model) + } +} diff --git a/internal/llm/client.go b/internal/llm/client.go index 6044d44..ddb6f40 100644 --- a/internal/llm/client.go +++ b/internal/llm/client.go @@ -45,6 +45,10 @@ type Options struct { // Region is the AWS region for Bedrock, e.g. "us-east-1". Empty reads // AWS_REGION / AWS_DEFAULT_REGION. Region string + // SessionID is an optional stable conversation id (e.g. Antares session). + // Gemini adapters use it for gateway sticky routing / implicit cache + // affinity on reverse proxies that fingerprint Gemini CLI sessions. + SessionID string } // New builds the adapter matching kind. Unknown kinds fall back to the @@ -79,7 +83,12 @@ func newBase(o Options) (Client, error) { case "gemini", "google": if o.BaseURL == "" { o.BaseURL = "https://generativelanguage.googleapis.com/v1beta" + } else { + // Match Gemini CLI: GOOGLE_GEMINI_BASE_URL is often the gateway root + // (e.g. http://127.0.0.1:8080/antigravity) and the client appends /v1beta. + o.BaseURL = normalizeGeminiBaseURL(o.BaseURL) } + o.Headers = withGeminiGatewayStickyHeaders(o.Headers, o.BaseURL, o.SessionID) return &geminiClient{opts: o}, nil case "bedrock", "aws", "aws-bedrock": return newBedrock(o) diff --git a/internal/llm/gemini.go b/internal/llm/gemini.go index be03b4b..14ccb18 100644 --- a/internal/llm/gemini.go +++ b/internal/llm/gemini.go @@ -2,6 +2,8 @@ package llm import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "net/url" @@ -40,9 +42,95 @@ func (c *geminiClient) headers() map[string]string { return h } +// normalizeGeminiBaseURL makes Antares accept the same base as Gemini CLI. +// CLI sets GOOGLE_GEMINI_BASE_URL to the gateway root (…/antigravity) and the +// SDK appends /v1beta. Antares previously required …/antigravity/v1beta in config. +// Without /v1beta, Sub2API returns 404 for /antigravity/models/…. +func normalizeGeminiBaseURL(base string) string { + base = strings.TrimRight(strings.TrimSpace(base), "/") + if base == "" { + return base + } + lower := strings.ToLower(base) + // Already a full v1beta endpoint (official or gateway). + if strings.HasSuffix(lower, "/v1beta") || strings.Contains(lower, "/v1beta/") { + return base + } + // Gemini CLI style: http://127.0.0.1:8080/antigravity + if strings.HasSuffix(lower, "/antigravity") || strings.Contains(lower, "/antigravity/") { + if !strings.Contains(lower, "/v1beta") { + return base + "/v1beta" + } + } + return base +} + +// geminiGatewaySticky reports whether this base is a reverse-proxy Antigravity +// (or similar) Gemini route that benefits from CLI-compatible sticky signals. +func geminiGatewaySticky(base string) bool { + return strings.Contains(strings.ToLower(base), "antigravity") +} + +// withGeminiGatewayStickyHeaders adds Gemini-CLI-compatible sticky headers so +// multi-account gateways pin the same upstream account across agent turns +// (needed for implicit cache + thoughtSignature continuity). +func withGeminiGatewayStickyHeaders(in map[string]string, base, sessionID string) map[string]string { + sessionID = strings.TrimSpace(sessionID) + if sessionID == "" || !geminiGatewaySticky(base) { + return in + } + out := make(map[string]string, len(in)+3) + for k, v := range in { + out[k] = v + } + // Sub2API extractGeminiCLISessionHash prefers this header + body tmp path. + if _, ok := out["x-gemini-api-privileged-user-id"]; !ok { + out["x-gemini-api-privileged-user-id"] = geminiStickyUserID(sessionID) + } + // Usage-log correlation (does not drive Gemini sticky alone, but harmless). + if _, ok := out["session_id"]; !ok { + out["session_id"] = sessionID + } + return out +} + +// geminiStickyUserID is a stable UUID-shaped id derived from the Antares session. +func geminiStickyUserID(sessionID string) string { + sum := sha256.Sum256([]byte("antares-gemini-sticky:" + sessionID)) + // Format first 16 bytes as UUID v4-ish (version/variant bits fixed). + b := sum[:16] + b[6] = (b[6] & 0x0f) | 0x40 + b[8] = (b[8] & 0x3f) | 0x80 + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) +} + +// geminiStickyTmpHash is a 64-hex digest matching Sub2API's +// /\.gemini\/tmp\/([A-Fa-f0-9]{64})/ sticky extractor. +func geminiStickyTmpHash(sessionID string) string { + sum := sha256.Sum256([]byte("antares-gemini-tmp:" + sessionID)) + return hex.EncodeToString(sum[:]) +} + +// geminiStickySystemAnchor is injected into systemInstruction so the gateway +// can hash a stable sticky key even as conversation contents grow each turn. +func geminiStickySystemAnchor(sessionID string) string { + if strings.TrimSpace(sessionID) == "" { + return "" + } + // Shape mirrors Gemini CLI project temp path that Sub2API keys sticky on. + return "The project's temporary directory is: /.gemini/tmp/" + geminiStickyTmpHash(sessionID) +} + +// geminiDummyThoughtSignature is what the official Gemini CLI injects when a +// functionCall part is missing thoughtSignature. Required for multi-turn tool +// use on Gemini 3 / thinking models (HTTP 400 otherwise). +// See packages/core historyHardening.js SYNTHETIC_THOUGHT_SIGNATURE. +const geminiDummyThoughtSignature = "skip_thought_signature_validator" + type gemPart struct { Text string `json:"text,omitempty"` Thought bool `json:"thought,omitempty"` + ThoughtSignature string `json:"thoughtSignature,omitempty"` InlineData *gemInlineData `json:"inlineData,omitempty"` FunctionCall *gemFuncCall `json:"functionCall,omitempty"` FunctionResponse *gemFuncResult `json:"functionResponse,omitempty"` @@ -56,10 +144,12 @@ type gemInlineData struct { type gemFuncCall struct { Name string `json:"name"` Args json.RawMessage `json:"args,omitempty"` + ID string `json:"id,omitempty"` } type gemFuncResult struct { Name string `json:"name"` + ID string `json:"id,omitempty"` Response any `json:"response"` } @@ -92,15 +182,31 @@ func toGemini(req Request) []gemContent { case RoleAssistant: var parts []gemPart if strings.TrimSpace(m.Content) != "" { - parts = append(parts, gemPart{Text: m.Content}) + p := gemPart{Text: m.Content} + // Text-only turns may carry a part-level thought signature. + if m.ThoughtSignature != "" && len(m.ToolCalls) == 0 { + p.ThoughtSignature = m.ThoughtSignature + } + parts = append(parts, p) } - for _, tc := range m.ToolCalls { + for i, tc := range m.ToolCalls { callName[tc.ID] = tc.Name args := json.RawMessage(tc.Arguments) if strings.TrimSpace(tc.Arguments) == "" { args = json.RawMessage("{}") } - parts = append(parts, gemPart{FunctionCall: &gemFuncCall{Name: tc.Name, Args: args}}) + sig := strings.TrimSpace(tc.ThoughtSignature) + if sig == "" && i == 0 { + sig = strings.TrimSpace(m.ThoughtSignature) + } + if sig == "" { + // Gemini 3 rejects functionCall parts without a signature. + sig = geminiDummyThoughtSignature + } + parts = append(parts, gemPart{ + ThoughtSignature: sig, + FunctionCall: &gemFuncCall{Name: tc.Name, Args: args, ID: tc.ID}, + }) } appendMsg("model", parts) case RoleTool: @@ -110,6 +216,7 @@ func toGemini(req Request) []gemContent { } appendMsg("user", []gemPart{{FunctionResponse: &gemFuncResult{ Name: name, + ID: m.ToolCallID, Response: map[string]any{"result": m.Content}, }}}) default: @@ -175,8 +282,16 @@ func sanitizeSchema(in map[string]any) map[string]any { func (c *geminiClient) buildBody(req Request) map[string]any { body := map[string]any{"contents": toGemini(req)} + sysParts := make([]gemPart, 0, 2) + if anchor := geminiStickySystemAnchor(c.opts.SessionID); anchor != "" && geminiGatewaySticky(c.opts.BaseURL) { + // Keep sticky fingerprint in systemInstruction (stable across agent turns). + sysParts = append(sysParts, gemPart{Text: anchor}) + } if s := strings.TrimSpace(req.System); s != "" { - body["systemInstruction"] = gemContent{Parts: []gemPart{{Text: s}}} + sysParts = append(sysParts, gemPart{Text: s}) + } + if len(sysParts) > 0 { + body["systemInstruction"] = gemContent{Parts: sysParts} } gen := map[string]any{} if req.Temperature > 0 { @@ -191,15 +306,8 @@ func (c *geminiClient) buildBody(req Request) map[string]any { if len(req.StopSequences) > 0 { gen["stopSequences"] = req.StopSequences } - switch strings.ToLower(req.ReasoningEffort) { - case "low": - gen["thinkingConfig"] = map[string]any{"thinkingBudget": 2048, "includeThoughts": true} - case "medium": - gen["thinkingConfig"] = map[string]any{"thinkingBudget": 8192, "includeThoughts": true} - case "high": - gen["thinkingConfig"] = map[string]any{"thinkingBudget": 24576, "includeThoughts": true} - case "none": - gen["thinkingConfig"] = map[string]any{"thinkingBudget": 0} + if tc := geminiThinkingConfig(req.Model, req.ReasoningEffort); tc != nil { + gen["thinkingConfig"] = tc } if len(gen) > 0 { body["generationConfig"] = gen @@ -262,7 +370,96 @@ func (c *geminiClient) endpoint(model, method string, stream bool) string { return u } +// geminiThinkingConfig builds generationConfig.thinkingConfig for the model. +// Gemini 3 series prefer thinkingLevel (MINIMAL/LOW/MEDIUM/HIGH); 2.5 series +// use thinkingBudget token counts. includeThoughts requests thought summaries +// when the endpoint exposes them (not all reverse proxies return thought text). +func geminiThinkingConfig(model, effort string) map[string]any { + e := strings.ToLower(strings.TrimSpace(effort)) + if e == "" { + return nil + } + useLevel := geminiModelUsesThinkingLevel(model) + switch e { + case "none": + if useLevel { + return map[string]any{"thinkingLevel": "MINIMAL", "includeThoughts": false} + } + return map[string]any{"thinkingBudget": 0} + case "low": + if useLevel { + return map[string]any{"thinkingLevel": "LOW", "includeThoughts": true} + } + return map[string]any{"thinkingBudget": 2048, "includeThoughts": true} + case "medium": + if useLevel { + return map[string]any{"thinkingLevel": "MEDIUM", "includeThoughts": true} + } + return map[string]any{"thinkingBudget": 8192, "includeThoughts": true} + case "high": + if useLevel { + return map[string]any{"thinkingLevel": "HIGH", "includeThoughts": true} + } + return map[string]any{"thinkingBudget": 24576, "includeThoughts": true} + default: + return nil + } +} + +func geminiModelUsesThinkingLevel(model string) bool { + m := strings.ToLower(strings.TrimPrefix(model, "models/")) + // Gemini 3.x model ids (and antigravity aliases like gemini-3.6-flash-high). + return strings.HasPrefix(m, "gemini-3") || strings.Contains(m, "gemini-3.") +} + +// parseGeminiParts folds candidate parts into content, reasoning, tool calls, +// and preserves thoughtSignature on tool calls / final text. +func parseGeminiParts(parts []gemPart) (content, reasoning string, calls []ToolCall, textSig string) { + var text, thought strings.Builder + for i, p := range parts { + switch { + case p.FunctionCall != nil: + args := string(p.FunctionCall.Args) + if strings.TrimSpace(args) == "" { + args = "{}" + } + id := p.FunctionCall.ID + if id == "" { + id = fmt.Sprintf("call_%d_%s", i, p.FunctionCall.Name) + } + calls = append(calls, ToolCall{ + ID: id, + Name: p.FunctionCall.Name, + Arguments: args, + ThoughtSignature: p.ThoughtSignature, + }) + case p.Thought: + thought.WriteString(p.Text) + if p.ThoughtSignature != "" && textSig == "" { + textSig = p.ThoughtSignature + } + default: + if p.Text != "" { + text.WriteString(p.Text) + } + // Final answer parts often carry thoughtSignature without thought:true. + if p.ThoughtSignature != "" { + textSig = p.ThoughtSignature + } + } + } + return text.String(), thought.String(), calls, textSig +} + func (c *geminiClient) Chat(ctx context.Context, req Request) (*Response, error) { + // Prefer stream collection: some Gemini-compatible reverse proxies aggregate + // non-stream generateContent by keeping only the final STOP chunk, which is + // often empty text after a functionCall chunk. streamGenerateContent preserves + // functionCall + thoughtSignature parts (official multi-turn tool contract). + if len(req.Tools) > 0 { + return c.Stream(ctx, req, func(Event) error { return nil }) + } + var raw gemResponse if err := c.opts.doJSON(ctx, "POST", c.endpoint(req.Model, "generateContent", false), c.buildBody(req), c.headers(), &raw); err != nil { return nil, err @@ -274,24 +471,7 @@ func (c *geminiClient) Chat(ctx context.Context, req Request) (*Response, error) if len(raw.Candidates) > 0 { cand := raw.Candidates[0] resp.FinishReason = strings.ToLower(cand.FinishReason) - var text, thought strings.Builder - for i, p := range cand.Content.Parts { - switch { - case p.FunctionCall != nil: - args := string(p.FunctionCall.Args) - if strings.TrimSpace(args) == "" { - args = "{}" - } - resp.ToolCalls = append(resp.ToolCalls, ToolCall{ - ID: fmt.Sprintf("call_%d_%s", i, p.FunctionCall.Name), Name: p.FunctionCall.Name, Arguments: args, - }) - case p.Thought: - thought.WriteString(p.Text) - default: - text.WriteString(p.Text) - } - } - resp.Content, resp.Reasoning = text.String(), thought.String() + resp.Content, resp.Reasoning, resp.ToolCalls, resp.ThoughtSignature = parseGeminiParts(cand.Content.Parts) } if u := raw.UsageMetadata; u != nil { resp.Usage = Usage{ @@ -317,6 +497,7 @@ func (c *geminiClient) Stream(ctx context.Context, req Request, emit func(Event) calls []ToolCall usage Usage finish string + textSig string callSeq int emitted = map[string]bool{} ) @@ -352,14 +533,22 @@ func (c *geminiClient) Stream(ctx context.Context, req Request, emit func(Event) if strings.TrimSpace(args) == "" { args = "{}" } - id := fmt.Sprintf("call_%d_%s", callSeq, p.FunctionCall.Name) + id := p.FunctionCall.ID + if id == "" { + id = fmt.Sprintf("call_%d_%s", callSeq, p.FunctionCall.Name) + } callSeq++ - if emitted[id] { + // Dedupe by provider id or name+args when id missing. + dedupeKey := id + if emitted[dedupeKey] { continue } - emitted[id] = true + emitted[dedupeKey] = true idx := len(calls) - calls = append(calls, ToolCall{ID: id, Name: p.FunctionCall.Name, Arguments: args}) + calls = append(calls, ToolCall{ + ID: id, Name: p.FunctionCall.Name, Arguments: args, + ThoughtSignature: p.ThoughtSignature, + }) if err := emit(Event{Type: EventToolCallStart, Index: idx, ToolCallID: id, ToolName: p.FunctionCall.Name}); err != nil { return err } @@ -367,15 +556,29 @@ func (c *geminiClient) Stream(ctx context.Context, req Request, emit func(Event) return err } case p.Thought: - thought.WriteString(p.Text) - if err := emit(Event{Type: EventReasoning, Delta: p.Text}); err != nil { - return err + if p.Text != "" { + thought.WriteString(p.Text) + if err := emit(Event{Type: EventReasoning, Delta: p.Text}); err != nil { + return err + } + } + if p.ThoughtSignature != "" { + textSig = p.ThoughtSignature } case p.Text != "": text.WriteString(p.Text) if err := emit(Event{Type: EventText, Delta: p.Text}); err != nil { return err } + if p.ThoughtSignature != "" { + textSig = p.ThoughtSignature + } + default: + // Signature-only or empty-text finish chunks still carry + // thoughtSignature that must survive into history. + if p.ThoughtSignature != "" { + textSig = p.ThoughtSignature + } } } } @@ -384,6 +587,17 @@ func (c *geminiClient) Stream(ctx context.Context, req Request, emit func(Event) if err != nil { return nil, err } + // If a functionCall arrived without a signature, inject the CLI dummy so + // the next turn does not 400. Prefer part-level sig already stored. + for i := range calls { + if strings.TrimSpace(calls[i].ThoughtSignature) == "" { + if textSig != "" { + calls[i].ThoughtSignature = textSig + } else { + calls[i].ThoughtSignature = geminiDummyThoughtSignature + } + } + } for i, call := range calls { if err := emit(Event{Type: EventToolCallEnd, Index: i, ToolCallID: call.ID, ToolName: call.Name, Delta: call.Arguments}); err != nil { return nil, err @@ -391,7 +605,7 @@ func (c *geminiClient) Stream(ctx context.Context, req Request, emit func(Event) } return &Response{ Content: text.String(), Reasoning: thought.String(), ToolCalls: calls, - FinishReason: finish, Model: req.Model, Usage: usage, + ThoughtSignature: textSig, FinishReason: finish, Model: req.Model, Usage: usage, }, nil } diff --git a/internal/llm/gemini_test.go b/internal/llm/gemini_test.go new file mode 100644 index 0000000..cbd122b --- /dev/null +++ b/internal/llm/gemini_test.go @@ -0,0 +1,185 @@ +package llm + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestGeminiThinkingConfigUsesLevelForGemini3(t *testing.T) { + tc := geminiThinkingConfig("gemini-3.6-flash-high", "high") + if tc == nil { + t.Fatal("expected thinkingConfig") + } + if tc["thinkingLevel"] != "HIGH" { + t.Fatalf("thinkingLevel = %v, want HIGH", tc["thinkingLevel"]) + } + if tc["includeThoughts"] != true { + t.Fatalf("includeThoughts = %v, want true", tc["includeThoughts"]) + } + if _, hasBudget := tc["thinkingBudget"]; hasBudget { + t.Fatal("gemini-3 should not use thinkingBudget") + } +} + +func TestGeminiThinkingConfigUsesBudgetFor25(t *testing.T) { + tc := geminiThinkingConfig("gemini-2.5-flash", "medium") + if tc == nil { + t.Fatal("expected thinkingConfig") + } + if tc["thinkingBudget"] != 8192 { + t.Fatalf("thinkingBudget = %v, want 8192", tc["thinkingBudget"]) + } + if _, hasLevel := tc["thinkingLevel"]; hasLevel { + t.Fatal("gemini-2.5 should not use thinkingLevel") + } +} + +func TestToGeminiPreservesThoughtSignatureOnFunctionCall(t *testing.T) { + req := Request{ + Messages: []Message{{ + Role: RoleAssistant, + ToolCalls: []ToolCall{{ + ID: "abc123", + Name: "get_weather", + Arguments: `{"city":"Jakarta"}`, + ThoughtSignature: "sig-from-model", + }}, + }}, + } + contents := toGemini(req) + if len(contents) != 1 || contents[0].Role != "model" { + t.Fatalf("contents = %+v", contents) + } + if len(contents[0].Parts) != 1 { + t.Fatalf("parts = %+v", contents[0].Parts) + } + p := contents[0].Parts[0] + if p.FunctionCall == nil || p.FunctionCall.Name != "get_weather" { + t.Fatalf("functionCall = %+v", p.FunctionCall) + } + if p.FunctionCall.ID != "abc123" { + t.Fatalf("functionCall.id = %q", p.FunctionCall.ID) + } + if p.ThoughtSignature != "sig-from-model" { + t.Fatalf("thoughtSignature = %q, want sig-from-model", p.ThoughtSignature) + } +} + +func TestToGeminiInjectsDummySignatureWhenMissing(t *testing.T) { + req := Request{ + Messages: []Message{{ + Role: RoleAssistant, + ToolCalls: []ToolCall{{ + ID: "x", + Name: "read_file", + Arguments: `{"path":"a.go"}`, + }}, + }}, + } + contents := toGemini(req) + sig := contents[0].Parts[0].ThoughtSignature + if sig != geminiDummyThoughtSignature { + t.Fatalf("thoughtSignature = %q, want dummy %q", sig, geminiDummyThoughtSignature) + } +} + +func TestParseGeminiPartsCapturesSignature(t *testing.T) { + parts := []gemPart{ + { + ThoughtSignature: "fc-sig", + FunctionCall: &gemFuncCall{Name: "search_web", Args: json.RawMessage(`{"q":"x"}`), ID: "call1"}, + }, + {Text: "done", ThoughtSignature: "text-sig"}, + } + content, _, calls, textSig := parseGeminiParts(parts) + if content != "done" { + t.Fatalf("content = %q", content) + } + if len(calls) != 1 || calls[0].ThoughtSignature != "fc-sig" { + t.Fatalf("calls = %+v", calls) + } + if calls[0].ID != "call1" { + t.Fatalf("id = %q", calls[0].ID) + } + if textSig != "text-sig" { + t.Fatalf("textSig = %q", textSig) + } +} + +func TestBuildBodyGemini3HighThinking(t *testing.T) { + c := &geminiClient{} + body := c.buildBody(Request{ + Model: "gemini-3.6-flash-high", + ReasoningEffort: "high", + Messages: []Message{{Role: RoleUser, Content: "hi"}}, + }) + gen, _ := body["generationConfig"].(map[string]any) + tc, _ := gen["thinkingConfig"].(map[string]any) + raw, _ := json.Marshal(tc) + if !strings.Contains(string(raw), `"thinkingLevel":"HIGH"`) { + t.Fatalf("thinkingConfig = %s", raw) + } +} + +func TestNormalizeGeminiBaseURLMatchesCLI(t *testing.T) { + cases := map[string]string{ + "http://127.0.0.1:8080/antigravity": "http://127.0.0.1:8080/antigravity/v1beta", + "http://127.0.0.1:8080/antigravity/": "http://127.0.0.1:8080/antigravity/v1beta", + "http://127.0.0.1:8080/antigravity/v1beta": "http://127.0.0.1:8080/antigravity/v1beta", + "https://generativelanguage.googleapis.com/v1beta": "https://generativelanguage.googleapis.com/v1beta", + "http://localhost:8080/v1": "http://localhost:8080/v1", + } + for in, want := range cases { + if got := normalizeGeminiBaseURL(in); got != want { + t.Fatalf("normalizeGeminiBaseURL(%q) = %q, want %q", in, got, want) + } + } +} + +func TestGeminiStickySystemAnchorAndHeaders(t *testing.T) { + sid := "sess-test-123" + anchor := geminiStickySystemAnchor(sid) + if !strings.Contains(anchor, "/.gemini/tmp/") { + t.Fatalf("anchor missing tmp path: %q", anchor) + } + hash := geminiStickyTmpHash(sid) + if len(hash) != 64 { + t.Fatalf("tmp hash len = %d, want 64", len(hash)) + } + if !strings.Contains(anchor, hash) { + t.Fatalf("anchor missing hash: %q", anchor) + } + + h := withGeminiGatewayStickyHeaders(nil, "http://127.0.0.1:8080/antigravity/v1beta", sid) + if h["x-gemini-api-privileged-user-id"] == "" { + t.Fatal("missing privileged-user-id header") + } + if h["session_id"] != sid { + t.Fatalf("session_id = %q", h["session_id"]) + } + // Official Google base should not get sticky headers. + h2 := withGeminiGatewayStickyHeaders(nil, "https://generativelanguage.googleapis.com/v1beta", sid) + if len(h2) != 0 { + t.Fatalf("official API must not inject sticky headers: %v", h2) + } + + c := &geminiClient{opts: Options{ + BaseURL: "http://127.0.0.1:8080/antigravity/v1beta", + SessionID: sid, + }} + body := c.buildBody(Request{ + System: "You are helpful.", + Messages: []Message{{Role: RoleUser, Content: "hi"}}, + }) + sys, _ := body["systemInstruction"].(gemContent) + if len(sys.Parts) < 2 { + t.Fatalf("system parts = %+v, want sticky + user system", sys.Parts) + } + if !strings.Contains(sys.Parts[0].Text, "/.gemini/tmp/"+hash) { + t.Fatalf("first system part = %q", sys.Parts[0].Text) + } + if sys.Parts[1].Text != "You are helpful." { + t.Fatalf("second system part = %q", sys.Parts[1].Text) + } +} diff --git a/internal/llm/types.go b/internal/llm/types.go index 5a20a32..f36a426 100644 --- a/internal/llm/types.go +++ b/internal/llm/types.go @@ -30,6 +30,13 @@ type ToolCall struct { ID string `json:"id"` Name string `json:"name"` Arguments string `json:"arguments"` // raw JSON + // ThoughtSignature is Gemini's encrypted reasoning state that must be + // echoed back on the same functionCall part in the next request. + // Without it, multi-turn tool use returns HTTP 400 ("Function call is + // missing a thought_signature"). Official SDKs and Gemini CLI preserve + // this; when absent, clients inject "skip_thought_signature_validator". + // See https://ai.google.dev/gemini-api/docs/thinking#signatures + ThoughtSignature string `json:"thought_signature,omitempty"` } // Message is one conversation element in provider-neutral form. @@ -41,6 +48,9 @@ type Message struct { ToolCallID string `json:"tool_call_id,omitempty"` Name string `json:"name,omitempty"` Reasoning string `json:"reasoning,omitempty"` + // ThoughtSignature is Gemini part-level metadata for assistant text + // (non-tool) turns. Echoed on the text part when re-sending history. + ThoughtSignature string `json:"thought_signature,omitempty"` // CacheHint marks a prefix boundary for providers that support prompt caching. CacheHint bool `json:"cache_hint,omitempty"` } @@ -106,10 +116,14 @@ type Response struct { Content string `json:"content"` Reasoning string `json:"reasoning,omitempty"` ToolCalls []ToolCall `json:"tool_calls,omitempty"` - FinishReason string `json:"finish_reason"` - Model string `json:"model"` - Usage Usage `json:"usage"` - Raw json.RawMessage + // ThoughtSignature is Gemini part-level metadata for the final text turn. + // Agent history must copy this onto the assistant Message for multi-turn + // continuity when the model is not making tool calls. + ThoughtSignature string `json:"thought_signature,omitempty"` + FinishReason string `json:"finish_reason"` + Model string `json:"model"` + Usage Usage `json:"usage"` + Raw json.RawMessage } // EventType enumerates streaming callbacks. diff --git a/internal/server/handlers_config.go b/internal/server/handlers_config.go index f0d0bc0..7a34561 100644 --- a/internal/server/handlers_config.go +++ b/internal/server/handlers_config.go @@ -338,10 +338,25 @@ func (s *Server) handleModelSet(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, err) return } + prevProvider := cfg.Model.Provider cfg.Model.Default = body.Model if body.Provider != "" { cfg.Model.Provider = body.Provider } + // Drop legacy top-level model.base_url/api_key whenever the active provider + // changes (or on every set as a safety net). Stale CodeBuddy values there + // used to override antigravity/gemini credentials in ResolveProvider. + if body.Provider != "" && body.Provider != prevProvider { + cfg.ClearInlineModelCredentials() + } else if strings.TrimSpace(cfg.Model.BaseURL) != "" || strings.TrimSpace(cfg.Model.APIKey) != "" { + // Even same-provider sets clear leftovers so a prior manual edit cannot + // keep routing through the wrong base_url after the user fixed providers.*. + // Only clear when the named provider already carries its own credentials. + if p, ok := cfg.Providers[cfg.Model.Provider]; ok && + (strings.TrimSpace(p.BaseURL) != "" || strings.TrimSpace(p.APIKey) != "") { + cfg.ClearInlineModelCredentials() + } + } if err := config.Save(cfg); err != nil { writeError(w, http.StatusInternalServerError, err) return diff --git a/internal/tui/pickers.go b/internal/tui/pickers.go index da5618b..32a8c40 100644 --- a/internal/tui/pickers.go +++ b/internal/tui/pickers.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "sort" + "strings" "sync" tea "github.com/charmbracelet/bubbletea" @@ -26,11 +27,11 @@ func (m *Model) openThemePicker() { } } m.openPicker(picker{ - title: "Select a theme", - hint: "previews live", - footer: "type to filter · ↑↓ move · Enter apply · Esc cancel", - items: items, - cursor: cursor, + title: "Select a theme", + hint: "previews live", + footer: "type to filter · ↑↓ move · Enter apply · Esc cancel", + items: items, + cursor: cursor, preview: func(m *Model, it pickerItem) { m.applyTheme(it.id) }, commit: func(m *Model, it pickerItem) { m.applyTheme(it.id) @@ -73,12 +74,17 @@ func (m *Model) collectConfigModels() []modelRef { // fetchableProviders is the active provider alone (when it can be reached), so // the model list only ever reflects the provider currently in use. +// Providers with a non-empty curated models list are skipped — that list is +// already the complete whitelist (no live catalog merge). func (m *Model) fetchableProviders() []string { prov := m.cfg.Model.Provider if prov == "" { return nil } p := m.cfg.Providers[prov] + if len(p.Models) > 0 { + return nil + } if providers.Connected(m.cfg, prov) || p.BaseURL != "" { return []string{prov} } @@ -126,10 +132,17 @@ func (m *Model) openModelPicker() tea.Cmd { items: items, cursor: cursor, commit: func(m *Model, it pickerItem) { + prev := m.cfg.Model.Provider m.cfg.Model.Default = it.id if it.meta != "" { m.cfg.Model.Provider = it.meta } + if m.cfg.Model.Provider != prev { + m.cfg.ClearInlineModelCredentials() + } else if p, ok := m.cfg.Providers[m.cfg.Model.Provider]; ok && + (strings.TrimSpace(p.BaseURL) != "" || strings.TrimSpace(p.APIKey) != "") { + m.cfg.ClearInlineModelCredentials() + } m.saveConfig() m.setStatus("model → " + it.id) },