From 38c1adfcc24f35550f7de1acc4f6b50ef9660ed9 Mon Sep 17 00:00:00 2001 From: Jihad Irfansyah Date: Sun, 2 Aug 2026 23:30:49 +0700 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] =?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 5/7] 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 6/7] 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 7/7] 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}