helmd v2: Go-server som styr agent i tmux — REST+SSE, frågedetektor, fildelning, ntfy, CI-release
Some checks failed
build-and-push / build (push) Successful in 42s
release-client / build-release (push) Has been cancelled
helmd-release / build-release (push) Successful in 1m6s

- 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:
2026-08-05 20:54:54 +02:00
parent 3e7152a884
commit f9c79ac84e
17 changed files with 2075 additions and 0 deletions

View 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

View File

@@ -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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,3 @@
module gitea.brasse-pc.eu/brasse/agent-helm/helmd
go 1.24

137
helmd/main.go Normal file
View 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
View 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
View 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
View 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
View 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

View 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
View 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
View 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
View 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
View 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"
}