helmd v2: Go-server som styr agent i tmux — REST+SSE, frågedetektor, fildelning, ntfy, CI-release
- tmux som sanningskälla (agentoberoende, överlever omstart via adoption) - API: sessions, prompt, question/answer, keys, mode, config, shares, notify, events - frågedetektor testad mot riktiga agy 1.1.9-dumpar + mock - integrationstest: 26 tester i isolerad debian-container (scripts/run-integration.sh) - e2e-verifierad mot riktig agy (trust-fråga -> svar -> prompt) - .gitea/workflows/helmd-release.yaml -> rullande helmd-latest (x64+arm64) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
65
.gitea/workflows/helmd-release.yaml
Normal file
65
.gitea/workflows/helmd-release.yaml
Normal file
@@ -0,0 +1,65 @@
|
||||
name: helmd-release
|
||||
|
||||
# På push till master som rör helmd/: testa, bygg linux x64 (+arm64)
|
||||
# och publicera på den rullande releasen "helmd-latest".
|
||||
on:
|
||||
push:
|
||||
branches: [master, main]
|
||||
paths:
|
||||
- "helmd/**"
|
||||
- ".gitea/workflows/helmd-release.yaml"
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
build-release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Installera Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
cache: false
|
||||
|
||||
- name: Testa + bygg
|
||||
run: |
|
||||
set -e
|
||||
cd helmd
|
||||
VERSION="latest-$(git rev-parse --short HEAD)"
|
||||
go vet ./...
|
||||
go test ./...
|
||||
mkdir -p build
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath \
|
||||
-ldflags "-s -w -X main.version=$VERSION" -o build/helmd-linux-x64 .
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath \
|
||||
-ldflags "-s -w -X main.version=$VERSION" -o build/helmd-linux-arm64 .
|
||||
(cd build && sha256sum helmd-linux-* > checksums.txt && ls -la)
|
||||
|
||||
- name: Skapa/uppdatera release + ladda upp binärer
|
||||
env:
|
||||
API: http://gitea-d:3000/api/v1
|
||||
REPO: ${{ github.repository }}
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -e
|
||||
TAG="helmd-latest"
|
||||
BODY="helmd - agent-helm-servern (Go, enskild binär). Rullande bygge från senaste master. Commit: ${{ github.sha }}. Arkitekturer: linux x64 + arm64."
|
||||
rid=$(curl -s -X POST "$API/repos/$REPO/releases" \
|
||||
-H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"body\":\"$BODY\"}" | jq -r '.id // empty')
|
||||
[ -z "$rid" ] && rid=$(curl -s "$API/repos/$REPO/releases/tags/$TAG" \
|
||||
-H "Authorization: token $TOKEN" | jq -r '.id')
|
||||
echo "release id $rid"
|
||||
for f in helmd-linux-x64 helmd-linux-arm64 checksums.txt; do
|
||||
aid=$(curl -s "$API/repos/$REPO/releases/$rid/assets" \
|
||||
-H "Authorization: token $TOKEN" | jq -r ".[] | select(.name==\"$f\") | .id")
|
||||
if [ -n "$aid" ] && [ "$aid" != "null" ]; then
|
||||
curl -s -X DELETE "$API/repos/$REPO/releases/$rid/assets/$aid" \
|
||||
-H "Authorization: token $TOKEN" -o /dev/null
|
||||
fi
|
||||
curl -s -X POST "$API/repos/$REPO/releases/$rid/assets?name=$f" \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-F "attachment=@helmd/build/$f" -o /dev/null -w " $f -> HTTP %{http_code}\n"
|
||||
done
|
||||
17
README.md
17
README.md
@@ -11,6 +11,23 @@ hoppa in i vilken som helst, var du än är.
|
||||
|
||||
---
|
||||
|
||||
## ⚡ v2 pågår: `helmd`
|
||||
|
||||
Gemini CLI:s OAuth-nedläggning dödade v1-kedjan (daemon → server →
|
||||
hooks). **v2** byter fundament: en enskild Go-binär, [`helmd/`](helmd/),
|
||||
äger en tmux-session med agenten (**agy**) och exponerar REST+SSE —
|
||||
tmux-skärmen är sanningskällan i stället för agent-specifika hooks.
|
||||
|
||||
- Server: klar — se [`helmd/README.md`](helmd/README.md) (API, säkerhet, test)
|
||||
- Plan (server → webbklient → Android): [`doc/plan.md`](doc/plan.md)
|
||||
- Release: rullande [`helmd-latest`](https://gitea.brasse-pc.eu/brasse/agent-helm/releases)
|
||||
(`helmd-linux-x64` + arm64)
|
||||
|
||||
Allt nedanför denna linje beskriver **v1** (Node-stacken i
|
||||
`packages/`), som ligger kvar tills webbklienten är portad.
|
||||
|
||||
---
|
||||
|
||||
## Designprinciper
|
||||
|
||||
1. **Graceful degradation — terminalen funkar alltid.**
|
||||
|
||||
71
doc/plan.md
Normal file
71
doc/plan.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# agent-helm v2 — plan
|
||||
|
||||
## Mål
|
||||
|
||||
Fjärrstyra CLI-agenter (agy i första hand) som "claude-desktop-appen
|
||||
fast för Googles agent": se sessionen, skicka prompts, svara på
|
||||
frågor/godkännanden, byta läge, ändra inställningar och ta emot delade
|
||||
filer — från webb och Android, utan att exponera något publikt.
|
||||
|
||||
## Varför v2
|
||||
|
||||
v1 (Node: daemon→server-på-Pi5→web, Gemini-hooks) dog med Gemini
|
||||
CLI:s OAuth-nedläggning, och hook-integrationen visade sig skör per
|
||||
agent. v2 vänder på det: **tmux är sanningskällan**. Servern läser
|
||||
skärmen och skickar tangenter — då spelar det ingen roll vilken agent
|
||||
som kör eller hur dess hooks ser ut.
|
||||
|
||||
## Arkitektur
|
||||
|
||||
```
|
||||
Android (egen SSH-tunnel) ─┐
|
||||
webb-PWA (LAN/tunnel) ─────┼──► helmd :8788 (Go, REST+SSE, token) ──tmux──► agy
|
||||
│ │
|
||||
└────────┴──► ntfy.brasse-pc.eu (topics: agent-helm, claude)
|
||||
```
|
||||
|
||||
- **Ingen publik exponering**: bind 127.0.0.1 eller LAN; utifrån via
|
||||
SSH-tunnel (mobilklienten sätter upp sin egen mot Pi5 → vidare till
|
||||
värddatorn, alt. helmd på Pi5 direkt).
|
||||
- v1-koden (packages/) ligger kvar tills webbklienten är portad.
|
||||
|
||||
## Steg
|
||||
|
||||
1. ✅ **helmd** (`helmd/`): Go-server, enskild statisk binär.
|
||||
- tmux-hantering: skapa/adoptera/döda sessioner (`helm-*`),
|
||||
paste+Enter för prompts, tangenter, skärmdump med/utan ANSI.
|
||||
- Frågedetektor (`question.go`): agy-godkännanden, trust-prompts,
|
||||
numrerade/onumrerade listor; stabil hash per fråga; testad mot
|
||||
riktiga agy-dumpar + mock.
|
||||
- REST+SSE-API, bearer-token, fildelning (`helmd share`), ntfy
|
||||
(event-pushar + `POST /api/notify`, topics `agent-helm`/`claude`).
|
||||
- Isolerat integrationstest i Docker (26 tester), CI-release
|
||||
`helmd-latest` (x64 + arm64).
|
||||
2. ⬜ **Webbklient v2** (`packages/web`): porta till helmd-API:t.
|
||||
- Sessionslista, skärmvy (poll + SSE), promptfält, frågekort med
|
||||
knappar, läges-/inställningsvy.
|
||||
- Delningsflik: html/md renderas, png/jpg visas, md till html.
|
||||
- Uppdaterad look. Serveras statiskt av helmd (embed) eller nginx.
|
||||
3. ⬜ **Android-klient**: nytt projekt eller Capacitor-port av PWA:n.
|
||||
- Egen SSH-tunnel (t.ex. sshj/JSch) → Pi5 → helmd; token +
|
||||
värdprofil i appen; push via ntfy-appen eller UnifiedPush.
|
||||
|
||||
## Beslut
|
||||
|
||||
- **REST + SSE, inte GraphQL/WS** i v1 — enklast att testa med curl,
|
||||
räcker för kort-UI. WS-terminal kan läggas till senare.
|
||||
- **Go, statisk binär** — samma modell som agent-tools; trivial
|
||||
cross-compile till Pi (arm64).
|
||||
- **tmux-scraping i stället för hooks** — agentoberoende, överlever
|
||||
omstarter (adoption), fallback är alltid rå tangentkanal.
|
||||
- ntfy-topics: `agent-helm` (serverhändelser), `claude` (Claudes
|
||||
direktnotiser). Allowlist i config.
|
||||
|
||||
## Öppna frågor
|
||||
|
||||
- Verifiera agy:s lägesbytes-tangent (Shift+Tab antas).
|
||||
- Webbklient: behålla Vue 3 eller byta till ren Vite+TS utan ramverk?
|
||||
- Android: Capacitor-wrap av PWA:n (snabbast) vs native Kotlin
|
||||
(bäst tunnel-kontroll). Lutar åt Capacitor + termux-liknande
|
||||
tunnel-lib.
|
||||
- helmd på Pi5 i container med tmux? (Kör agenter direkt på Pi:n.)
|
||||
139
helmd/README.md
Normal file
139
helmd/README.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# helmd — agent-helm v2-servern
|
||||
|
||||
En **enskild statisk Go-binär** som äger kommunikationen med en
|
||||
tmux-session där en CLI-agent (agy, claude, …) kör, och exponerar ett
|
||||
**REST-API + SSE** som webb- och mobilklienterna pratar med.
|
||||
|
||||
Designen bygger på insikten från Gemini-OAuth-döden: att integrera mot
|
||||
en specifik agents hook-mekanism är skört. `helmd` behandlar i stället
|
||||
**tmux som sanningskälla** — den läser skärmen, känner igen frågor och
|
||||
skickar tangenter. Fungerar därför med vilken TUI-agent som helst, och
|
||||
överlever att helmd själv startas om (sessioner adopteras tillbaka).
|
||||
|
||||
```
|
||||
webb-PWA / Android ──(LAN eller SSH-tunnel)──► helmd :8788 ──tmux──► agy
|
||||
│
|
||||
└──► ntfy.brasse-pc.eu (pushar till mobilen)
|
||||
```
|
||||
|
||||
## Säkerhetsmodell
|
||||
|
||||
- **Ingen publik exponering.** Servern binder `127.0.0.1:8788` som
|
||||
default. På det privata nätet kan man binda `0.0.0.0:8788`
|
||||
(`helmd serve --listen 0.0.0.0:8788`). Utifrån: SSH-tunnel —
|
||||
`ssh -L 8788:127.0.0.1:8788 <host>` — eller Android-klientens
|
||||
inbyggda tunnel (steg 3 i planen).
|
||||
- **Bearer-token** på alla endpoints. Genereras vid första start,
|
||||
lagras `0600` i `~/.config/helmd/config.json`, visas med
|
||||
`helmd token`. Skickas som `Authorization: Bearer <t>`,
|
||||
`X-Api-Token: <t>` eller `?token=<t>` (för SSE/EventSource och
|
||||
`<img>`-länkar).
|
||||
- Delad HTML serveras med `Content-Security-Policy: sandbox` så att
|
||||
ett delat dokument aldrig kan anropa API:et med klientens token.
|
||||
|
||||
## Kommandon
|
||||
|
||||
```
|
||||
helmd serve [--listen host:port] [--config fil] starta servern
|
||||
helmd share [--note text] [--session namn] <fil> dela en fil till klienterna
|
||||
helmd token skriv ut API-token
|
||||
helmd version
|
||||
```
|
||||
|
||||
`helmd share` är **agentens verktyg för att dela filer** (html, md,
|
||||
png, jpg, …) till klienterna: agenten kör kommandot i sin shell, filen
|
||||
laddas upp till servern och dyker upp i klientens delningsflik.
|
||||
Tillåtna filtyper styrs av `share_exts` i konfigen.
|
||||
|
||||
## API
|
||||
|
||||
Alla svar är JSON om inget annat sägs. Auth krävs överallt.
|
||||
|
||||
| Metod & väg | Gör |
|
||||
|---|---|
|
||||
| `GET /api/health` | `{ok, version, sessions}` |
|
||||
| `GET /api/sessions` | lista sessioner (namn, alive, seq, pending question) |
|
||||
| `POST /api/sessions` | `{name, cmd?, cwd?}` startar agent i ny tmux-session `helm-<name>`; `{name, adopt:true, tmux?}` adopterar befintlig |
|
||||
| `GET /api/sessions/{n}` | en sessions tillstånd |
|
||||
| `DELETE /api/sessions/{n}` | döda sessionen |
|
||||
| `GET /api/sessions/{n}/screen` | pane-text; `?ansi=1` behåller färger, `?history=N` tar med N rader scrollback |
|
||||
| `POST /api/sessions/{n}/prompt` | `{text, submit?:bool}` klistrar in text (radbrytningar ok) och trycker Enter |
|
||||
| `GET /api/sessions/{n}/question` | `{question}` — detekterad väntande fråga eller `null` |
|
||||
| `POST /api/sessions/{n}/answer` | `{option:N}` väljer alternativ N (1-baserat). Numrerade listor väljs med siffertangent, onumrerade navigeras med pil+Enter |
|
||||
| `POST /api/sessions/{n}/keys` | `{keys:["Escape","Down","Enter"]}` råa tmux-tangenter |
|
||||
| `POST /api/sessions/{n}/mode` | `{action:"cycle"}` skickar Shift+Tab (lägesbyte i agy/claude) |
|
||||
| `GET /api/config` / `PUT /api/config` | läs/ändra agentkommando, args, workdir, poll-intervall, ntfy-inställningar (token ändras aldrig via API) |
|
||||
| `GET /api/shares` / `POST /api/shares` | lista / ladda upp (multipart, fält `file`, `note`, `session`) |
|
||||
| `GET /api/shares/{id}` / `…/raw` | metadata / själva filen |
|
||||
| `DELETE /api/shares/{id}` | ta bort delning |
|
||||
| `POST /api/notify` | `{topic?, title?, message, priority?}` → ntfy; topic måste finnas i `allowed_topics` (`agent-helm`, `claude`) |
|
||||
| `GET /api/events` | SSE-ström: `screen` (seq), `question`, `session`, `share` |
|
||||
|
||||
### Frågedetektering
|
||||
|
||||
Pollern läser panen (default var 500:e ms), och `DetectQuestion`
|
||||
känner igen agy:s frågeformer: godkännande-listor (`1. Yes … 4. No`),
|
||||
trust-/val-listor med `>`-markör, och gamla Gemini-CLI:s inramade
|
||||
pickers. Varje fråga får en stabil hash — servern ntfy:ar **en gång
|
||||
per ny fråga** till topic `agent-helm` (kan stängas av med
|
||||
`notify_on_question:false`).
|
||||
|
||||
Verifierat mot riktiga agy 1.1.9-sessioner (trust-prompt,
|
||||
kommandogodkännande) och mot mock i integrationstestet.
|
||||
|
||||
## Konfiguration
|
||||
|
||||
`~/.config/helmd/config.json` (skapas vid första start; override med
|
||||
`HELMD_CONFIG` eller `--config`):
|
||||
|
||||
```json
|
||||
{
|
||||
"listen": "127.0.0.1:8788",
|
||||
"token": "<genereras>",
|
||||
"agent_cmd": "agy",
|
||||
"agent_args": [],
|
||||
"workdir": "/home/brasse",
|
||||
"share_dir": "~/.local/share/helmd/shares",
|
||||
"share_exts": [".html", ".htm", ".md", ".txt", ".json", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".pdf"],
|
||||
"poll_ms": 500,
|
||||
"cols": 200, "rows": 50,
|
||||
"ntfy": {
|
||||
"server": "https://ntfy.brasse-pc.eu",
|
||||
"topic": "agent-helm",
|
||||
"allowed_topics": ["agent-helm", "claude"],
|
||||
"enabled": true,
|
||||
"notify_on_question": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Bygga & testa
|
||||
|
||||
```bash
|
||||
cd helmd
|
||||
go test ./... # enhetstester (frågedetektorn m.m.)
|
||||
CGO_ENABLED=0 go build -o build/helmd .
|
||||
bash scripts/run-integration.sh # 26 API-tester i isolerad Docker-container
|
||||
```
|
||||
|
||||
Integrationstestet startar helmd i en `debian:stable-slim`-container
|
||||
med en mock-agent (`testdata/mock-agent.sh`) som härmar agy:s båda
|
||||
frågetyper, och kör hela API-ytan med curl.
|
||||
|
||||
## CI / release
|
||||
|
||||
`.gitea/workflows/helmd-release.yaml` kör på push till master som rör
|
||||
`helmd/`: `go vet` + `go test`, bygger **linux x64 + arm64** statiskt
|
||||
och publicerar på den rullande releasen **`helmd-latest`**
|
||||
(`helmd-linux-x64`, `helmd-linux-arm64`, `checksums.txt`).
|
||||
|
||||
## Kända begränsningar (v1)
|
||||
|
||||
- `mode cycle` skickar Shift+Tab (BTab) — verifierat tangentnamn i
|
||||
tmux men inte bekräftat att agy byter läge på det; justera via
|
||||
`/keys` om agy använder annan tangent.
|
||||
- Ingen WebSocket-terminal ännu — klienterna pollar `screen` +
|
||||
lyssnar på SSE. Räcker för kort-UI:t; rå terminal-vy kommer med
|
||||
webbklienten (steg 2).
|
||||
- En helmd-instans per maskin/användare; flera agenter = flera
|
||||
sessioner i samma helmd.
|
||||
361
helmd/api.go
Normal file
361
helmd/api.go
Normal file
@@ -0,0 +1,361 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type API struct {
|
||||
cfg *Config
|
||||
mgr *Manager
|
||||
shares *ShareStore
|
||||
ntfy *Ntfy
|
||||
}
|
||||
|
||||
func (a *API) auth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
tok := r.Header.Get("X-Api-Token")
|
||||
if tok == "" {
|
||||
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
|
||||
tok = strings.TrimPrefix(h, "Bearer ")
|
||||
}
|
||||
}
|
||||
if tok == "" {
|
||||
tok = r.URL.Query().Get("token") // SSE/EventSource + <img> links
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(tok), []byte(a.cfg.Token)) != 1 {
|
||||
jsonErr(w, http.StatusUnauthorized, "ogiltig eller saknad token")
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func jsonOut(w http.ResponseWriter, code int, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(code)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func jsonErr(w http.ResponseWriter, code int, msg string) {
|
||||
jsonOut(w, code, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
func readJSON(r *http.Request, v interface{}) error {
|
||||
defer r.Body.Close()
|
||||
return json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(v)
|
||||
}
|
||||
|
||||
func (a *API) routes() *http.ServeMux {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("GET /api/health", a.auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
jsonOut(w, 200, map[string]interface{}{
|
||||
"ok": true, "version": version, "sessions": len(a.mgr.List()),
|
||||
})
|
||||
}))
|
||||
|
||||
mux.HandleFunc("GET /api/sessions", a.auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
jsonOut(w, 200, a.mgr.List())
|
||||
}))
|
||||
|
||||
mux.HandleFunc("POST /api/sessions", a.auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Cmd string `json:"cmd"`
|
||||
Cwd string `json:"cwd"`
|
||||
Adopt bool `json:"adopt"`
|
||||
Tmux string `json:"tmux"`
|
||||
}
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
var (
|
||||
s *SessionState
|
||||
err error
|
||||
)
|
||||
if req.Adopt {
|
||||
s, err = a.mgr.Adopt(req.Name, req.Tmux)
|
||||
} else {
|
||||
s, err = a.mgr.Create(req.Name, req.Cmd, req.Cwd)
|
||||
}
|
||||
if err != nil {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOut(w, 201, s)
|
||||
}))
|
||||
|
||||
mux.HandleFunc("GET /api/sessions/{name}", a.auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
s, ok := a.mgr.Get(r.PathValue("name"))
|
||||
if !ok {
|
||||
jsonErr(w, 404, "ingen sådan session")
|
||||
return
|
||||
}
|
||||
jsonOut(w, 200, s)
|
||||
}))
|
||||
|
||||
mux.HandleFunc("DELETE /api/sessions/{name}", a.auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := a.mgr.Kill(r.PathValue("name")); err != nil {
|
||||
jsonErr(w, 404, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOut(w, 200, map[string]bool{"killed": true})
|
||||
}))
|
||||
|
||||
mux.HandleFunc("GET /api/sessions/{name}/screen", a.auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
ansi := r.URL.Query().Get("ansi") == "1"
|
||||
history, _ := strconv.Atoi(r.URL.Query().Get("history"))
|
||||
screen, err := a.mgr.Screen(r.PathValue("name"), ansi, history)
|
||||
if err != nil {
|
||||
jsonErr(w, 404, err.Error())
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
io.WriteString(w, screen)
|
||||
}))
|
||||
|
||||
mux.HandleFunc("POST /api/sessions/{name}/prompt", a.auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Text string `json:"text"`
|
||||
Submit *bool `json:"submit"`
|
||||
}
|
||||
if err := readJSON(r, &req); err != nil || req.Text == "" {
|
||||
jsonErr(w, 400, "JSON-kropp med \"text\" krävs")
|
||||
return
|
||||
}
|
||||
submit := req.Submit == nil || *req.Submit
|
||||
if err := a.mgr.Prompt(r.PathValue("name"), req.Text, submit); err != nil {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOut(w, 200, map[string]bool{"sent": true})
|
||||
}))
|
||||
|
||||
mux.HandleFunc("GET /api/sessions/{name}/question", a.auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
s, ok := a.mgr.Get(r.PathValue("name"))
|
||||
if !ok {
|
||||
jsonErr(w, 404, "ingen sådan session")
|
||||
return
|
||||
}
|
||||
jsonOut(w, 200, map[string]interface{}{"question": s.Question})
|
||||
}))
|
||||
|
||||
mux.HandleFunc("POST /api/sessions/{name}/answer", a.auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Option int `json:"option"`
|
||||
}
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
if err := a.mgr.Answer(r.PathValue("name"), req.Option); err != nil {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOut(w, 200, map[string]bool{"answered": true})
|
||||
}))
|
||||
|
||||
mux.HandleFunc("POST /api/sessions/{name}/keys", a.auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Keys []string `json:"keys"`
|
||||
}
|
||||
if err := readJSON(r, &req); err != nil || len(req.Keys) == 0 {
|
||||
jsonErr(w, 400, "JSON-kropp med \"keys\": [\"Enter\", ...] krävs")
|
||||
return
|
||||
}
|
||||
if err := a.mgr.Keys(r.PathValue("name"), req.Keys); err != nil {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOut(w, 200, map[string]bool{"sent": true})
|
||||
}))
|
||||
|
||||
// Mode: "cycle" sends the agent's mode-switch key (Shift+Tab in
|
||||
// agy/Claude Code). Arbitrary key sequences via /keys.
|
||||
mux.HandleFunc("POST /api/sessions/{name}/mode", a.auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Action string `json:"action"`
|
||||
}
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
if req.Action != "cycle" {
|
||||
jsonErr(w, 400, "stödd action: \"cycle\"")
|
||||
return
|
||||
}
|
||||
if err := a.mgr.Keys(r.PathValue("name"), []string{"BTab"}); err != nil {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOut(w, 200, map[string]bool{"sent": true})
|
||||
}))
|
||||
|
||||
mux.HandleFunc("GET /api/config", a.auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
c := *a.cfg
|
||||
c.Token = "(dold — se konfigfilen)"
|
||||
jsonOut(w, 200, c)
|
||||
}))
|
||||
|
||||
mux.HandleFunc("PUT /api/config", a.auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
AgentCmd *string `json:"agent_cmd"`
|
||||
AgentArgs *[]string `json:"agent_args"`
|
||||
Workdir *string `json:"workdir"`
|
||||
PollMs *int `json:"poll_ms"`
|
||||
Ntfy *NtfyConfig `json:"ntfy"`
|
||||
}
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
if req.AgentCmd != nil {
|
||||
a.cfg.AgentCmd = *req.AgentCmd
|
||||
}
|
||||
if req.AgentArgs != nil {
|
||||
a.cfg.AgentArgs = *req.AgentArgs
|
||||
}
|
||||
if req.Workdir != nil {
|
||||
a.cfg.Workdir = *req.Workdir
|
||||
}
|
||||
if req.PollMs != nil && *req.PollMs >= 100 {
|
||||
a.cfg.PollMs = *req.PollMs
|
||||
}
|
||||
if req.Ntfy != nil {
|
||||
a.cfg.Ntfy = *req.Ntfy
|
||||
}
|
||||
if err := a.cfg.Save(); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOut(w, 200, map[string]bool{"saved": true})
|
||||
}))
|
||||
|
||||
mux.HandleFunc("GET /api/shares", a.auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
jsonOut(w, 200, a.shares.List())
|
||||
}))
|
||||
|
||||
mux.HandleFunc("POST /api/shares", a.auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseMultipartForm(64 << 20); err != nil {
|
||||
jsonErr(w, 400, "multipart-form med fältet \"file\" krävs: "+err.Error())
|
||||
return
|
||||
}
|
||||
f, hdr, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
jsonErr(w, 400, "fältet \"file\" saknas")
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
sh, err := a.shares.Add(hdr.Filename, r.FormValue("note"), r.FormValue("session"), f)
|
||||
if err != nil {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
a.mgr.emit(Event{Type: "share", Data: sh})
|
||||
jsonOut(w, 201, sh)
|
||||
}))
|
||||
|
||||
mux.HandleFunc("GET /api/shares/{id}", a.auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
sh, err := a.shares.Get(r.PathValue("id"))
|
||||
if err != nil {
|
||||
jsonErr(w, 404, "ingen sådan delning")
|
||||
return
|
||||
}
|
||||
jsonOut(w, 200, sh)
|
||||
}))
|
||||
|
||||
mux.HandleFunc("GET /api/shares/{id}/raw", a.auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
sh, err := a.shares.Get(r.PathValue("id"))
|
||||
if err != nil {
|
||||
jsonErr(w, 404, "ingen sådan delning")
|
||||
return
|
||||
}
|
||||
if sh.Mime != "" {
|
||||
w.Header().Set("Content-Type", sh.Mime)
|
||||
}
|
||||
w.Header().Set("Content-Security-Policy", "sandbox allow-scripts") // shared HTML must not reach the API with our token
|
||||
http.ServeFile(w, r, a.shares.Path(sh))
|
||||
}))
|
||||
|
||||
mux.HandleFunc("DELETE /api/shares/{id}", a.auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := a.shares.Delete(r.PathValue("id")); err != nil {
|
||||
jsonErr(w, 404, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOut(w, 200, map[string]bool{"deleted": true})
|
||||
}))
|
||||
|
||||
mux.HandleFunc("POST /api/notify", a.auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Topic string `json:"topic"`
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Priority string `json:"priority"`
|
||||
}
|
||||
if err := readJSON(r, &req); err != nil || req.Message == "" {
|
||||
jsonErr(w, 400, "JSON-kropp med \"message\" krävs")
|
||||
return
|
||||
}
|
||||
if req.Topic == "" {
|
||||
req.Topic = a.cfg.Ntfy.Topic
|
||||
}
|
||||
if !a.cfg.topicAllowed(req.Topic) {
|
||||
jsonErr(w, 403, "topic inte i allowed_topics")
|
||||
return
|
||||
}
|
||||
if err := a.ntfy.Publish(req.Topic, req.Title, req.Message, req.Priority); err != nil {
|
||||
jsonErr(w, 502, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOut(w, 200, map[string]bool{"published": true})
|
||||
}))
|
||||
|
||||
mux.HandleFunc("GET /api/events", a.auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
fl, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
jsonErr(w, 500, "streaming stöds inte")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
ch := a.mgr.Subscribe()
|
||||
defer a.mgr.Unsubscribe(ch)
|
||||
fmt.Fprintf(w, "event: hello\ndata: {\"version\":%q}\n\n", version)
|
||||
fl.Flush()
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case ev := <-ch:
|
||||
data, _ := json.Marshal(ev)
|
||||
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", ev.Type, data)
|
||||
fl.Flush()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
func serve(cfg *Config) error {
|
||||
if _, err := exec_LookPath("tmux"); err != nil {
|
||||
return fmt.Errorf("tmux hittas inte i PATH — helmd kräver tmux")
|
||||
}
|
||||
ntfy := &Ntfy{cfg: &cfg.Ntfy}
|
||||
mgr := NewManager(cfg, ntfy)
|
||||
shares, err := NewShareStore(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
api := &API{cfg: cfg, mgr: mgr, shares: shares, ntfy: ntfy}
|
||||
log.Printf("helmd %s lyssnar på http://%s (token i %s)", version, cfg.Listen, cfg.path)
|
||||
return http.ListenAndServe(cfg.Listen, api.routes())
|
||||
}
|
||||
135
helmd/config.go
Normal file
135
helmd/config.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type NtfyConfig struct {
|
||||
Server string `json:"server"`
|
||||
Topic string `json:"topic"` // server events (questions etc.)
|
||||
AllowedTopics []string `json:"allowed_topics"` // POST /api/notify may only use these
|
||||
Enabled bool `json:"enabled"`
|
||||
NotifyOnQuestion bool `json:"notify_on_question"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Listen string `json:"listen"`
|
||||
Token string `json:"token"`
|
||||
AgentCmd string `json:"agent_cmd"`
|
||||
AgentArgs []string `json:"agent_args"`
|
||||
Workdir string `json:"workdir"`
|
||||
ShareDir string `json:"share_dir"`
|
||||
ShareExts []string `json:"share_exts"`
|
||||
PollMs int `json:"poll_ms"`
|
||||
Cols int `json:"cols"`
|
||||
Rows int `json:"rows"`
|
||||
Ntfy NtfyConfig `json:"ntfy"`
|
||||
|
||||
path string
|
||||
}
|
||||
|
||||
func defaultConfig() *Config {
|
||||
home, _ := os.UserHomeDir()
|
||||
return &Config{
|
||||
Listen: "127.0.0.1:8788",
|
||||
AgentCmd: "agy",
|
||||
AgentArgs: []string{},
|
||||
Workdir: home,
|
||||
ShareDir: filepath.Join(home, ".local/share/helmd/shares"),
|
||||
ShareExts: []string{".html", ".htm", ".md", ".txt", ".json", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".pdf"},
|
||||
PollMs: 500,
|
||||
Cols: 200,
|
||||
Rows: 50,
|
||||
Ntfy: NtfyConfig{
|
||||
Server: "https://ntfy.brasse-pc.eu",
|
||||
Topic: "agent-helm",
|
||||
AllowedTopics: []string{"agent-helm", "claude"},
|
||||
Enabled: true,
|
||||
NotifyOnQuestion: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func configPath() string {
|
||||
if p := os.Getenv("HELMD_CONFIG"); p != "" {
|
||||
return p
|
||||
}
|
||||
dir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
dir = "."
|
||||
}
|
||||
return filepath.Join(dir, "helmd", "config.json")
|
||||
}
|
||||
|
||||
// loadConfig reads the config file, creating it with defaults (and a
|
||||
// fresh token) on first run.
|
||||
func loadConfig(path string) (*Config, error) {
|
||||
cfg := defaultConfig()
|
||||
cfg.path = path
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
cfg.Token = newToken()
|
||||
if err := cfg.Save(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "helmd: skapade %s (ny token genererad)\n", path)
|
||||
return cfg, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(data, cfg); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
if cfg.Token == "" {
|
||||
cfg.Token = newToken()
|
||||
if err := cfg.Save(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (c *Config) Save() error {
|
||||
if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(c, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(c.path, append(data, '\n'), 0o600)
|
||||
}
|
||||
|
||||
func newToken() string {
|
||||
b := make([]byte, 24)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func (c *Config) extAllowed(name string) bool {
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
for _, e := range c.ShareExts {
|
||||
if ext == e {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *Config) topicAllowed(topic string) bool {
|
||||
for _, t := range c.Ntfy.AllowedTopics {
|
||||
if topic == t {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
3
helmd/go.mod
Normal file
3
helmd/go.mod
Normal file
@@ -0,0 +1,3 @@
|
||||
module gitea.brasse-pc.eu/brasse/agent-helm/helmd
|
||||
|
||||
go 1.24
|
||||
137
helmd/main.go
Normal file
137
helmd/main.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
var version = "dev" // sätts av CI via -ldflags
|
||||
|
||||
var exec_LookPath = exec.LookPath
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
switch os.Args[1] {
|
||||
case "serve":
|
||||
fs := flag.NewFlagSet("serve", flag.ExitOnError)
|
||||
listen := fs.String("listen", "", "override listen-adress (t.ex. 0.0.0.0:8788)")
|
||||
confPath := fs.String("config", configPath(), "sökväg till config.json")
|
||||
fs.Parse(os.Args[2:])
|
||||
cfg, err := loadConfig(*confPath)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
if *listen != "" {
|
||||
cfg.Listen = *listen
|
||||
}
|
||||
if err := serve(cfg); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
|
||||
case "token":
|
||||
cfg, err := loadConfig(configPath())
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
fmt.Println(cfg.Token)
|
||||
|
||||
case "share":
|
||||
fs := flag.NewFlagSet("share", flag.ExitOnError)
|
||||
server := fs.String("server", "http://127.0.0.1:8788", "helmd-serverns adress")
|
||||
note := fs.String("note", "", "beskrivning som visas i klienten")
|
||||
session := fs.String("session", "", "session som delningen hör till")
|
||||
fs.Parse(os.Args[2:])
|
||||
if fs.NArg() != 1 {
|
||||
fatal(fmt.Errorf("användning: helmd share [flaggor] <fil>"))
|
||||
}
|
||||
cfg, err := loadConfig(configPath())
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
id, err := shareFile(*server, cfg.Token, fs.Arg(0), *note, *session)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
fmt.Printf("delad: %s (id %s)\n%s/api/shares/%s/raw\n", filepath.Base(fs.Arg(0)), id, *server, id)
|
||||
|
||||
case "version", "--version", "-v":
|
||||
fmt.Println("helmd", version)
|
||||
|
||||
default:
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintln(os.Stderr, `helmd — agent-helm-server: styr en CLI-agent i tmux via REST-API
|
||||
|
||||
användning:
|
||||
helmd serve [--listen host:port] [--config fil] starta servern
|
||||
helmd share [--note text] [--session namn] <fil> dela en fil till klienterna
|
||||
helmd token skriv ut API-token
|
||||
helmd version`)
|
||||
}
|
||||
|
||||
func fatal(err error) {
|
||||
fmt.Fprintln(os.Stderr, "helmd:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// shareFile uploads a file to a running helmd and returns the share id.
|
||||
func shareFile(server, token, path, note, session string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
fw, err := mw.CreateFormFile("file", filepath.Base(path))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := io.Copy(fw, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
mw.WriteField("note", note)
|
||||
mw.WriteField("session", session)
|
||||
mw.Close()
|
||||
|
||||
req, err := http.NewRequest("POST", server+"/api/shares", &buf)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("servern svarade %s: %s", resp.Status, bytes.TrimSpace(body))
|
||||
}
|
||||
var out struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return out.ID, nil
|
||||
}
|
||||
68
helmd/ntfy.go
Normal file
68
helmd/ntfy.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Ntfy struct {
|
||||
cfg *NtfyConfig
|
||||
}
|
||||
|
||||
// Publish posts a message to the configured ntfy server. Errors are
|
||||
// logged, never fatal — notifications must not take the server down.
|
||||
func (n *Ntfy) Publish(topic, title, message, priority string) error {
|
||||
if !n.cfg.Enabled || n.cfg.Server == "" {
|
||||
return nil
|
||||
}
|
||||
if topic == "" {
|
||||
topic = n.cfg.Topic
|
||||
}
|
||||
url := strings.TrimRight(n.cfg.Server, "/") + "/" + topic
|
||||
req, err := http.NewRequest("POST", url, strings.NewReader(message))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if title != "" {
|
||||
req.Header.Set("X-Title", title)
|
||||
}
|
||||
if priority != "" {
|
||||
req.Header.Set("X-Priority", priority)
|
||||
}
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("ntfy: %v", err)
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
err := fmt.Errorf("ntfy: %s -> %s", topic, resp.Status)
|
||||
log.Print(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *Ntfy) Question(session string, q *Question) {
|
||||
if !n.cfg.NotifyOnQuestion {
|
||||
return
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(q.Text)
|
||||
for i, o := range q.Options {
|
||||
b.WriteString("\n")
|
||||
if o.Num > 0 {
|
||||
fmt.Fprintf(&b, "%d. %s", o.Num, o.Label)
|
||||
} else {
|
||||
fmt.Fprintf(&b, "- %s", o.Label)
|
||||
}
|
||||
if i == q.Selected {
|
||||
b.WriteString(" ←")
|
||||
}
|
||||
}
|
||||
n.Publish("", "helm: "+session+" väntar på svar", b.String(), "high")
|
||||
}
|
||||
211
helmd/question.go
Normal file
211
helmd/question.go
Normal file
@@ -0,0 +1,211 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Question is an interactive prompt detected on the agent's screen:
|
||||
// tool approval ("Do you want to proceed?"), folder trust, ask_question
|
||||
// multiple choice, auth pickers etc.
|
||||
type Question struct {
|
||||
Text string `json:"text"`
|
||||
Options []Option `json:"options"`
|
||||
Selected int `json:"selected"` // index of the ">"-marked row, -1 if unknown
|
||||
Numbered bool `json:"numbered"` // options can be picked with digit keys
|
||||
Hash string `json:"hash"` // stable id: same question => same hash
|
||||
}
|
||||
|
||||
type Option struct {
|
||||
Num int `json:"num"` // 1-based number if the list is numbered, else 0
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
var (
|
||||
numberedRe = regexp.MustCompile(`^\s*(?:[>●○]\s*)?(\d+)\.\s+(.+?)\s*$`)
|
||||
selectedRe = regexp.MustCompile(`^\s*>\s+(\S.*?)\s*$`)
|
||||
indentedRe = regexp.MustCompile(`^\s{2,}(\S.*?)\s*$`)
|
||||
hintRe = regexp.MustCompile(`↑/↓|↵|esc to|ctrl\+|tab Amend|enter Confirm|Navigate|\? for shortcuts|Use Enter to select`)
|
||||
borderRe = regexp.MustCompile(`^[\s│╭╰╮╯]+|[\s│╭╰╮╯]+$`)
|
||||
)
|
||||
|
||||
func cleanLine(l string) string {
|
||||
return borderRe.ReplaceAllString(l, "")
|
||||
}
|
||||
|
||||
func isHint(l string) bool { return hintRe.MatchString(l) }
|
||||
|
||||
// optionish reports whether a raw line can belong to an option block.
|
||||
func optionish(raw string) bool {
|
||||
c := cleanLine(raw)
|
||||
if c == "" || isHint(c) {
|
||||
return false
|
||||
}
|
||||
return numberedRe.MatchString(c) || selectedRe.MatchString(c) || indentedRe.MatchString(raw)
|
||||
}
|
||||
|
||||
// DetectQuestion scans the tail of a pane capture for a pending
|
||||
// interactive question. Returns nil when the agent is not asking.
|
||||
func DetectQuestion(screen string) *Question {
|
||||
lines := strings.Split(strings.TrimRight(screen, "\n"), "\n")
|
||||
if len(lines) > 45 {
|
||||
lines = lines[len(lines)-45:]
|
||||
}
|
||||
|
||||
// Anchor: the last line that is a numbered option or a ">" selection.
|
||||
anchor := -1
|
||||
for i := len(lines) - 1; i >= 0; i-- {
|
||||
c := cleanLine(lines[i])
|
||||
if c == "" || isHint(c) {
|
||||
continue
|
||||
}
|
||||
if numberedRe.MatchString(c) || selectedRe.MatchString(c) {
|
||||
anchor = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if anchor < 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Grow the block over contiguous option-ish lines. Long numbered
|
||||
// labels wrap to column 0 in the TUI, so bridge over up to 3
|
||||
// non-matching lines when another option row lies beyond them.
|
||||
start, end := anchor, anchor
|
||||
for i := start - 1; i >= 0; i-- {
|
||||
if optionish(lines[i]) {
|
||||
start = i
|
||||
continue
|
||||
}
|
||||
c := cleanLine(lines[i])
|
||||
if c == "" || isHint(c) {
|
||||
break
|
||||
}
|
||||
bridged := false
|
||||
for k := i - 1; k >= i-3 && k >= 0; k-- {
|
||||
if numberedRe.MatchString(cleanLine(lines[k])) {
|
||||
start, i, bridged = k, k, true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !bridged {
|
||||
break
|
||||
}
|
||||
}
|
||||
for i := end + 1; i < len(lines); i++ {
|
||||
if optionish(lines[i]) {
|
||||
end = i
|
||||
continue
|
||||
}
|
||||
c := cleanLine(lines[i])
|
||||
if c == "" || isHint(c) {
|
||||
break
|
||||
}
|
||||
bridged := false
|
||||
for k := i + 1; k <= i+3 && k < len(lines); k++ {
|
||||
if numberedRe.MatchString(cleanLine(lines[k])) {
|
||||
end, i, bridged = k, k, true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !bridged {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Safety: a real prompt has a hint line within 3 rows below the
|
||||
// block or a ">" marker in it — a numbered markdown list in normal
|
||||
// agent output has neither.
|
||||
hasMarker := false
|
||||
for i := start; i <= end; i++ {
|
||||
if selectedRe.MatchString(cleanLine(lines[i])) || strings.HasPrefix(strings.TrimSpace(lines[i]), ">") {
|
||||
hasMarker = true
|
||||
break
|
||||
}
|
||||
}
|
||||
hintBelow := false
|
||||
for i := end + 1; i <= end+3 && i < len(lines); i++ {
|
||||
if isHint(cleanLine(lines[i])) {
|
||||
hintBelow = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasMarker && !hintBelow {
|
||||
return nil
|
||||
}
|
||||
|
||||
q := &Question{Selected: -1}
|
||||
for i := start; i <= end; i++ {
|
||||
c := cleanLine(lines[i])
|
||||
if c == "" || isHint(c) {
|
||||
continue
|
||||
}
|
||||
if m := numberedRe.FindStringSubmatch(c); m != nil {
|
||||
q.Options = append(q.Options, Option{Num: atoi(m[1]), Label: m[2]})
|
||||
q.Numbered = true
|
||||
} else if m := selectedRe.FindStringSubmatch(c); m != nil {
|
||||
q.Options = append(q.Options, Option{Label: m[1]})
|
||||
} else if q.Numbered {
|
||||
continue // wrapped continuation of a long numbered label
|
||||
} else if m := indentedRe.FindStringSubmatch(lines[i]); m != nil {
|
||||
q.Options = append(q.Options, Option{Label: m[1]})
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(strings.TrimSpace(lines[i]), ">") {
|
||||
q.Selected = len(q.Options) - 1
|
||||
}
|
||||
}
|
||||
if len(q.Options) < 2 {
|
||||
return nil
|
||||
}
|
||||
if q.Selected < 0 && !q.Numbered {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Question text: nearest lines above the block; a line ending in
|
||||
// "?" anchors it.
|
||||
var txt []string
|
||||
blanks := 0
|
||||
for i := start - 1; i >= 0 && len(txt) < 3; i-- {
|
||||
c := cleanLine(lines[i])
|
||||
if c == "" {
|
||||
blanks++
|
||||
if blanks >= 2 && len(txt) > 0 {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
blanks = 0
|
||||
if isHint(c) {
|
||||
continue
|
||||
}
|
||||
txt = append([]string{c}, txt...)
|
||||
if strings.HasSuffix(c, "?") {
|
||||
break
|
||||
}
|
||||
}
|
||||
q.Text = strings.Join(txt, " ")
|
||||
if q.Text == "" {
|
||||
q.Text = "(fråga utan text)"
|
||||
}
|
||||
|
||||
h := sha256.New()
|
||||
h.Write([]byte(q.Text))
|
||||
for _, o := range q.Options {
|
||||
h.Write([]byte{0})
|
||||
h.Write([]byte(o.Label))
|
||||
}
|
||||
q.Hash = hex.EncodeToString(h.Sum(nil))[:16]
|
||||
return q
|
||||
}
|
||||
|
||||
func atoi(s string) int {
|
||||
n := 0
|
||||
for _, r := range s {
|
||||
n = n*10 + int(r-'0')
|
||||
}
|
||||
return n
|
||||
}
|
||||
126
helmd/question_test.go
Normal file
126
helmd/question_test.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// Real captures from a live agy 1.1.9 session (2026-08-05).
|
||||
|
||||
const agyApproval = ` python3 -c '
|
||||
import json
|
||||
with open("analys.ipynb", "r", encoding="utf-8") as f:
|
||||
⋯ (10 lines hidden)
|
||||
Do you want to proceed?
|
||||
> 1. Yes
|
||||
2. Yes, and always allow in this conversation for commands that start with 'python3 -c '
|
||||
import json
|
||||
with open("analys.ipynb", "r", encoding="utf-8") as f:...'
|
||||
3. Yes, and always allow for commands that start with 'python3 -c '
|
||||
import json
|
||||
with open("analys.ipynb", "r", encoding="utf-8") as f:...' (Persist to settings.json)
|
||||
4. No
|
||||
↑/↓ Navigate · tab Amend · ctrl+g edit/expand command
|
||||
esc to cancel Gemini 3.6 Flash · high`
|
||||
|
||||
const agyTrust = `Accessing workspace:
|
||||
/tmp/scratch/gemini-arena
|
||||
Do you trust the contents of this project?
|
||||
Antigravity CLI requires permission to read, edit, and execute files here.
|
||||
> Yes, I trust this folder
|
||||
No, exit
|
||||
↑/↓ Navigate · enter Confirm
|
||||
Gemini 3.6 Flash · high`
|
||||
|
||||
const agyIdle = ` ▄▀▀▄ Antigravity CLI 1.1.9
|
||||
▀▀▀▀▀▀ brasse.bb@gmail.com (Google AI Pro)
|
||||
────────────────────────────────────────────────────────────
|
||||
>
|
||||
────────────────────────────────────────────────────────────
|
||||
? for shortcuts Gemini 3.6 Flash · high`
|
||||
|
||||
// Agent output containing a markdown numbered list must NOT trigger.
|
||||
const agyMarkdownList = ` Here are 5 targeted CLI tools:
|
||||
1. ntfy: sends push notifications to your phone.
|
||||
2. tea: manages Gitea repositories from the command line.
|
||||
3. skopeo: inspects registry images.
|
||||
────────────────────────────────────────────────────────────
|
||||
>
|
||||
────────────────────────────────────────────────────────────
|
||||
? for shortcuts Gemini 3.6 Flash · high`
|
||||
|
||||
const geminiAuthPicker = ` ╭──────────────────────────────────────────────╮
|
||||
│ ? Get started │
|
||||
│ │
|
||||
│ How would you like to authenticate? │
|
||||
│ │
|
||||
│ ● 1. Sign in with Google │
|
||||
│ 2. Use Gemini API Key │
|
||||
│ 3. Vertex AI │
|
||||
│ │
|
||||
│ (Use Enter to select) │
|
||||
╰──────────────────────────────────────────────╯`
|
||||
|
||||
func TestApprovalPrompt(t *testing.T) {
|
||||
q := DetectQuestion(agyApproval)
|
||||
if q == nil {
|
||||
t.Fatal("expected question, got nil")
|
||||
}
|
||||
if !q.Numbered || len(q.Options) != 4 {
|
||||
t.Fatalf("want 4 numbered options, got %+v", q.Options)
|
||||
}
|
||||
if q.Options[0].Label != "Yes" || q.Options[3].Label != "No" {
|
||||
t.Errorf("bad labels: %+v", q.Options)
|
||||
}
|
||||
if q.Selected != 0 {
|
||||
t.Errorf("want selected=0, got %d", q.Selected)
|
||||
}
|
||||
if q.Text != "Do you want to proceed?" {
|
||||
t.Errorf("bad text: %q", q.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustPrompt(t *testing.T) {
|
||||
q := DetectQuestion(agyTrust)
|
||||
if q == nil {
|
||||
t.Fatal("expected question, got nil")
|
||||
}
|
||||
if q.Numbered || len(q.Options) != 2 {
|
||||
t.Fatalf("want 2 unnumbered options, got %+v", q.Options)
|
||||
}
|
||||
if q.Selected != 0 {
|
||||
t.Errorf("want selected=0, got %d", q.Selected)
|
||||
}
|
||||
if q.Options[1].Label != "No, exit" {
|
||||
t.Errorf("bad option: %+v", q.Options[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdleScreenNoQuestion(t *testing.T) {
|
||||
if q := DetectQuestion(agyIdle); q != nil {
|
||||
t.Fatalf("idle screen misdetected as question: %+v", q)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownListNoQuestion(t *testing.T) {
|
||||
if q := DetectQuestion(agyMarkdownList); q != nil {
|
||||
t.Fatalf("markdown list misdetected as question: %+v", q)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoxedNumberedPicker(t *testing.T) {
|
||||
q := DetectQuestion(geminiAuthPicker)
|
||||
if q == nil {
|
||||
t.Fatal("expected question, got nil")
|
||||
}
|
||||
if len(q.Options) != 3 || !q.Numbered {
|
||||
t.Fatalf("want 3 numbered options, got %+v", q.Options)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSameQuestionSameHash(t *testing.T) {
|
||||
a, b := DetectQuestion(agyApproval), DetectQuestion(agyApproval)
|
||||
if a.Hash != b.Hash {
|
||||
t.Error("hash not stable")
|
||||
}
|
||||
if DetectQuestion(agyTrust).Hash == a.Hash {
|
||||
t.Error("different questions share hash")
|
||||
}
|
||||
}
|
||||
125
helmd/scripts/integration-test.sh
Executable file
125
helmd/scripts/integration-test.sh
Executable file
@@ -0,0 +1,125 @@
|
||||
#!/bin/bash
|
||||
# Integrationstest för helmd. Körs i en isolerad container (se
|
||||
# scripts/run-integration.sh) men fungerar var som helst där tmux,
|
||||
# curl och binären finns och där HOME får smutsas ner.
|
||||
set -eu
|
||||
|
||||
BIN="${HELMD_BIN:-./build/helmd}"
|
||||
export HOME="${TEST_HOME:-$(mktemp -d)}"
|
||||
export HELMD_CONFIG="$HOME/helmd-config.json"
|
||||
API=http://127.0.0.1:8788
|
||||
PASS=0; FAIL=0
|
||||
|
||||
say() { printf '\n== %s\n' "$*"; }
|
||||
ok() { PASS=$((PASS+1)); echo " OK: $*"; }
|
||||
fail() { FAIL=$((FAIL+1)); echo " FAIL: $*" >&2; }
|
||||
|
||||
expect_contains() { # haystackfil, nål, beskrivning
|
||||
if grep -qF "$2" "$1"; then ok "$3"; else fail "$3 — hittade inte: $2"; cat "$1" | tail -20; fi
|
||||
}
|
||||
|
||||
curl_api() { curl -sS -H "Authorization: Bearer $TOKEN" "$@"; }
|
||||
|
||||
wait_for() { # beskrivning, timeout-sek, kommando...
|
||||
local desc="$1" t="$2"; shift 2
|
||||
for _ in $(seq 1 $((t*2))); do
|
||||
if "$@" >/dev/null 2>&1; then ok "$desc"; return 0; fi
|
||||
sleep 0.5
|
||||
done
|
||||
fail "$desc (timeout ${t}s)"; return 1
|
||||
}
|
||||
|
||||
say "startar helmd"
|
||||
TOKEN=$("$BIN" token)
|
||||
"$BIN" serve --listen 127.0.0.1:8788 &
|
||||
HELMD_PID=$!
|
||||
trap 'kill $HELMD_PID 2>/dev/null || true; tmux kill-server 2>/dev/null || true' EXIT
|
||||
wait_for "servern svarar på /api/health" 10 \
|
||||
curl -fsS -H "Authorization: Bearer $TOKEN" $API/api/health
|
||||
|
||||
say "auth"
|
||||
CODE=$(curl -s -o /dev/null -w '%{http_code}' $API/api/sessions)
|
||||
[ "$CODE" = 401 ] && ok "utan token => 401" || fail "utan token gav $CODE"
|
||||
CODE=$(curl -s -o /dev/null -w '%{http_code}' "$API/api/sessions?token=$TOKEN")
|
||||
[ "$CODE" = 200 ] && ok "query-token => 200" || fail "query-token gav $CODE"
|
||||
|
||||
say "stäng av ntfy för testet (PUT /api/config)"
|
||||
curl_api -X PUT -d '{"ntfy":{"server":"https://ntfy.brasse-pc.eu","topic":"agent-helm","allowed_topics":["agent-helm","claude"],"enabled":false,"notify_on_question":false}}' \
|
||||
$API/api/config > /tmp/cfg.json
|
||||
expect_contains /tmp/cfg.json '"saved":true' "config sparad"
|
||||
curl_api $API/api/config | grep -q '"enabled":false' && ok "ntfy avstängd" || fail "ntfy fortfarande på"
|
||||
|
||||
say "session: skapa med mock-agent"
|
||||
MOCK="$(cd "$(dirname "$0")/.." && pwd)/testdata/mock-agent.sh"
|
||||
curl_api -X POST -d "{\"name\":\"prov\",\"cmd\":\"bash $MOCK\",\"cwd\":\"/tmp\"}" \
|
||||
$API/api/sessions > /tmp/sess.json
|
||||
expect_contains /tmp/sess.json '"name":"prov"' "session skapad"
|
||||
|
||||
say "fråga 1: onumrerad trust-fråga (pilnavigering)"
|
||||
q_has_two_options() { curl_api $API/api/sessions/prov/question | grep -q '"No, exit"'; }
|
||||
wait_for "trust-frågan detekterad" 10 q_has_two_options
|
||||
curl_api $API/api/sessions/prov/question > /tmp/q1.json
|
||||
expect_contains /tmp/q1.json '"selected":0' "markören står på första valet"
|
||||
curl_api -X POST -d '{"option":2}' $API/api/sessions/prov/answer > /tmp/a1.json
|
||||
expect_contains /tmp/a1.json '"answered":true' "svar skickat"
|
||||
screen_has() { curl_api "$API/api/sessions/prov/screen" | grep -qF "$1"; }
|
||||
wait_for "mocken fick svaret (ned+enter => val 1)" 10 screen_has "TRUST_ANSWER=1"
|
||||
|
||||
say "prompt"
|
||||
curl_api -X POST -d '{"text":"hej från testet"}' $API/api/sessions/prov/prompt > /dev/null
|
||||
wait_for "prompten nådde agenten" 10 screen_has "ECHO: hej från testet"
|
||||
|
||||
say "fråga 2: numrerad godkännandefråga (sifferval)"
|
||||
curl_api -X POST -d '{"text":"ask-numbered"}' $API/api/sessions/prov/prompt > /dev/null
|
||||
q2_detected() { curl_api $API/api/sessions/prov/question | grep -q '"numbered":true'; }
|
||||
wait_for "numrerad fråga detekterad" 10 q2_detected
|
||||
curl_api -X POST -d '{"option":3}' $API/api/sessions/prov/answer > /dev/null
|
||||
wait_for "mocken fick siffran 3" 10 screen_has "PICKED=3"
|
||||
|
||||
say "raw keys + mode"
|
||||
curl_api -X POST -d '{"keys":["Enter"]}' $API/api/sessions/prov/keys | grep -q '"sent":true' \
|
||||
&& ok "keys-endpoint" || fail "keys-endpoint"
|
||||
curl_api -X POST -d '{"action":"cycle"}' $API/api/sessions/prov/mode | grep -q '"sent":true' \
|
||||
&& ok "mode cycle (BTab)" || fail "mode cycle"
|
||||
|
||||
say "SSE-events"
|
||||
(curl_api -N --max-time 4 "$API/api/events" > /tmp/events.txt 2>/dev/null || true) &
|
||||
SSE_PID=$!
|
||||
sleep 0.5
|
||||
curl_api -X POST -d '{"text":"sse-test"}' $API/api/sessions/prov/prompt > /dev/null
|
||||
wait $SSE_PID || true
|
||||
expect_contains /tmp/events.txt 'event: hello' "SSE hello"
|
||||
expect_contains /tmp/events.txt 'event: screen' "SSE screen-event vid ändring"
|
||||
|
||||
say "fildelning"
|
||||
printf '<h1>rapport</h1>' > /tmp/rapport.html
|
||||
"$BIN" share --note "testrapport" --session prov /tmp/rapport.html > /tmp/share.out
|
||||
expect_contains /tmp/share.out "delad: rapport.html" "helmd share-CLI"
|
||||
curl_api $API/api/shares > /tmp/shares.json
|
||||
expect_contains /tmp/shares.json '"note":"testrapport"' "delningen listas"
|
||||
SHARE_ID=$(grep -o '"id":"[a-f0-9]*"' /tmp/shares.json | head -1 | cut -d'"' -f4)
|
||||
curl_api "$API/api/shares/$SHARE_ID/raw" > /tmp/share.raw
|
||||
expect_contains /tmp/share.raw '<h1>rapport</h1>' "raw-innehåll serveras"
|
||||
printf 'x' > /tmp/otillaten.sh
|
||||
CODE=$(curl_api -s -o /dev/null -w '%{http_code}' -F file=@/tmp/otillaten.sh $API/api/shares)
|
||||
[ "$CODE" = 400 ] && ok "otillåten filtyp => 400" || fail "otillåten filtyp gav $CODE"
|
||||
curl_api -X DELETE "$API/api/shares/$SHARE_ID" | grep -q '"deleted":true' \
|
||||
&& ok "delning raderad" || fail "radering"
|
||||
|
||||
say "notify-regler"
|
||||
CODE=$(curl_api -s -o /dev/null -w '%{http_code}' -X POST -d '{"topic":"hemligt","message":"x"}' $API/api/notify)
|
||||
[ "$CODE" = 403 ] && ok "otillåten topic => 403" || fail "otillåten topic gav $CODE"
|
||||
curl_api -X POST -d '{"topic":"claude","message":"tyst (ntfy disabled)"}' $API/api/notify \
|
||||
| grep -q '"published":true' && ok "tillåten topic => ok (ntfy av => no-op)" || fail "tillåten topic"
|
||||
|
||||
say "session: avsluta + döda"
|
||||
curl_api -X POST -d '{"text":"exit"}' $API/api/sessions/prov/prompt > /dev/null
|
||||
sess_dead() { curl_api $API/api/sessions/prov | grep -q '"alive":false'; }
|
||||
wait_for "exit upptäcks (alive=false)" 10 sess_dead
|
||||
curl_api -X DELETE $API/api/sessions/prov | grep -q '"killed":true' \
|
||||
&& ok "session dödad" || fail "kill"
|
||||
|
||||
echo
|
||||
echo "================================"
|
||||
echo "PASS: $PASS FAIL: $FAIL"
|
||||
[ "$FAIL" = 0 ] && echo "ALLA TESTER GRÖNA" || exit 1
|
||||
14
helmd/scripts/run-integration.sh
Executable file
14
helmd/scripts/run-integration.sh
Executable file
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
# Kör integrationstestet i en isolerad Docker-container.
|
||||
# Binären byggs statiskt på värden; containern får bara tmux+curl.
|
||||
set -eu
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
CGO_ENABLED=0 go build -o build/helmd .
|
||||
docker run --rm \
|
||||
-v "$PWD":/work:ro \
|
||||
-e HELMD_BIN=/work/build/helmd \
|
||||
-e TEST_HOME=/tmp/home \
|
||||
-w /work \
|
||||
debian:stable-slim \
|
||||
bash -c 'apt-get update -qq >/dev/null && apt-get install -yqq tmux curl procps >/dev/null 2>&1 && mkdir -p /tmp/home && bash scripts/integration-test.sh'
|
||||
323
helmd/session.go
Normal file
323
helmd/session.go
Normal file
@@ -0,0 +1,323 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const sessPrefix = "helm-"
|
||||
|
||||
// SessionState is what clients see for one managed tmux session.
|
||||
type SessionState struct {
|
||||
Name string `json:"name"` // API name (without tmux prefix)
|
||||
Tmux string `json:"tmux"` // tmux session name
|
||||
Cmd string `json:"cmd"` // command line launched in the pane
|
||||
Alive bool `json:"alive"` // pane process still running
|
||||
Seq uint64 `json:"seq"` // bumped on every screen change
|
||||
Question *Question `json:"question"` // pending question, if any
|
||||
Changed time.Time `json:"changed"` // last screen change
|
||||
Created time.Time `json:"created"`
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
Type string `json:"type"` // screen | question | session | share
|
||||
Session string `json:"session,omitempty"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
cfg *Config
|
||||
ntfy *Ntfy
|
||||
|
||||
mu sync.Mutex
|
||||
sessions map[string]*SessionState
|
||||
notified map[string]string // session -> last question hash sent to ntfy
|
||||
|
||||
subMu sync.Mutex
|
||||
subs map[chan Event]struct{}
|
||||
}
|
||||
|
||||
func NewManager(cfg *Config, ntfy *Ntfy) *Manager {
|
||||
m := &Manager{
|
||||
cfg: cfg,
|
||||
ntfy: ntfy,
|
||||
sessions: map[string]*SessionState{},
|
||||
notified: map[string]string{},
|
||||
subs: map[chan Event]struct{}{},
|
||||
}
|
||||
m.adoptExisting()
|
||||
go m.pollLoop()
|
||||
return m
|
||||
}
|
||||
|
||||
// adoptExisting re-registers helm-* tmux sessions after a helmd restart:
|
||||
// tmux is the source of truth, helmd only mirrors it.
|
||||
func (m *Manager) adoptExisting() {
|
||||
for _, t := range tmuxListSessions() {
|
||||
if strings.HasPrefix(t, sessPrefix) {
|
||||
name := strings.TrimPrefix(t, sessPrefix)
|
||||
m.sessions[name] = &SessionState{
|
||||
Name: name, Tmux: t, Cmd: "(adopterad)",
|
||||
Created: time.Now(), Changed: time.Now(),
|
||||
}
|
||||
log.Printf("adopterade befintlig tmux-session %s", t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Subscribe() chan Event {
|
||||
ch := make(chan Event, 32)
|
||||
m.subMu.Lock()
|
||||
m.subs[ch] = struct{}{}
|
||||
m.subMu.Unlock()
|
||||
return ch
|
||||
}
|
||||
|
||||
func (m *Manager) Unsubscribe(ch chan Event) {
|
||||
m.subMu.Lock()
|
||||
delete(m.subs, ch)
|
||||
m.subMu.Unlock()
|
||||
}
|
||||
|
||||
func (m *Manager) emit(ev Event) {
|
||||
m.subMu.Lock()
|
||||
for ch := range m.subs {
|
||||
select {
|
||||
case ch <- ev:
|
||||
default: // slow client: drop rather than block the poller
|
||||
}
|
||||
}
|
||||
m.subMu.Unlock()
|
||||
}
|
||||
|
||||
func (m *Manager) List() []*SessionState {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
out := make([]*SessionState, 0, len(m.sessions))
|
||||
for _, s := range m.sessions {
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *Manager) Get(name string) (*SessionState, bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
s, ok := m.sessions[name]
|
||||
return s, ok
|
||||
}
|
||||
|
||||
// Create starts a new tmux session running the agent (or a custom
|
||||
// command). An empty cmd uses the configured agent + args.
|
||||
func (m *Manager) Create(name, cmdline, cwd string) (*SessionState, error) {
|
||||
if name == "" || strings.ContainsAny(name, " \t/.:") {
|
||||
return nil, fmt.Errorf("ogiltigt sessionsnamn %q", name)
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, exists := m.sessions[name]; exists {
|
||||
return nil, fmt.Errorf("sessionen %s finns redan", name)
|
||||
}
|
||||
tmuxName := sessPrefix + name
|
||||
if tmuxHasSession(tmuxName) {
|
||||
return nil, fmt.Errorf("tmux-sessionen %s finns redan (adoptera med POST {\"adopt\":true})", tmuxName)
|
||||
}
|
||||
if cmdline == "" {
|
||||
cmdline = m.cfg.AgentCmd
|
||||
if len(m.cfg.AgentArgs) > 0 {
|
||||
cmdline += " " + strings.Join(m.cfg.AgentArgs, " ")
|
||||
}
|
||||
}
|
||||
if cwd == "" {
|
||||
cwd = m.cfg.Workdir
|
||||
}
|
||||
if err := tmuxNewSession(tmuxName, cwd, m.cfg.Cols, m.cfg.Rows, cmdline); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &SessionState{
|
||||
Name: name, Tmux: tmuxName, Cmd: cmdline, Alive: true,
|
||||
Created: time.Now(), Changed: time.Now(),
|
||||
}
|
||||
m.sessions[name] = s
|
||||
m.emit(Event{Type: "session", Session: name, Data: "created"})
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Adopt registers an already-running tmux session under helmd control.
|
||||
func (m *Manager) Adopt(name, tmuxName string) (*SessionState, error) {
|
||||
if tmuxName == "" {
|
||||
tmuxName = sessPrefix + name
|
||||
}
|
||||
if !tmuxHasSession(tmuxName) {
|
||||
return nil, fmt.Errorf("ingen tmux-session %q", tmuxName)
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, exists := m.sessions[name]; exists {
|
||||
return nil, fmt.Errorf("sessionen %s finns redan", name)
|
||||
}
|
||||
s := &SessionState{
|
||||
Name: name, Tmux: tmuxName, Cmd: "(adopterad)", Alive: true,
|
||||
Created: time.Now(), Changed: time.Now(),
|
||||
}
|
||||
m.sessions[name] = s
|
||||
m.emit(Event{Type: "session", Session: name, Data: "adopted"})
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Kill terminates the tmux session and forgets it.
|
||||
func (m *Manager) Kill(name string) error {
|
||||
m.mu.Lock()
|
||||
s, ok := m.sessions[name]
|
||||
if ok {
|
||||
delete(m.sessions, name)
|
||||
delete(m.notified, name)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
if !ok {
|
||||
return fmt.Errorf("ingen session %q", name)
|
||||
}
|
||||
m.emit(Event{Type: "session", Session: name, Data: "killed"})
|
||||
if tmuxHasSession(s.Tmux) {
|
||||
return tmuxKillSession(s.Tmux)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Prompt pastes text into the session and (by default) submits it.
|
||||
func (m *Manager) Prompt(name, text string, submit bool) error {
|
||||
s, ok := m.Get(name)
|
||||
if !ok {
|
||||
return fmt.Errorf("ingen session %q", name)
|
||||
}
|
||||
if err := tmuxPasteText(s.Tmux, text); err != nil {
|
||||
return err
|
||||
}
|
||||
if submit {
|
||||
time.Sleep(150 * time.Millisecond) // let the TUI ingest the paste
|
||||
return tmuxSendKeys(s.Tmux, "Enter")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Answer picks an option in the pending question. Numbered lists get
|
||||
// the digit key; unnumbered lists are navigated relative to the
|
||||
// current selection.
|
||||
func (m *Manager) Answer(name string, option int) error {
|
||||
s, ok := m.Get(name)
|
||||
if !ok {
|
||||
return fmt.Errorf("ingen session %q", name)
|
||||
}
|
||||
q := s.Question
|
||||
if q == nil {
|
||||
return fmt.Errorf("ingen väntande fråga i %s", name)
|
||||
}
|
||||
if option < 1 || option > len(q.Options) {
|
||||
return fmt.Errorf("option %d utanför 1..%d", option, len(q.Options))
|
||||
}
|
||||
idx := option - 1
|
||||
if q.Numbered {
|
||||
num := q.Options[idx].Num
|
||||
if err := tmuxSendKeys(s.Tmux, fmt.Sprint(num)); err != nil {
|
||||
return err
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
// Digit selection auto-confirms in agy; if the prompt is still
|
||||
// up (pure highlight moved), Enter confirms. Extra Enter on an
|
||||
// already-gone prompt lands in the input box and is harmless
|
||||
// only if empty — so only confirm when the question persists.
|
||||
if cur, err := tmuxCapture(s.Tmux, false, 0); err == nil {
|
||||
if nq := DetectQuestion(cur); nq != nil && nq.Hash == q.Hash {
|
||||
return tmuxSendKeys(s.Tmux, "Enter")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if q.Selected < 0 {
|
||||
return fmt.Errorf("okänd markering — svara med raw keys i stället")
|
||||
}
|
||||
diff := idx - q.Selected
|
||||
key, n := "Down", diff
|
||||
if diff < 0 {
|
||||
key, n = "Up", -diff
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
if err := tmuxSendKeys(s.Tmux, key); err != nil {
|
||||
return err
|
||||
}
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
}
|
||||
return tmuxSendKeys(s.Tmux, "Enter")
|
||||
}
|
||||
|
||||
// Keys sends raw tmux key names (Escape, BTab, C-c, y, ...).
|
||||
func (m *Manager) Keys(name string, keys []string) error {
|
||||
s, ok := m.Get(name)
|
||||
if !ok {
|
||||
return fmt.Errorf("ingen session %q", name)
|
||||
}
|
||||
return tmuxSendKeys(s.Tmux, keys...)
|
||||
}
|
||||
|
||||
func (m *Manager) Screen(name string, ansi bool, history int) (string, error) {
|
||||
s, ok := m.Get(name)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("ingen session %q", name)
|
||||
}
|
||||
return tmuxCapture(s.Tmux, ansi, history)
|
||||
}
|
||||
|
||||
// pollLoop watches every session for screen changes and questions.
|
||||
func (m *Manager) pollLoop() {
|
||||
interval := time.Duration(m.cfg.PollMs) * time.Millisecond
|
||||
if interval <= 0 {
|
||||
interval = 500 * time.Millisecond
|
||||
}
|
||||
hashes := map[string]string{}
|
||||
for {
|
||||
time.Sleep(interval)
|
||||
for _, s := range m.List() {
|
||||
alive := tmuxHasSession(s.Tmux) && tmuxSessionAlive(s.Tmux)
|
||||
if !alive {
|
||||
if s.Alive {
|
||||
s.Alive = false
|
||||
m.emit(Event{Type: "session", Session: s.Name, Data: "exited"})
|
||||
m.ntfy.Publish("", "helm: "+s.Name+" avslutades", "Agentprocessen i "+s.Tmux+" kör inte längre.", "default")
|
||||
}
|
||||
continue
|
||||
}
|
||||
s.Alive = true
|
||||
screen, err := tmuxCapture(s.Tmux, false, 0)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
sum := sha256.Sum256([]byte(screen))
|
||||
h := hex.EncodeToString(sum[:8])
|
||||
if h == hashes[s.Name] {
|
||||
continue
|
||||
}
|
||||
hashes[s.Name] = h
|
||||
s.Seq++
|
||||
s.Changed = time.Now()
|
||||
s.Question = DetectQuestion(screen)
|
||||
m.emit(Event{Type: "screen", Session: s.Name, Data: s.Seq})
|
||||
if s.Question != nil {
|
||||
m.emit(Event{Type: "question", Session: s.Name, Data: s.Question})
|
||||
m.mu.Lock()
|
||||
already := m.notified[s.Name] == s.Question.Hash
|
||||
if !already {
|
||||
m.notified[s.Name] = s.Question.Hash
|
||||
}
|
||||
m.mu.Unlock()
|
||||
if !already {
|
||||
m.ntfy.Question(s.Name, s.Question)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
123
helmd/shares.go
Normal file
123
helmd/shares.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Share is a file the agent published for the clients to view
|
||||
// (reports, screenshots, generated pages...).
|
||||
type Share struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Note string `json:"note,omitempty"`
|
||||
Session string `json:"session,omitempty"`
|
||||
Size int64 `json:"size"`
|
||||
Mime string `json:"mime"`
|
||||
Created time.Time `json:"created"`
|
||||
}
|
||||
|
||||
type ShareStore struct {
|
||||
dir string
|
||||
cfg *Config
|
||||
}
|
||||
|
||||
func NewShareStore(cfg *Config) (*ShareStore, error) {
|
||||
if err := os.MkdirAll(cfg.ShareDir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ShareStore{dir: cfg.ShareDir, cfg: cfg}, nil
|
||||
}
|
||||
|
||||
func (s *ShareStore) metaPath(id string) string { return filepath.Join(s.dir, id+".meta.json") }
|
||||
func (s *ShareStore) filePath(id, name string) string {
|
||||
return filepath.Join(s.dir, id+"_"+filepath.Base(name))
|
||||
}
|
||||
|
||||
// Add stores content under a fresh id and writes a metadata sidecar.
|
||||
func (s *ShareStore) Add(name, note, session string, r io.Reader) (*Share, error) {
|
||||
name = filepath.Base(name)
|
||||
if !s.cfg.extAllowed(name) {
|
||||
return nil, fmt.Errorf("filtyp tillåts inte: %s (tillåtna: %s)",
|
||||
filepath.Ext(name), strings.Join(s.cfg.ShareExts, " "))
|
||||
}
|
||||
idb := make([]byte, 6)
|
||||
if _, err := rand.Read(idb); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id := hex.EncodeToString(idb)
|
||||
f, err := os.Create(s.filePath(id, name))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
size, err := io.Copy(f, r)
|
||||
f.Close()
|
||||
if err != nil {
|
||||
os.Remove(s.filePath(id, name))
|
||||
return nil, err
|
||||
}
|
||||
sh := &Share{
|
||||
ID: id,
|
||||
Name: name,
|
||||
Note: note,
|
||||
Session: session,
|
||||
Size: size,
|
||||
Mime: mime.TypeByExtension(strings.ToLower(filepath.Ext(name))),
|
||||
Created: time.Now(),
|
||||
}
|
||||
meta, _ := json.MarshalIndent(sh, "", " ")
|
||||
if err := os.WriteFile(s.metaPath(id), meta, 0o644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sh, nil
|
||||
}
|
||||
|
||||
func (s *ShareStore) Get(id string) (*Share, error) {
|
||||
data, err := os.ReadFile(s.metaPath(id))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var sh Share
|
||||
if err := json.Unmarshal(data, &sh); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sh, nil
|
||||
}
|
||||
|
||||
func (s *ShareStore) List() []*Share {
|
||||
matches, _ := filepath.Glob(filepath.Join(s.dir, "*.meta.json"))
|
||||
var out []*Share
|
||||
for _, m := range matches {
|
||||
data, err := os.ReadFile(m)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var sh Share
|
||||
if json.Unmarshal(data, &sh) == nil {
|
||||
out = append(out, &sh)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Created.After(out[j].Created) })
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *ShareStore) Delete(id string) error {
|
||||
sh, err := s.Get(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
os.Remove(s.filePath(sh.ID, sh.Name))
|
||||
return os.Remove(s.metaPath(id))
|
||||
}
|
||||
|
||||
// Path returns the on-disk path for serving.
|
||||
func (s *ShareStore) Path(sh *Share) string { return s.filePath(sh.ID, sh.Name) }
|
||||
64
helmd/testdata/mock-agent.sh
vendored
Executable file
64
helmd/testdata/mock-agent.sh
vendored
Executable file
@@ -0,0 +1,64 @@
|
||||
#!/bin/bash
|
||||
# mock-agent — låtsas-agent för helmd:s integrationstester.
|
||||
# Härmar agy-TUI:ns två frågetyper:
|
||||
# 1) onumrerad lista med ">"-markör som navigeras med pilarna + Enter
|
||||
# 2) numrerad lista som väljs med sifferknapp
|
||||
set -u
|
||||
|
||||
echo "MOCK AGENT v1"
|
||||
echo
|
||||
|
||||
# --- Fas 1: trust-fråga (onumrerad, pil-navigerad) --------------------
|
||||
SEL=0
|
||||
draw_trust() {
|
||||
echo
|
||||
echo "Do you trust this test arena?"
|
||||
if [ "$SEL" = 0 ]; then
|
||||
echo "> Yes, run the test"
|
||||
echo " No, exit"
|
||||
else
|
||||
echo " Yes, run the test"
|
||||
echo "> No, exit"
|
||||
fi
|
||||
echo " ↑/↓ Navigate · enter Confirm"
|
||||
}
|
||||
draw_trust
|
||||
while true; do
|
||||
IFS= read -rsn1 c
|
||||
if [ "$c" = $'\e' ]; then
|
||||
read -rsn2 -t 1 rest || rest=""
|
||||
case "$rest" in
|
||||
"[B") SEL=1; draw_trust ;;
|
||||
"[A") SEL=0; draw_trust ;;
|
||||
esac
|
||||
elif [ -z "$c" ]; then # Enter
|
||||
break
|
||||
fi
|
||||
done
|
||||
echo "TRUST_ANSWER=$SEL"
|
||||
echo
|
||||
|
||||
# --- Fas 2: promptloop -------------------------------------------------
|
||||
while true; do
|
||||
printf "> "
|
||||
IFS= read -r line || break
|
||||
case "$line" in
|
||||
ask-numbered)
|
||||
echo
|
||||
echo "Do you want to proceed?"
|
||||
echo "> 1. Yes"
|
||||
echo " 2. Yes, and always allow"
|
||||
echo " 3. No"
|
||||
echo " ↑/↓ Navigate · enter Confirm"
|
||||
IFS= read -rsn1 d
|
||||
echo "PICKED=$d"
|
||||
;;
|
||||
exit)
|
||||
echo "BYE"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "ECHO: $line"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
93
helmd/tmux.go
Normal file
93
helmd/tmux.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// tmuxCmd runs a tmux command and returns its stdout.
|
||||
func tmuxCmd(args ...string) (string, error) {
|
||||
out, err := exec.Command("tmux", args...).CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("tmux %s: %v: %s", strings.Join(args, " "), err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
func tmuxHasSession(name string) bool {
|
||||
err := exec.Command("tmux", "has-session", "-t", "="+name).Run()
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func tmuxListSessions() []string {
|
||||
out, err := tmuxCmd("list-sessions", "-F", "#{session_name}")
|
||||
if err != nil {
|
||||
return nil // no tmux server running = no sessions
|
||||
}
|
||||
var names []string
|
||||
for _, l := range strings.Split(strings.TrimSpace(out), "\n") {
|
||||
if l != "" {
|
||||
names = append(names, l)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func tmuxNewSession(name, cwd string, width, height int, cmdline string) error {
|
||||
args := []string{"new-session", "-d", "-s", name,
|
||||
"-x", fmt.Sprint(width), "-y", fmt.Sprint(height)}
|
||||
if cwd != "" {
|
||||
args = append(args, "-c", cwd)
|
||||
}
|
||||
if cmdline != "" {
|
||||
args = append(args, cmdline)
|
||||
}
|
||||
_, err := tmuxCmd(args...)
|
||||
return err
|
||||
}
|
||||
|
||||
func tmuxKillSession(name string) error {
|
||||
_, err := tmuxCmd("kill-session", "-t", "="+name)
|
||||
return err
|
||||
}
|
||||
|
||||
// tmuxCapture returns the visible pane content. ansi keeps escape
|
||||
// sequences (for terminal-faithful rendering in clients).
|
||||
func tmuxCapture(name string, ansi bool, history int) (string, error) {
|
||||
args := []string{"capture-pane", "-p", "-t", "=" + name + ":"}
|
||||
if ansi {
|
||||
args = append(args, "-e")
|
||||
}
|
||||
if history > 0 {
|
||||
args = append(args, "-S", fmt.Sprintf("-%d", history))
|
||||
}
|
||||
return tmuxCmd(args...)
|
||||
}
|
||||
|
||||
// tmuxSendKeys sends key names (Enter, Up, Down, BTab, C-c, ...).
|
||||
func tmuxSendKeys(name string, keys ...string) error {
|
||||
args := append([]string{"send-keys", "-t", "=" + name + ":"}, keys...)
|
||||
_, err := tmuxCmd(args...)
|
||||
return err
|
||||
}
|
||||
|
||||
// tmuxPasteText inserts literal text (multi-line safe) via a paste
|
||||
// buffer, without submitting it.
|
||||
func tmuxPasteText(name, text string) error {
|
||||
cmd := exec.Command("tmux", "load-buffer", "-b", "helmd", "-")
|
||||
cmd.Stdin = strings.NewReader(text)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("tmux load-buffer: %v: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
_, err := tmuxCmd("paste-buffer", "-d", "-b", "helmd", "-t", "="+name+":")
|
||||
return err
|
||||
}
|
||||
|
||||
func tmuxSessionAlive(name string) bool {
|
||||
out, err := tmuxCmd("list-panes", "-t", "="+name+":", "-F", "#{pane_dead}")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(out) == "0"
|
||||
}
|
||||
Reference in New Issue
Block a user