Update auf Skills

This commit is contained in:
2026-08-29 17:02:35 +02:00
parent d6a50d8a0d
commit 7aec6c39a1
60 changed files with 9937 additions and 50 deletions
+34 -13
View File
@@ -43,7 +43,7 @@ type agentPlan struct {
}
func (a *app) runToolAgent(ctx context.Context, msg string) (agentOutcome, error) {
registry := a.tools
registry := a.skills
if registry == nil {
return agentOutcome{}, fmt.Errorf("Tool-Registry nicht initialisiert")
}
@@ -141,12 +141,20 @@ func (a *app) runToolAgent(ctx context.Context, msg string) (agentOutcome, error
if args == nil {
args = map[string]any{}
}
if a.skills.RequiresConfirmation(name) {
label := a.skills.ActionLabel(name)
a.setPendingToolAction(name, args, label)
a.trace(ctx, "skills", "confirmation_pending", "output", map[string]any{"tool": name, "arguments": args, "label": label})
out.Reply = fmt.Sprintf("Soll ich den Skill „%s“ mit diesen Parametern ausführen?", label)
out.Mode = "skill-confirmation"
return out, nil
}
a.trace(ctx, "agent", "tool_call", "input", map[string]any{"step": step + 1, "tool": name, "arguments": args})
toolStarted := time.Now()
res := registry.Execute(ctx, name, args)
a.traceTimed(ctx, "agent", "tool_result", "output", res, toolStarted)
out.Results = append(out.Results, res)
if !res.Success && isMutationTool(name) {
if !res.Success && a.skills.IsMutation(name) {
writeFailed = true
}
if res.Mutated && res.Success {
@@ -172,11 +180,11 @@ func (a *app) runToolAgent(ctx context.Context, msg string) (agentOutcome, error
}
func (a *app) agentSystemPrompt() string {
return `Du bist JARVIS, der lokale Agent eines Home-Dashboards. Du hast ausschließlich über die bereitgestellten Tools Zugriff auf Kalender, Aufgaben, Erinnerungen, Einkauf, Rezepte, Essensplan, Kanban und Dokumentwissen.
return `Du bist JARVIS, der lokale Agent eines Home-Dashboards. Deine Fähigkeiten werden dynamisch als Skills bereitgestellt. Jeder Skill erscheint technisch als Function/Tool mit festem JSON-Schema. Core-Skills verwalten Kalender, Aufgaben, Erinnerungen, Einkauf, Rezepte, Essensplan, Kanban und Dokumentwissen; Plugin-Skills können zusätzliche Fähigkeiten bereitstellen.
REGELN:
1. Für JEDE Aussage über den aktuellen Home-State zuerst ein passendes Lese-Tool benutzen. Home-State niemals aus Gesprächserinnerung erfinden.
2. Für JEDE gewünschte Änderung ein Schreib-Tool benutzen. Behaupte niemals eine Änderung ohne success=true aus einem Tool-Resultat.
1. Für JEDE Aussage über den aktuellen Home-State zuerst einen passenden Lese-Skill benutzen. Home-State niemals aus Gesprächserinnerung erfinden.
2. Für JEDE gewünschte Änderung einen mutierenden Skill benutzen. Behaupte niemals eine Änderung ohne success=true aus einem Tool-Resultat.
3. Bei update/delete und unklarer ID zuerst das passende list/search Tool verwenden, danach die ID aus dessen Ergebnis.
4. Relative Zeiten NICHT selbst in Kalenderdaten umrechnen. Übergib sie symbolisch im when-Objekt:
- heute => {"kind":"relative","offset_days":0}
@@ -192,12 +200,12 @@ REGELN:
8. Für generierte Essenspläne gilt RECIPE-FIRST: Lies zuerst mit recipes_list das Rezeptbuch. Plane bevorzugt und standardmäßig vorhandene Rezepte und verwende deren recipe_id in mealplan_create. Bei zu wenigen Rezepten darfst du vorhandene Rezepte wiederholen. Freie erfundene Gerichtstitel nur, wenn das Rezeptbuch leer ist oder der Nutzer ausdrücklich ein nicht vorhandenes Gericht verlangt.
9. Wenn ein konkretes bekanntes Rezept gemeint ist, nutze recipes_search/recipes_get und verwende dessen recipe ID. Zum Bearbeiten eines Rezepts nutze recipes_update; bei unbekannter ID zuerst recipes_search oder recipes_list.
10. Für "was muss ich für den Essensplan einkaufen?", "Einkaufsliste aus Essensplan" o. ä. nutze groceries_sync_mealplan. Dieses Tool aggregiert ausschließlich gepflegte Rezeptzutaten; erfinde keine Zutaten. Danach kannst du groceries_list verwenden, wenn nötig.
11. Wenn ein Tool einen Fehler oder mehrere Kandidaten liefert, korrigiere die Arguments oder frage den Nutzer knapp nach der fehlenden Information. Keine freie Vermutung.
11. Wenn ein Skill einen Fehler oder mehrere Kandidaten liefert, korrigiere die Arguments oder frage den Nutzer knapp nach der fehlenden Information. Keine freie Vermutung.
12. RAG/Dokumentwissen ausschließlich über rag_search. Quellen nur nennen, wenn sie im Tool-Ergebnis vorkommen.
13. Eine Nutzeranfrage kann MEHRERE Aufgaben enthalten. Zerlege komplexe Ziele selbstständig in Unteraufgaben und führe mehrere Tool-Aufrufe aus, wenn das Ziel dies erfordert. Beispiel: "Essensplan für die ganze Woche, nur Abendessen" bedeutet mehrere mealplan_create-Aufrufe, typischerweise einen pro Tag.
14. Wenn der Nutzer ausdrücklich "plane", "erstelle", "generiere", "mach mir" o. ä. sagt und Inhalte nicht einzeln vorgibt, ist die Auswahl sinnvoller Vorschläge an dich delegiert. Frage NICHT nach jedem einzelnen Inhalt. Erfinde jedoch keine harten persönlichen Fakten wie Allergien oder Personenzahl.
15. "ganze Woche" ohne weitere Präzisierung bedeutet für Planungsaufgaben die nächsten 7 Kalendertage ab heute. Verwende dafür symbolische relative Zeiten offset_days 0..6. "nächste Woche" bedeutet Montag bis Sonntag der kommenden Kalenderwoche; dafür darfst du system_get_time verwenden und anschließend Wochentage symbolisch planen.
16. Nutze so wenige Tool-Aufrufe wie nötig, aber so viele wie fachlich erforderlich. Nach erfolgreicher Ausführung antworte kurz auf Deutsch. Die tatsächliche Commit-Bestätigung kann vom Server deterministisch überschrieben werden.`
16. Skills dürfen miteinander kombiniert werden. Nutze so wenige Skill-Aufrufe wie nötig, aber so viele wie fachlich erforderlich. Nach erfolgreicher Ausführung antworte kurz auf Deutsch. Die tatsächliche Commit-Bestätigung kann vom Server deterministisch überschrieben werden.`
}
func (a *app) agentSessionHint() string {
@@ -226,6 +234,9 @@ func entityID(res toolResult) string {
}
func toolResultMessage(res toolResult) string {
if strings.TrimSpace(res.Message) != "" {
return strings.TrimSpace(res.Message)
}
if !res.Success {
if res.Error != nil {
return res.Error.Message
@@ -257,6 +268,9 @@ func (a *app) requiresHomeTool(msg string) bool {
return true
}
}
if a.skills != nil && a.skills.MayApply(msg) {
return true
}
// A terse correction immediately after a mutation belongs to the tool session.
a.recentMu.Lock()
recent := a.recent != nil && time.Since(a.recent.At) <= 15*time.Minute
@@ -265,11 +279,11 @@ func (a *app) requiresHomeTool(msg string) bool {
}
func (a *app) selectToolPlanJSON(ctx context.Context, msg string, history []ollama.Turn) (agentPlan, []ollama.ToolCall, bool, error) {
defs := a.tools.Definitions()
defs := a.skills.Definitions()
catalog, _ := json.Marshal(defs)
st := a.db.Snapshot()
planningContext := a.compactPlanningContext(st)
system := `Du bist der JSON-Workflow-Planner von JARVIS. Zerlege die Nutzeranfrage in 1 bis 24 konkrete ausführbare Unteraufgaben. Jede ausführbare Unteraufgabe muss exakt ein registriertes Tool verwenden.
system := `Du bist der JSON-Workflow-Planner von JARVIS. Zerlege die Nutzeranfrage in 1 bis 24 konkrete ausführbare Unteraufgaben. Jede ausführbare Unteraufgabe muss exakt eine registrierte Skill-Aktion verwenden. Die Skill-Aktionen erscheinen im Katalog technisch als Function/Tool-Namen.
Antworte ausschließlich als JSON:
{"goal":"kurzes Ziel","steps":[{"id":"s1","parent":"optional","task":"kurze Beschreibung","tool":"exakter_tool_name","arguments":{...}}],"confidence":0.0,"clarification":"optional"}
@@ -286,7 +300,7 @@ REGELN:
- Update/Delete mit unbekannter ID zuerst als list/search-Schritt planen. Wenn ein Folgeschritt zwingend eine erst zur Laufzeit unbekannte ID benötigt, plane nur den Lese-Schritt; der native Agent kann danach fortsetzen.
- Keine Tools außerhalb des Katalogs. Maximal 24 steps.
- Nur bei wirklich fehlenden harten Angaben, die nicht sinnvoll delegiert wurden, clarification setzen und steps leer lassen.`
prompt := fmt.Sprintf("TOOL-KATALOG:\n%s\n\nPLANUNGS-KONTEXT:\n%s\n\nSESSION:\n%s\n\nNUTZER:\n%s", string(catalog), planningContext, a.agentSessionHint(), msg)
prompt := fmt.Sprintf("SKILL-AKTIONS-KATALOG:\n%s\n\nPLANUNGS-KONTEXT:\n%s\n\nSESSION:\n%s\n\nNUTZER:\n%s", string(catalog), planningContext, a.agentSessionHint(), msg)
a.trace(ctx, "agent", "workflow_plan_request", "input", map[string]any{"message": msg, "catalog_tools": toolNames(defs), "history": history, "planning_context": planningContext, "prompt_estimated_tokens": estimateTokens(prompt)})
started := time.Now()
raw, err := a.ai.ChatWithHistory(ctx, system, history, prompt, true)
@@ -321,7 +335,7 @@ REGELN:
if step.Arguments == nil {
step.Arguments = map[string]any{}
}
if step.Tool == "" || !a.tools.Has(step.Tool) {
if step.Tool == "" || !a.skills.Has(step.Tool) {
continue
}
validSteps = append(validSteps, step)
@@ -611,6 +625,10 @@ func (a *app) toolCommitReply(results []toolResult) string {
if !res.Success || !res.Mutated {
continue
}
if strings.TrimSpace(res.Message) != "" {
lines = append(lines, strings.TrimSpace(res.Message))
continue
}
switch res.Tool {
case "calendar_create", "calendar_update":
if v, ok := decodeToolData[store.CalendarEvent](res.Data); ok {
@@ -707,10 +725,13 @@ func (a *app) toolReadReply(results []toolResult) string {
if last.Error != nil {
return last.Error.Message
}
return "Tool-Anfrage fehlgeschlagen."
return "Skill-Anfrage fehlgeschlagen."
}
if strings.TrimSpace(last.Message) != "" {
return strings.TrimSpace(last.Message)
}
b, _ := json.Marshal(last.Data)
return "Tool-Ergebnis: " + string(b)
return "Skill-Ergebnis: " + string(b)
}
func decodeToolData[T any](v any) (T, bool) {
+5 -2
View File
@@ -180,11 +180,14 @@ func (a *app) handleDebugExport(w http.ResponseWriter, r *http.Request) {
ms := a.ai.Status()
runtimeState := a.debugRuntimeState()
manifest := map[string]any{
"schema": "jarvis.debug.export.v2", "generated_at": time.Now().In(a.timezone).Format(time.RFC3339), "timezone": a.timezone.String(),
"schema": "jarvis.debug.export.v3", "generated_at": time.Now().In(a.timezone).Format(time.RFC3339), "timezone": a.timezone.String(),
"events": len(events), "ollama": ms, "context": a.context, "debug": a.debug.Config(), "rag_chunks": a.rag.Count(),
"state_summary": map[string]any{"events": len(st.Events), "tasks": len(st.Tasks), "reminders": len(st.Reminders), "groceries": len(st.Groceries), "recipes": len(st.Recipes), "meal_plans": len(st.MealPlans), "kanban": len(st.Kanban), "documents": len(st.Documents), "chat_turns": len(st.Chat)},
"runtime": runtimeState,
"agent": map[string]any{"mode": "native-tools", "max_steps": a.context.AgentMaxSteps, "tools": toolNames(a.tools.Definitions())},
"agent": map[string]any{"mode": "skill-mesh-agent", "max_steps": a.context.AgentMaxSteps, "tools": toolNames(a.skills.Definitions())},
"skills": a.skills.Status(),
"mesh": a.mesh.Status(),
"docker_skills": a.docker.Status(r.Context()),
"home_state": debugStateSnapshot(st),
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
+180
View File
@@ -0,0 +1,180 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"sort"
"strings"
"time"
)
type dockerContainerSummary struct {
ID string `json:"Id"`
Names []string `json:"Names"`
Image string `json:"Image"`
State string `json:"State"`
Status string `json:"Status"`
Labels map[string]string `json:"Labels"`
}
type dockerSkillService struct {
ID string `json:"id"`
Name string `json:"name"`
Image string `json:"image"`
State string `json:"state"`
Status string `json:"status"`
Runtime string `json:"runtime,omitempty"`
WorkerName string `json:"worker_name,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
}
type dockerControllerStatus struct {
Enabled bool `json:"enabled"`
Available bool `json:"available"`
Socket string `json:"socket"`
LabelKey string `json:"label_key"`
LabelValue string `json:"label_value"`
Error string `json:"error,omitempty"`
Services []dockerSkillService `json:"services"`
}
type dockerSkillController struct {
app *app
enabled bool
socket string
labelKey string
labelValue string
client *http.Client
}
func newDockerSkillController(a *app) *dockerSkillController {
socket := env("JARVIS_DOCKER_SOCKET", "/var/run/docker.sock")
tr := &http.Transport{DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return (&net.Dialer{Timeout: 3 * time.Second}).DialContext(ctx, "unix", socket)
}}
return &dockerSkillController{
app: a,
enabled: envBool("JARVIS_DOCKER_CONTROLLER_ENABLED", false),
socket: socket,
labelKey: strings.TrimSpace(env("JARVIS_DOCKER_SKILL_LABEL", "com.jarvis.skill-service")),
labelValue: strings.TrimSpace(env("JARVIS_DOCKER_SKILL_LABEL_VALUE", "true")),
client: &http.Client{Transport: tr, Timeout: 8 * time.Second},
}
}
func (d *dockerSkillController) Status(ctx context.Context) dockerControllerStatus {
st := dockerControllerStatus{Enabled: d.enabled, Socket: d.socket, LabelKey: d.labelKey, LabelValue: d.labelValue}
if d == nil || !d.enabled {
return st
}
list, err := d.list(ctx)
if err != nil {
st.Error = err.Error()
return st
}
st.Available = true
st.Services = list
return st
}
func (d *dockerSkillController) list(ctx context.Context) ([]dockerSkillService, error) {
if d == nil || !d.enabled {
return nil, errors.New("Docker-Controller deaktiviert")
}
filters := map[string][]string{"label": {d.labelKey + "=" + d.labelValue}}
fb, _ := json.Marshal(filters)
endpoint := "http://docker/containers/json?all=1&filters=" + url.QueryEscape(string(fb))
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
resp, err := d.client.Do(req)
if err != nil {
return nil, fmt.Errorf("docker.sock nicht erreichbar: %w", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
if resp.StatusCode != 200 {
return nil, fmt.Errorf("Docker API HTTP %d: %s", resp.StatusCode, clipSkillString(string(raw), 800))
}
var cs []dockerContainerSummary
if err := json.Unmarshal(raw, &cs); err != nil {
return nil, err
}
out := make([]dockerSkillService, 0, len(cs))
for _, c := range cs {
if c.Labels[d.labelKey] != d.labelValue {
continue
}
name := ""
if len(c.Names) > 0 {
name = strings.TrimPrefix(c.Names[0], "/")
}
out = append(out, dockerSkillService{ID: c.ID, Name: name, Image: c.Image, State: c.State, Status: c.Status, Runtime: c.Labels["com.jarvis.skill-runtime"], WorkerName: c.Labels["com.jarvis.worker-name"], Labels: map[string]string{d.labelKey: c.Labels[d.labelKey], "com.jarvis.skill-runtime": c.Labels["com.jarvis.skill-runtime"], "com.jarvis.worker-name": c.Labels["com.jarvis.worker-name"]}})
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
}
func (d *dockerSkillController) control(ctx context.Context, idv, action string) error {
if d == nil || !d.enabled {
return errors.New("Docker-Controller deaktiviert")
}
services, err := d.list(ctx)
if err != nil {
return err
}
var selected *dockerSkillService
for i := range services {
if services[i].ID == idv {
selected = &services[i]
break
}
}
if selected == nil {
return errors.New("Container ist nicht für JARVIS Skill Control freigegeben")
}
var path string
switch action {
case "start":
path = "/containers/" + url.PathEscape(idv) + "/start"
case "stop":
path = "/containers/" + url.PathEscape(idv) + "/stop?t=10"
case "restart":
path = "/containers/" + url.PathEscape(idv) + "/restart?t=10"
default:
return errors.New("unbekannte Docker-Aktion")
}
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, "http://docker"+path, bytes.NewReader(nil))
started := time.Now()
resp, err := d.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("Docker API HTTP %d: %s", resp.StatusCode, clipSkillString(strings.TrimSpace(string(raw)), 600))
}
if d.app != nil {
d.app.traceTimed(ctx, "docker", "skill_service_control", "output", map[string]any{"container_id": idv, "container": selected.Name, "action": action}, started)
}
return nil
}
func (d *dockerSkillController) handleList(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, d.Status(r.Context()))
}
func (d *dockerSkillController) handleControl(w http.ResponseWriter, r *http.Request) {
action := r.PathValue("action")
idv := r.PathValue("id")
if err := d.control(r.Context(), idv, action); err != nil {
writeJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
writeJSON(w, 200, map[string]any{"ok": true, "action": action, "id": idv})
}
+71
View File
@@ -0,0 +1,71 @@
package main
import (
"context"
"encoding/json"
"net"
"net/http"
"path/filepath"
"testing"
)
func TestDockerControllerOnlyControlsLabelledContainers(t *testing.T) {
sock := filepath.Join(t.TempDir(), "docker.sock")
ln, err := net.Listen("unix", sock)
if err != nil {
t.Fatal(err)
}
defer ln.Close()
controlled := false
mux := http.NewServeMux()
mux.HandleFunc("GET /containers/json", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode([]dockerContainerSummary{
{
ID: "allowed123",
Names: []string{"/skill-python"},
Image: "worker",
State: "exited",
Status: "Exited",
Labels: map[string]string{
"com.jarvis.skill-service": "true",
"com.jarvis.skill-runtime": "python",
},
},
{
ID: "other999",
Names: []string{"/db"},
Image: "db",
State: "running",
Labels: map[string]string{"other": "true"},
},
})
})
mux.HandleFunc("POST /containers/allowed123/start", func(w http.ResponseWriter, r *http.Request) {
controlled = true
w.WriteHeader(http.StatusNoContent)
})
srv := &http.Server{Handler: mux}
go srv.Serve(ln)
defer srv.Close()
t.Setenv("JARVIS_DOCKER_CONTROLLER_ENABLED", "true")
t.Setenv("JARVIS_DOCKER_SOCKET", sock)
t.Setenv("JARVIS_DOCKER_SKILL_LABEL", "com.jarvis.skill-service")
t.Setenv("JARVIS_DOCKER_SKILL_LABEL_VALUE", "true")
d := newDockerSkillController(nil)
st := d.Status(context.Background())
if !st.Available || len(st.Services) != 1 || st.Services[0].ID != "allowed123" {
t.Fatalf("bad status: %+v", st)
}
if err := d.control(context.Background(), "other999", "start"); err == nil {
t.Fatalf("unlabelled container was controllable")
}
if err := d.control(context.Background(), "allowed123", "start"); err != nil {
t.Fatal(err)
}
if !controlled {
t.Fatalf("start not called")
}
}
+45 -6
View File
@@ -46,6 +46,9 @@ type app struct {
recentMu sync.Mutex
recent *recentMutation
tools *toolRegistry
skills *skillRegistry
mesh *skillMesh
docker *dockerSkillController
debug *debugtrace.Logger
}
@@ -181,6 +184,16 @@ func main() {
},
}
a.tools = newToolRegistry(a)
a.mesh = newSkillMesh(a)
a.skills = newSkillRegistry(a, a.tools, env("JARVIS_SKILLS_DIR", "./skills"))
a.docker = newDockerSkillController(a)
a.mesh.Start()
meshStatus := a.mesh.Status()
log.Printf("Skill Mesh: enabled=%v enrollment=%v lease=%ds", meshStatus.Enabled, meshStatus.EnrollmentReady, meshStatus.LeaseSeconds)
dockerStatus := a.docker.Status(context.Background())
if dockerStatus.Enabled {
log.Printf("Docker Skill Controller: available=%v socket=%s label=%s=%s error=%s", dockerStatus.Available, dockerStatus.Socket, dockerStatus.LabelKey, dockerStatus.LabelValue, dockerStatus.Error)
}
voiceStatus := a.voice.Status(wakeWord)
log.Printf("Voice erkannt: stt=%v tts=%v ffmpeg=%v whisperBin=%v whisperModel=%v piperBin=%v piperModel=%v", voiceStatus.STTReady, voiceStatus.TTSReady, voiceStatus.FFmpegReady, voiceStatus.WhisperBinReady, voiceStatus.WhisperModelReady, voiceStatus.PiperBinReady, voiceStatus.PiperModelReady)
if !voiceStatus.STTReady {
@@ -219,6 +232,16 @@ func main() {
mux.HandleFunc("POST /api/voice/speak", a.handleSpeak)
mux.HandleFunc("GET /api/health", a.handleHealth)
mux.HandleFunc("GET /api/agent/tools", a.handleAgentTools)
mux.HandleFunc("GET /api/skills", a.handleSkills)
mux.HandleFunc("POST /api/skills/reload", a.handleSkillsReload)
mux.HandleFunc("POST /api/mesh/enroll", a.mesh.handleEnroll)
mux.HandleFunc("GET /api/mesh/workers", a.mesh.handleWorkers)
mux.HandleFunc("PUT /api/mesh/workers/{id}/skills", a.mesh.handleRegister)
mux.HandleFunc("POST /api/mesh/workers/{id}/heartbeat", a.mesh.handleHeartbeat)
mux.HandleFunc("POST /api/mesh/workers/{id}/reload", a.mesh.handleReloadWorker)
mux.HandleFunc("DELETE /api/mesh/workers/{id}", a.mesh.handleDeregister)
mux.HandleFunc("GET /api/docker/skill-services", a.docker.handleList)
mux.HandleFunc("POST /api/docker/skill-services/{id}/{action}", a.docker.handleControl)
mux.HandleFunc("GET /api/debug/export", a.handleDebugExport)
mux.HandleFunc("GET /api/debug/export.jsonl", a.handleDebugExportJSONL)
mux.HandleFunc("DELETE /api/debug", a.handleDebugClear)
@@ -257,23 +280,39 @@ func (a *app) handleState(w http.ResponseWriter, r *http.Request) {
st := a.db.Snapshot()
ms := a.ai.Status()
writeJSON(w, 200, systemState{State: st, System: map[string]any{
"ragChunks": a.rag.Count(), "voice": a.voice.Status(a.wakeWord), "model": ms.Model, "embeddingModel": ms.EmbeddingModel, "ollama": ms, "context": a.context, "debug": a.debug.Config(), "agent": map[string]any{"mode": "native-tools", "tools": toolNames(a.tools.Definitions()), "maxSteps": a.context.AgentMaxSteps},
"ragChunks": a.rag.Count(), "voice": a.voice.Status(a.wakeWord), "model": ms.Model, "embeddingModel": ms.EmbeddingModel, "ollama": ms, "context": a.context, "debug": a.debug.Config(), "agent": map[string]any{"mode": "skill-mesh-agent", "tools": toolNames(a.skills.Definitions()), "maxSteps": a.context.AgentMaxSteps}, "skills": a.skills.Status(), "mesh": a.mesh.Status(), "dockerSkills": a.docker.Status(r.Context()),
}})
}
func (a *app) handleHealth(w http.ResponseWriter, r *http.Request) {
ms := a.ai.Status()
writeJSON(w, 200, map[string]any{"ok": true, "model": ms.Model, "embeddingModel": ms.EmbeddingModel, "ollama": ms, "ragChunks": a.rag.Count(), "voice": a.voice.Status(a.wakeWord), "context": a.context, "debug": a.debug.Config(), "agent": map[string]any{"mode": "native-tools", "tools": toolNames(a.tools.Definitions()), "maxSteps": a.context.AgentMaxSteps}})
writeJSON(w, 200, map[string]any{"ok": true, "model": ms.Model, "embeddingModel": ms.EmbeddingModel, "ollama": ms, "ragChunks": a.rag.Count(), "voice": a.voice.Status(a.wakeWord), "context": a.context, "debug": a.debug.Config(), "agent": map[string]any{"mode": "skill-mesh-agent", "tools": toolNames(a.skills.Definitions()), "maxSteps": a.context.AgentMaxSteps}, "skills": a.skills.Status(), "mesh": a.mesh.Status(), "dockerSkills": a.docker.Status(r.Context())})
}
func (a *app) handleAgentTools(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{
"mode": "native-tools",
"mode": "skill-mesh-agent",
"maxSteps": a.context.AgentMaxSteps,
"tools": a.tools.Definitions(),
"tools": a.skills.Definitions(),
"skills": a.skills.Status(),
})
}
func (a *app) handleSkills(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, a.skills.Status())
}
func (a *app) handleSkillsReload(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
err := a.skills.Reload(ctx)
status := a.skills.Status()
if err != nil {
writeJSON(w, 200, map[string]any{"ok": false, "warning": err.Error(), "skills": status})
return
}
writeJSON(w, 200, map[string]any{"ok": true, "skills": status})
}
func (a *app) handleTaskUpsert(w http.ResponseWriter, r *http.Request) {
var v store.Task
if !decodeJSON(w, r, &v) {
@@ -513,7 +552,7 @@ func (a *app) handleAIChat(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Nachricht fehlt", 400)
return
}
a.trace(baseCtx, "command", "user_input", "input", map[string]any{"message": req.Message, "timezone": a.timezone.String(), "architecture": "native-tool-agent"})
a.trace(baseCtx, "command", "user_input", "input", map[string]any{"message": req.Message, "timezone": a.timezone.String(), "architecture": "skill-agent"})
a.trace(baseCtx, "state", "before_command", "input", debugStateSnapshot(a.db.Snapshot()))
// One household command is processed atomically at the orchestration level.
@@ -704,7 +743,7 @@ func (a *app) runScout(parent context.Context, reason string) (int, error) {
}
}
a.trace(ctx, "scout", "tool_call", "input", map[string]any{"tool": "tasks_create", "arguments": args})
res := a.tools.Execute(ctx, "tasks_create", args)
res := a.skills.Execute(ctx, "tasks_create", args)
a.trace(ctx, "scout", "tool_result", "output", res)
if res.Success && res.Mutated {
created++
+696
View File
@@ -0,0 +1,696 @@
package main
import (
"bytes"
"context"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strings"
"sync"
"time"
)
const (
skillWorkerProtocolV1 = "jarvis.skill.worker.v1"
skillInvokeProtocolV1 = "jarvis.skill.invoke.v1"
)
type meshWorkerHello struct {
Name string `json:"name"`
Runtime string `json:"runtime"`
RuntimeVersion string `json:"runtime_version,omitempty"`
Endpoint string `json:"endpoint"`
Labels map[string]string `json:"labels,omitempty"`
}
type meshEnrollRequest struct {
Protocol string `json:"protocol"`
EnrollmentToken string `json:"enrollment_token"`
Worker meshWorkerHello `json:"worker"`
}
type meshEnrollResponse struct {
Protocol string `json:"protocol"`
WorkerID string `json:"worker_id"`
AccessToken string `json:"access_token"`
LeaseSeconds int `json:"lease_seconds"`
MasterTime string `json:"master_time"`
}
type meshSkillRegistration struct {
Protocol string `json:"protocol"`
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
Description string `json:"description,omitempty"`
Actions []skillActionManifest `json:"actions"`
}
type meshRegistryRequest struct {
Protocol string `json:"protocol"`
Skills []meshSkillRegistration `json:"skills"`
}
type meshHeartbeatRequest struct {
Protocol string `json:"protocol"`
Status map[string]any `json:"status,omitempty"`
}
type meshWorker struct {
ID string
Name string
Runtime string
RuntimeVersion string
Endpoint string
Labels map[string]string
AccessToken string
Skills []meshSkillRegistration
LastSeen time.Time
ExpiresAt time.Time
RegisteredAt time.Time
}
type meshWorkerStatus struct {
ID string `json:"id"`
Name string `json:"name"`
Runtime string `json:"runtime"`
RuntimeVersion string `json:"runtime_version,omitempty"`
Endpoint string `json:"endpoint"`
Labels map[string]string `json:"labels,omitempty"`
State string `json:"state"`
SkillCount int `json:"skill_count"`
ActionCount int `json:"action_count"`
LastSeen string `json:"last_seen"`
ExpiresAt string `json:"expires_at"`
RegisteredAt string `json:"registered_at"`
}
type meshStatus struct {
Protocol string `json:"protocol"`
Enabled bool `json:"enabled"`
LeaseSeconds int `json:"lease_seconds"`
EnrollmentReady bool `json:"enrollment_ready"`
Workers []meshWorkerStatus `json:"workers"`
WorkerCount int `json:"worker_count"`
OnlineCount int `json:"online_count"`
RemoteActions int `json:"remote_actions"`
}
type meshRemoteAction struct {
ToolName string
SkillID string
SkillName string
Version string
Description string
Action skillActionManifest
ProviderIDs []string
}
type skillMesh struct {
app *app
enabled bool
enrollmentToken string
lease time.Duration
invokeTimeout time.Duration
client *http.Client
mu sync.RWMutex
workers map[string]*meshWorker
rr map[string]int
}
func newSkillMesh(a *app) *skillMesh {
leaseSeconds := envInt("JARVIS_MESH_LEASE_SECONDS", 30)
if leaseSeconds < 10 {
leaseSeconds = 10
}
if leaseSeconds > 600 {
leaseSeconds = 600
}
invokeMS := envInt("JARVIS_MESH_INVOKE_TIMEOUT_MS", 30000)
if invokeMS < 500 {
invokeMS = 500
}
if invokeMS > 300000 {
invokeMS = 300000
}
m := &skillMesh{
app: a,
enabled: envBool("JARVIS_MESH_ENABLED", true),
enrollmentToken: strings.TrimSpace(env("JARVIS_MESH_ENROLLMENT_TOKEN", "")),
lease: time.Duration(leaseSeconds) * time.Second,
invokeTimeout: time.Duration(invokeMS) * time.Millisecond,
client: &http.Client{Timeout: time.Duration(invokeMS) * time.Millisecond},
workers: map[string]*meshWorker{},
rr: map[string]int{},
}
return m
}
func (m *skillMesh) Start() {
if m == nil || !m.enabled {
return
}
go func() {
tick := time.NewTicker(maxDuration(5*time.Second, m.lease/3))
defer tick.Stop()
for range tick.C {
m.reapExpired()
}
}()
}
func maxDuration(a, b time.Duration) time.Duration {
if a > b {
return a
}
return b
}
func (m *skillMesh) reapExpired() {
now := time.Now()
removed := []string{}
m.mu.Lock()
for id, w := range m.workers {
if !w.ExpiresAt.IsZero() && now.After(w.ExpiresAt) {
removed = append(removed, id)
delete(m.workers, id)
}
}
m.mu.Unlock()
if len(removed) > 0 {
if m.app != nil {
m.app.trace(context.Background(), "mesh", "workers_expired", "output", map[string]any{"worker_ids": removed})
if m.app.skills != nil {
_ = m.app.skills.Reload(context.Background())
}
}
}
}
func (m *skillMesh) Status() meshStatus {
if m == nil {
return meshStatus{Protocol: skillWorkerProtocolV1}
}
now := time.Now()
m.mu.RLock()
defer m.mu.RUnlock()
out := meshStatus{Protocol: skillWorkerProtocolV1, Enabled: m.enabled, LeaseSeconds: int(m.lease.Seconds()), EnrollmentReady: m.enrollmentToken != ""}
actionSet := map[string]bool{}
for _, w := range m.workers {
actions := 0
for _, s := range w.Skills {
actions += len(s.Actions)
for _, a := range s.Actions {
tn := strings.TrimSpace(a.ToolName)
if tn == "" {
tn = pluginToolName(s.ID, a.Name)
}
actionSet[tn] = true
}
}
state := "online"
if now.After(w.ExpiresAt) {
state = "offline"
} else {
out.OnlineCount++
}
out.Workers = append(out.Workers, meshWorkerStatus{ID: w.ID, Name: w.Name, Runtime: w.Runtime, RuntimeVersion: w.RuntimeVersion, Endpoint: w.Endpoint, Labels: cloneStringMap(w.Labels), State: state, SkillCount: len(w.Skills), ActionCount: actions, LastSeen: w.LastSeen.Format(time.RFC3339), ExpiresAt: w.ExpiresAt.Format(time.RFC3339), RegisteredAt: w.RegisteredAt.Format(time.RFC3339)})
}
sort.Slice(out.Workers, func(i, j int) bool { return out.Workers[i].Name < out.Workers[j].Name })
out.WorkerCount = len(out.Workers)
out.RemoteActions = len(actionSet)
return out
}
func cloneStringMap(in map[string]string) map[string]string {
if len(in) == 0 {
return nil
}
out := make(map[string]string, len(in))
for k, v := range in {
out[k] = v
}
return out
}
func (m *skillMesh) RemoteActions() []meshRemoteAction {
if m == nil || !m.enabled {
return nil
}
now := time.Now()
grouped := map[string]*meshRemoteAction{}
m.mu.RLock()
defer m.mu.RUnlock()
ids := make([]string, 0, len(m.workers))
for id := range m.workers {
ids = append(ids, id)
}
sort.Strings(ids)
for _, id := range ids {
w := m.workers[id]
if now.After(w.ExpiresAt) {
continue
}
for _, s := range w.Skills {
for _, a := range s.Actions {
tn := strings.TrimSpace(a.ToolName)
if tn == "" {
tn = pluginToolName(s.ID, a.Name)
}
x := grouped[tn]
if x == nil {
cp := a
cp.ToolName = tn
x = &meshRemoteAction{ToolName: tn, SkillID: s.ID, SkillName: s.Name, Version: s.Version, Description: firstNonEmpty(a.Description, s.Description, "Remote Skill "+s.Name), Action: cp}
grouped[tn] = x
}
// Only replicas of the same logical skill/action may share a tool name.
if x.SkillID == s.ID && x.Action.Name == a.Name {
x.ProviderIDs = append(x.ProviderIDs, id)
}
}
}
}
out := make([]meshRemoteAction, 0, len(grouped))
for _, x := range grouped {
sort.Strings(x.ProviderIDs)
out = append(out, *x)
}
sort.Slice(out, func(i, j int) bool { return out[i].ToolName < out[j].ToolName })
return out
}
func firstNonEmpty(v ...string) string {
for _, s := range v {
if strings.TrimSpace(s) != "" {
return strings.TrimSpace(s)
}
}
return ""
}
func (m *skillMesh) Invoke(ctx context.Context, toolName string, args map[string]any) toolResult {
requestID := id("remote")
remote, ok := m.remoteAction(toolName)
if !ok {
return failTool(toolName, requestID, "REMOTE_SKILL_UNAVAILABLE", "Kein aktiver Remote-Worker stellt diese Skill-Aktion bereit")
}
if err := validateJSONAgainstSchema(args, remote.Action.InputSchema, "input"); err != nil {
return failTool(toolName, requestID, "INVALID_SKILL_INPUT", err.Error())
}
providers := m.providerSnapshot(toolName, remote.ProviderIDs)
if len(providers) == 0 {
return failTool(toolName, requestID, "REMOTE_SKILL_OFFLINE", "Alle Worker für diese Skill-Aktion sind offline")
}
startIdx := m.nextProviderIndex(toolName, len(providers))
var lastErr error
for i := 0; i < len(providers); i++ {
w := providers[(startIdx+i)%len(providers)]
res, transportErr := m.invokeWorker(ctx, w, remote, requestID, args)
if transportErr == nil {
return res
}
lastErr = transportErr
if m.app != nil {
m.app.traceErr(ctx, "mesh", "invoke_transport", map[string]any{"worker_id": w.ID, "tool": toolName}, transportErr, time.Now())
}
}
return failTool(toolName, requestID, "REMOTE_SKILL_TRANSPORT", lastErr.Error())
}
func (m *skillMesh) remoteAction(tool string) (meshRemoteAction, bool) {
for _, a := range m.RemoteActions() {
if a.ToolName == tool {
return a, true
}
}
return meshRemoteAction{}, false
}
func (m *skillMesh) providerSnapshot(tool string, ids []string) []*meshWorker {
now := time.Now()
m.mu.RLock()
defer m.mu.RUnlock()
out := []*meshWorker{}
for _, id := range ids {
if w := m.workers[id]; w != nil && now.Before(w.ExpiresAt) {
cp := *w
cp.Labels = cloneStringMap(w.Labels)
out = append(out, &cp)
}
}
return out
}
func (m *skillMesh) nextProviderIndex(tool string, n int) int {
if n <= 1 {
return 0
}
m.mu.Lock()
defer m.mu.Unlock()
x := m.rr[tool] % n
m.rr[tool] = (x + 1) % n
return x
}
func (m *skillMesh) invokeWorker(ctx context.Context, w *meshWorker, action meshRemoteAction, requestID string, args map[string]any) (toolResult, error) {
callCtx, cancel := context.WithTimeout(ctx, m.invokeTimeout)
defer cancel()
envReq := skillRequestEnvelope{Protocol: skillInvokeProtocolV1, RequestID: requestID, SkillID: action.SkillID, Action: action.Action.Name, Input: args, Context: skillInvocationContext{TraceID: traceIDFromContext(ctx), Now: time.Now().In(m.app.timezone).Format(time.RFC3339), Timezone: m.app.timezone.String()}}
body, _ := json.Marshal(envReq)
endpoint := strings.TrimRight(w.Endpoint, "/") + "/v1/invoke"
req, err := http.NewRequestWithContext(callCtx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return toolResult{}, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+w.AccessToken)
req.Header.Set("X-Jarvis-Trace-ID", traceIDFromContext(ctx))
started := time.Now()
if m.app != nil {
m.app.trace(ctx, "mesh", "invoke_request", "input", map[string]any{"worker_id": w.ID, "endpoint": endpoint, "tool": action.ToolName, "skill_id": action.SkillID, "action": action.Action.Name, "arguments": args})
}
resp, err := m.client.Do(req)
if err != nil {
return toolResult{}, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return toolResult{}, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return toolResult{}, fmt.Errorf("worker %s HTTP %d: %s", w.Name, resp.StatusCode, clipSkillString(strings.TrimSpace(string(raw)), 1000))
}
var wire skillResponseEnvelope
if err := json.Unmarshal(raw, &wire); err != nil {
return toolResult{}, fmt.Errorf("worker %s lieferte ungültiges JSON: %w", w.Name, err)
}
if wire.Protocol != "" && wire.Protocol != skillInvokeProtocolV1 && wire.Protocol != skillProtocolV1 {
return toolResult{}, fmt.Errorf("worker %s Protokoll %q inkompatibel", w.Name, wire.Protocol)
}
if wire.Success && action.Action.OutputSchema != nil {
if err := validateJSONAgainstSchema(wire.Data, action.Action.OutputSchema, "data"); err != nil {
return failTool(action.ToolName, requestID, "INVALID_SKILL_OUTPUT", err.Error()), nil
}
}
res := toolResult{Success: wire.Success, Tool: action.ToolName, RequestID: requestID, Data: wire.Data, Error: wire.Error, Warnings: wire.Warnings, Entity: wire.Entity, Message: strings.TrimSpace(wire.Message)}
if !res.Success && res.Error == nil {
res.Error = &toolError{Code: "REMOTE_SKILL_FAILED", Message: "Remote-Skill meldete success=false"}
}
mutated := action.Action.Mutates && wire.Success
if wire.Mutated != nil {
mutated = action.Action.Mutates && wire.Success && *wire.Mutated
}
res.Mutated = mutated
if m.app != nil {
m.app.traceTimed(ctx, "mesh", "invoke_response", "output", map[string]any{"worker_id": w.ID, "tool": action.ToolName, "result": res}, started)
}
return res, nil
}
func (m *skillMesh) handleEnroll(w http.ResponseWriter, r *http.Request) {
if !m.enabled {
http.Error(w, "Skill Mesh deaktiviert", 503)
return
}
var in meshEnrollRequest
if !decodeJSON(w, r, &in) {
return
}
if in.Protocol != "" && in.Protocol != skillWorkerProtocolV1 {
http.Error(w, "Protocol mismatch", 400)
return
}
if !constantTimeEqual(strings.TrimSpace(in.EnrollmentToken), m.enrollmentToken) || m.enrollmentToken == "" {
http.Error(w, "Enrollment abgelehnt", 401)
return
}
endpoint, err := validateWorkerEndpoint(in.Worker.Endpoint)
if err != nil {
http.Error(w, err.Error(), 400)
return
}
if strings.TrimSpace(in.Worker.Name) == "" || strings.TrimSpace(in.Worker.Runtime) == "" {
http.Error(w, "worker.name/runtime fehlt", 400)
return
}
tok, err := secureToken(32)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
now := time.Now()
wid := "worker_" + strings.ToLower(strings.ReplaceAll(id("mesh"), "_", ""))
mw := &meshWorker{ID: wid, Name: strings.TrimSpace(in.Worker.Name), Runtime: strings.TrimSpace(in.Worker.Runtime), RuntimeVersion: strings.TrimSpace(in.Worker.RuntimeVersion), Endpoint: endpoint, Labels: cloneStringMap(in.Worker.Labels), AccessToken: tok, LastSeen: now, ExpiresAt: now.Add(m.lease), RegisteredAt: now}
m.mu.Lock()
// Re-enrollment of the same logical worker replaces an older lease instead
// of leaving duplicate registrations around until expiry.
for existingID, existing := range m.workers {
if existing.Name == mw.Name && existing.Endpoint == mw.Endpoint {
delete(m.workers, existingID)
}
}
m.workers[wid] = mw
m.mu.Unlock()
if m.app != nil {
m.app.trace(r.Context(), "mesh", "worker_enrolled", "output", map[string]any{"worker_id": wid, "name": mw.Name, "runtime": mw.Runtime, "endpoint": mw.Endpoint})
}
writeJSON(w, 200, meshEnrollResponse{Protocol: skillWorkerProtocolV1, WorkerID: wid, AccessToken: tok, LeaseSeconds: int(m.lease.Seconds()), MasterTime: now.In(m.app.timezone).Format(time.RFC3339)})
}
func validateWorkerEndpoint(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", errors.New("worker.endpoint fehlt")
}
u, err := url.Parse(raw)
if err != nil {
return "", err
}
if u.Scheme != "http" && u.Scheme != "https" {
return "", errors.New("worker.endpoint muss http/https verwenden")
}
if u.Host == "" {
return "", errors.New("worker.endpoint Host fehlt")
}
return strings.TrimRight(raw, "/"), nil
}
func secureToken(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func constantTimeEqual(a, b string) bool {
if len(a) != len(b) {
return false
}
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
func (m *skillMesh) authWorker(r *http.Request) (*meshWorker, bool) {
idv := r.PathValue("id")
auth := strings.TrimSpace(r.Header.Get("Authorization"))
if !strings.HasPrefix(auth, "Bearer ") {
return nil, false
}
tok := strings.TrimSpace(strings.TrimPrefix(auth, "Bearer "))
m.mu.RLock()
w := m.workers[idv]
if w == nil {
m.mu.RUnlock()
return nil, false
}
cp := *w
m.mu.RUnlock()
if !constantTimeEqual(tok, cp.AccessToken) {
return nil, false
}
return &cp, true
}
func (m *skillMesh) handleRegister(w http.ResponseWriter, r *http.Request) {
current, ok := m.authWorker(r)
if !ok {
http.Error(w, "unauthorized", 401)
return
}
var in meshRegistryRequest
if !decodeJSON(w, r, &in) {
return
}
if in.Protocol != "" && in.Protocol != skillWorkerProtocolV1 {
http.Error(w, "Protocol mismatch", 400)
return
}
clean := make([]meshSkillRegistration, 0, len(in.Skills))
issues := []string{}
for _, s := range in.Skills {
cs, err := validateMeshSkill(s)
if err != nil {
issues = append(issues, fmt.Sprintf("%s: %v", s.ID, err))
continue
}
clean = append(clean, cs)
}
now := time.Now()
m.mu.Lock()
live := m.workers[current.ID]
if live != nil {
live.Skills = clean
live.LastSeen = now
live.ExpiresAt = now.Add(m.lease)
}
m.mu.Unlock()
if m.app != nil {
m.app.trace(r.Context(), "mesh", "skills_registered", "output", map[string]any{"worker_id": current.ID, "skills": len(clean), "issues": issues})
if m.app.skills != nil {
_ = m.app.skills.Reload(r.Context())
}
}
writeJSON(w, 200, map[string]any{"ok": len(issues) == 0, "registered": len(clean), "issues": issues, "lease_seconds": int(m.lease.Seconds())})
}
func validateMeshSkill(s meshSkillRegistration) (meshSkillRegistration, error) {
s.Protocol = strings.TrimSpace(s.Protocol)
if s.Protocol == "" {
s.Protocol = skillProtocolV1
}
if s.Protocol != skillProtocolV1 {
return s, fmt.Errorf("protocol %q wird nicht unterstützt", s.Protocol)
}
s.ID = strings.ToLower(strings.TrimSpace(s.ID))
if !skillIDPattern.MatchString(s.ID) {
return s, errors.New("ungültige skill id")
}
if strings.TrimSpace(s.Name) == "" {
return s, errors.New("name fehlt")
}
if strings.TrimSpace(s.Version) == "" {
s.Version = "0.0.0"
}
if len(s.Actions) == 0 {
return s, errors.New("actions leer")
}
seen := map[string]bool{}
for i := range s.Actions {
a := &s.Actions[i]
a.Name = strings.TrimSpace(a.Name)
if !skillActionPattern.MatchString(a.Name) {
return s, fmt.Errorf("action[%d].name ungültig", i)
}
if seen[a.Name] {
return s, fmt.Errorf("action %q doppelt", a.Name)
}
seen[a.Name] = true
if a.InputSchema == nil {
a.InputSchema = obj(nil, nil)
}
if err := validateSchemaDefinition(a.InputSchema); err != nil {
return s, fmt.Errorf("action %s input_schema: %w", a.Name, err)
}
if a.OutputSchema != nil {
if err := validateSchemaDefinition(a.OutputSchema); err != nil {
return s, fmt.Errorf("action %s output_schema: %w", a.Name, err)
}
}
}
return s, nil
}
func (m *skillMesh) handleHeartbeat(w http.ResponseWriter, r *http.Request) {
current, ok := m.authWorker(r)
if !ok {
http.Error(w, "unauthorized", 401)
return
}
var in meshHeartbeatRequest
if r.ContentLength > 0 {
if !decodeJSON(w, r, &in) {
return
}
}
now := time.Now()
m.mu.Lock()
live := m.workers[current.ID]
if live != nil {
live.LastSeen = now
live.ExpiresAt = now.Add(m.lease)
}
m.mu.Unlock()
writeJSON(w, 200, map[string]any{"ok": true, "lease_seconds": int(m.lease.Seconds()), "master_time": now.In(m.app.timezone).Format(time.RFC3339)})
}
func (m *skillMesh) handleDeregister(w http.ResponseWriter, r *http.Request) {
current, ok := m.authWorker(r)
if !ok {
http.Error(w, "unauthorized", 401)
return
}
m.mu.Lock()
delete(m.workers, current.ID)
m.mu.Unlock()
if m.app != nil && m.app.skills != nil {
_ = m.app.skills.Reload(r.Context())
}
writeJSON(w, 200, map[string]bool{"ok": true})
}
func (m *skillMesh) handleReloadWorker(w http.ResponseWriter, r *http.Request) {
idv := r.PathValue("id")
m.mu.RLock()
current := m.workers[idv]
if current != nil {
cp := *current
current = &cp
}
m.mu.RUnlock()
if current == nil || time.Now().After(current.ExpiresAt) {
writeJSON(w, http.StatusNotFound, map[string]any{"ok": false, "error": "Worker nicht gefunden oder offline"})
return
}
endpoint := strings.TrimRight(current.Endpoint, "/") + "/v1/reload"
req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, endpoint, bytes.NewReader([]byte(`{}`)))
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+current.AccessToken)
started := time.Now()
resp, err := m.client.Do(req)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"ok": false, "error": err.Error()})
return
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
writeJSON(w, http.StatusBadGateway, map[string]any{"ok": false, "status": resp.StatusCode, "error": clipSkillString(strings.TrimSpace(string(raw)), 800)})
return
}
if m.app != nil {
m.app.traceTimed(r.Context(), "mesh", "worker_reload", "output", map[string]any{"worker_id": current.ID, "name": current.Name}, started)
}
var out any = map[string]any{"ok": true}
if len(bytes.TrimSpace(raw)) > 0 {
_ = json.Unmarshal(raw, &out)
}
writeJSON(w, http.StatusOK, out)
}
func (m *skillMesh) handleWorkers(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, m.Status())
}
+178
View File
@@ -0,0 +1,178 @@
package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestSkillMeshEnrollRegisterInvoke(t *testing.T) {
t.Setenv("JARVIS_MESH_ENABLED", "true")
t.Setenv("JARVIS_MESH_ENROLLMENT_TOKEN", "secret-enroll")
t.Setenv("JARVIS_SKILLS_ENABLED", "false")
var expectedToken string
workerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/invoke" {
http.NotFound(w, r)
return
}
if got := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "); got == "" || got != expectedToken {
http.Error(w, "bad token", http.StatusUnauthorized)
return
}
var in skillRequestEnvelope
_ = json.NewDecoder(r.Body).Decode(&in)
if in.SkillID != "demo.remote" || in.Action != "echo" {
http.Error(w, "bad action", http.StatusBadRequest)
return
}
writeJSON(w, http.StatusOK, skillResponseEnvelope{
Protocol: skillInvokeProtocolV1,
Success: true,
Data: map[string]any{"text": in.Input["text"]},
Message: "remote ok",
})
}))
defer workerServer.Close()
loc, _ := time.LoadLocation("Europe/Berlin")
a := &app{timezone: loc}
a.mesh = newSkillMesh(a)
a.skills = newSkillRegistry(a, nil, t.TempDir())
enrollBody := `{"protocol":"jarvis.skill.worker.v1","enrollment_token":"secret-enroll","worker":{"name":"python-test","runtime":"python","endpoint":"` + workerServer.URL + `"}}`
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/mesh/enroll", strings.NewReader(enrollBody))
req.Header.Set("Content-Type", "application/json")
a.mesh.handleEnroll(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("enroll status=%d body=%s", rr.Code, rr.Body.String())
}
var enrolled meshEnrollResponse
if err := json.Unmarshal(rr.Body.Bytes(), &enrolled); err != nil {
t.Fatal(err)
}
expectedToken = enrolled.AccessToken
if enrolled.WorkerID == "" || expectedToken == "" {
t.Fatalf("bad enrollment: %+v", enrolled)
}
inputSchema := map[string]any{
"type": "object",
"properties": map[string]any{
"text": map[string]any{"type": "string"},
},
"required": []any{"text"},
"additionalProperties": false,
}
outputSchema := map[string]any{
"type": "object",
"properties": map[string]any{
"text": map[string]any{"type": "string"},
},
"required": []any{"text"},
"additionalProperties": false,
}
reg := meshRegistryRequest{
Protocol: skillWorkerProtocolV1,
Skills: []meshSkillRegistration{{
Protocol: skillProtocolV1,
ID: "demo.remote",
Name: "Remote Demo",
Version: "1.0.0",
Actions: []skillActionManifest{{
Name: "echo",
Description: "echo",
InputSchema: inputSchema,
OutputSchema: outputSchema,
}},
}},
}
b, _ := json.Marshal(reg)
rr = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPut, "/api/mesh/workers/"+enrolled.WorkerID+"/skills", strings.NewReader(string(b)))
req.SetPathValue("id", enrolled.WorkerID)
req.Header.Set("Authorization", "Bearer "+expectedToken)
req.Header.Set("Content-Type", "application/json")
a.mesh.handleRegister(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("register=%d %s", rr.Code, rr.Body.String())
}
toolName := pluginToolName("demo.remote", "echo")
if !a.skills.Has(toolName) {
t.Fatalf("remote tool %s not registered; status=%+v", toolName, a.skills.Status())
}
res := a.skills.Execute(context.Background(), toolName, map[string]any{"text": "hello"})
if !res.Success {
t.Fatalf("invoke failed: %+v", res)
}
data, _ := res.Data.(map[string]any)
if data["text"] != "hello" {
t.Fatalf("bad data: %#v", res.Data)
}
}
func TestSkillMeshExpiredWorkerIsRemoved(t *testing.T) {
t.Setenv("JARVIS_MESH_ENABLED", "true")
t.Setenv("JARVIS_MESH_ENROLLMENT_TOKEN", "x")
t.Setenv("JARVIS_SKILLS_ENABLED", "false")
loc, _ := time.LoadLocation("Europe/Berlin")
a := &app{timezone: loc}
a.mesh = newSkillMesh(a)
a.skills = newSkillRegistry(a, nil, t.TempDir())
a.mesh.mu.Lock()
a.mesh.workers["worker_dead"] = &meshWorker{
ID: "worker_dead",
Name: "dead",
Runtime: "python",
Endpoint: "http://127.0.0.1:1",
AccessToken: "x",
ExpiresAt: time.Now().Add(-time.Second),
Skills: []meshSkillRegistration{{
Protocol: skillProtocolV1,
ID: "dead.skill",
Name: "Dead",
Version: "1",
Actions: []skillActionManifest{{
Name: "run",
InputSchema: map[string]any{"type": "object", "properties": map[string]any{}},
}},
}},
}
a.mesh.mu.Unlock()
a.mesh.reapExpired()
if a.mesh.Status().WorkerCount != 0 {
t.Fatalf("expired worker remains")
}
}
func TestSkillMeshReenrollReplacesSameWorker(t *testing.T) {
t.Setenv("JARVIS_MESH_ENABLED", "true")
t.Setenv("JARVIS_MESH_ENROLLMENT_TOKEN", "secret")
loc, _ := time.LoadLocation("Europe/Berlin")
a := &app{timezone: loc}
a.mesh = newSkillMesh(a)
body := `{"protocol":"jarvis.skill.worker.v1","enrollment_token":"secret","worker":{"name":"python-main","runtime":"python","endpoint":"http://worker:8090"}}`
for i := 0; i < 2; i++ {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/mesh/enroll", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
a.mesh.handleEnroll(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("enroll %d: %s", i, rr.Body.String())
}
}
if got := a.mesh.Status().WorkerCount; got != 1 {
t.Fatalf("worker_count=%d want 1", got)
}
}
+2 -2
View File
@@ -77,7 +77,7 @@ func (a *app) resolvePendingToolAction(ctx context.Context, raw string) (agentOu
}
a.clearPendingToolAction()
res := a.tools.Execute(ctx, cp.Tool, cp.Arguments)
res := a.skills.Execute(ctx, cp.Tool, cp.Arguments)
a.trace(ctx, "agent", "pending_tool_execute", "output", map[string]any{"tool": cp.Tool, "arguments": cp.Arguments, "label": cp.Label, "result": res})
out := agentOutcome{Mode: "tool-confirmed", Results: []toolResult{res}, Automations: []automationResult{}}
if !res.Success {
@@ -164,7 +164,7 @@ func isMutationTool(name string) bool {
func (a *app) toolFailureReply(results []toolResult) string {
for i := len(results) - 1; i >= 0; i-- {
res := results[i]
if res.Success || !isMutationTool(res.Tool) {
if res.Success || !a.skills.IsMutation(res.Tool) {
continue
}
msg := "Die gewünschte Änderung konnte nicht ausgeführt werden."
+995
View File
@@ -0,0 +1,995 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"homehub/internal/debugtrace"
"homehub/internal/ollama"
)
const skillProtocolV1 = "jarvis.skill.v1"
var skillIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}$`)
var skillActionPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$`)
type skillRuntime struct {
Type string `json:"type"`
Command string `json:"command,omitempty"`
Args []string `json:"args,omitempty"`
TimeoutMS int `json:"timeout_ms,omitempty"`
Env map[string]string `json:"env,omitempty"`
EnvFrom []string `json:"env_from,omitempty"`
}
type skillPermissions struct {
Network bool `json:"network,omitempty"`
SystemExec bool `json:"system_exec,omitempty"`
}
type skillActionManifest struct {
Name string `json:"name"`
ToolName string `json:"tool_name,omitempty"`
Description string `json:"description"`
InputSchema map[string]any `json:"input_schema"`
OutputSchema map[string]any `json:"output_schema,omitempty"`
Mutates bool `json:"mutates,omitempty"`
RequiresConfirmation bool `json:"requires_confirmation,omitempty"`
Triggers []string `json:"triggers,omitempty"`
}
type skillManifest struct {
Protocol string `json:"protocol"`
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
Description string `json:"description"`
Enabled *bool `json:"enabled,omitempty"`
Runtime skillRuntime `json:"runtime"`
Permissions skillPermissions `json:"permissions,omitempty"`
Actions []skillActionManifest `json:"actions"`
}
type skillActionRef struct {
SkillID string
SkillName string
Version string
Source string
Dir string
Runtime skillRuntime
Permissions skillPermissions
Action skillActionManifest
ToolName string
Core bool
Remote bool
}
type skillActionStatus struct {
Name string `json:"name"`
ToolName string `json:"tool_name"`
Description string `json:"description,omitempty"`
Mutates bool `json:"mutates"`
RequiresConfirmation bool `json:"requires_confirmation"`
}
type skillModuleStatus struct {
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
Description string `json:"description,omitempty"`
Source string `json:"source"`
Runtime string `json:"runtime"`
Enabled bool `json:"enabled"`
Permissions skillPermissions `json:"permissions,omitempty"`
Actions []skillActionStatus `json:"actions"`
Error string `json:"error,omitempty"`
}
type skillRegistryStatus struct {
Protocol string `json:"protocol"`
Directory string `json:"directory"`
Enabled bool `json:"enabled"`
ProcessEnabled bool `json:"process_enabled"`
SystemExec bool `json:"system_exec_enabled"`
Modules []skillModuleStatus `json:"modules"`
ActionCount int `json:"action_count"`
PluginCount int `json:"plugin_count"`
RemoteCount int `json:"remote_count"`
CoreCount int `json:"core_count"`
Errors []string `json:"errors,omitempty"`
LoadedAt string `json:"loaded_at"`
}
type skillRegistry struct {
app *app
core *toolRegistry
dir string
enabled bool
processEnabled bool
allowSystemExec bool
maxTimeout time.Duration
maxOutputBytes int
mu sync.RWMutex
defs []ollama.Tool
actions map[string]skillActionRef
modules []skillModuleStatus
errors []string
loadedAt time.Time
}
type skillInvocationContext struct {
TraceID string `json:"trace_id,omitempty"`
Now string `json:"now"`
Timezone string `json:"timezone"`
}
type skillRequestEnvelope struct {
Protocol string `json:"protocol"`
RequestID string `json:"request_id"`
SkillID string `json:"skill_id"`
Action string `json:"action"`
Input map[string]any `json:"input"`
Context skillInvocationContext `json:"context"`
}
type skillResponseEnvelope struct {
Protocol string `json:"protocol,omitempty"`
Success bool `json:"success"`
Data any `json:"data,omitempty"`
Message string `json:"message,omitempty"`
Error *toolError `json:"error,omitempty"`
Warnings []string `json:"warnings,omitempty"`
Mutated *bool `json:"mutated,omitempty"`
Entity *toolEntity `json:"entity,omitempty"`
}
func newSkillRegistry(a *app, core *toolRegistry, dir string) *skillRegistry {
maxTimeoutMS := envInt("JARVIS_SKILL_MAX_TIMEOUT_MS", 30000)
if maxTimeoutMS < 250 {
maxTimeoutMS = 250
}
if maxTimeoutMS > 300000 {
maxTimeoutMS = 300000
}
maxOutputKB := envInt("JARVIS_SKILL_MAX_OUTPUT_KB", 1024)
if maxOutputKB < 16 {
maxOutputKB = 16
}
if maxOutputKB > 16384 {
maxOutputKB = 16384
}
r := &skillRegistry{
app: a,
core: core,
dir: dir,
enabled: envBool("JARVIS_SKILLS_ENABLED", true),
processEnabled: envBool("JARVIS_SKILL_PROCESS_ENABLED", true),
allowSystemExec: envBool("JARVIS_SKILL_ALLOW_SYSTEM_EXEC", false),
maxTimeout: time.Duration(maxTimeoutMS) * time.Millisecond,
maxOutputBytes: maxOutputKB * 1024,
actions: map[string]skillActionRef{},
}
_ = r.Reload(context.Background())
return r
}
func (r *skillRegistry) Definitions() []ollama.Tool {
r.mu.RLock()
defer r.mu.RUnlock()
return append([]ollama.Tool(nil), r.defs...)
}
func (r *skillRegistry) Has(name string) bool {
r.mu.RLock()
defer r.mu.RUnlock()
_, ok := r.actions[name]
return ok
}
func (r *skillRegistry) MayApply(message string) bool {
x := foldCommand(message)
r.mu.RLock()
defer r.mu.RUnlock()
for _, ref := range r.actions {
if ref.Core {
continue
}
for _, trigger := range ref.Action.Triggers {
trigger = strings.TrimSpace(trigger)
if trigger != "" && strings.Contains(x, foldCommand(trigger)) {
return true
}
}
}
return false
}
func (r *skillRegistry) IsMutation(name string) bool {
r.mu.RLock()
defer r.mu.RUnlock()
ref, ok := r.actions[name]
return ok && ref.Action.Mutates
}
func (r *skillRegistry) RequiresConfirmation(name string) bool {
r.mu.RLock()
defer r.mu.RUnlock()
ref, ok := r.actions[name]
return ok && ref.Action.RequiresConfirmation
}
func (r *skillRegistry) ActionLabel(name string) string {
r.mu.RLock()
defer r.mu.RUnlock()
ref, ok := r.actions[name]
if !ok {
return name
}
if ref.SkillName != "" {
return ref.SkillName + " · " + ref.Action.Name
}
return name
}
func (r *skillRegistry) Status() skillRegistryStatus {
r.mu.RLock()
defer r.mu.RUnlock()
mods := append([]skillModuleStatus(nil), r.modules...)
errs := append([]string(nil), r.errors...)
sort.Slice(mods, func(i, j int) bool {
if mods[i].Source == mods[j].Source {
return mods[i].Name < mods[j].Name
}
return mods[i].Source < mods[j].Source
})
coreCount, pluginCount, remoteCount := 0, 0, 0
for _, m := range mods {
switch m.Source {
case "core":
coreCount++
case "remote":
remoteCount++
default:
pluginCount++
}
}
return skillRegistryStatus{
Protocol: skillProtocolV1, Directory: r.dir, Enabled: r.enabled,
ProcessEnabled: r.processEnabled, SystemExec: r.allowSystemExec,
Modules: mods, ActionCount: len(r.actions), PluginCount: pluginCount, RemoteCount: remoteCount,
CoreCount: coreCount, Errors: errs, LoadedAt: r.loadedAt.Format(time.RFC3339),
}
}
func (r *skillRegistry) Reload(ctx context.Context) error {
defs := []ollama.Tool{}
actions := map[string]skillActionRef{}
modulesByID := map[string]*skillModuleStatus{}
issues := []string{}
// Existing deterministic Home tools become Core Skills. Their public function
// names intentionally stay stable so old conversations and model behavior do
// not break while the execution architecture changes underneath them.
if r.core != nil {
for _, d := range r.core.Definitions() {
toolName := d.Function.Name
domain, actionName := coreSkillParts(toolName)
sid := "core." + domain
ref := skillActionRef{
SkillID: sid, SkillName: "Core " + strings.Title(domain), Version: "1",
Source: "core", Core: true, ToolName: toolName,
Action: skillActionManifest{Name: actionName, ToolName: toolName, Description: d.Function.Description, InputSchema: d.Function.Parameters, Mutates: isMutationTool(toolName)},
}
actions[toolName] = ref
defs = append(defs, d)
mod := modulesByID[sid]
if mod == nil {
mod = &skillModuleStatus{ID: sid, Name: ref.SkillName, Version: "1", Source: "core", Runtime: "builtin", Enabled: true}
modulesByID[sid] = mod
}
mod.Actions = append(mod.Actions, skillActionStatus{Name: actionName, ToolName: toolName, Description: d.Function.Description, Mutates: ref.Action.Mutates})
}
}
// Remote Skill Mesh providers are merged into the exact same registry as
// built-in and local process skills. The LLM therefore sees one capability
// catalog regardless of where code is executed.
if r.app != nil && r.app.mesh != nil {
for _, remote := range r.app.mesh.RemoteActions() {
if _, exists := actions[remote.ToolName]; exists {
issues = append(issues, remote.SkillID+": Remote Tool-Name kollidiert: "+remote.ToolName)
continue
}
act := remote.Action
act.ToolName = remote.ToolName
ref := skillActionRef{SkillID: remote.SkillID, SkillName: remote.SkillName, Version: remote.Version, Source: "remote", Action: act, ToolName: remote.ToolName, Remote: true}
actions[remote.ToolName] = ref
desc := firstNonEmpty(remote.Description, act.Description, "Remote Skill "+remote.SkillName)
defs = append(defs, toolDef(remote.ToolName, "[Remote Skill: "+remote.SkillName+"] "+desc, act.InputSchema))
sid := "remote." + remote.SkillID
mod := modulesByID[sid]
if mod == nil {
mod = &skillModuleStatus{ID: remote.SkillID, Name: remote.SkillName, Version: remote.Version, Description: "Remote Skill Mesh", Source: "remote", Runtime: "http", Enabled: true}
modulesByID[sid] = mod
}
mod.Actions = append(mod.Actions, skillActionStatus{Name: act.Name, ToolName: remote.ToolName, Description: desc, Mutates: act.Mutates, RequiresConfirmation: act.RequiresConfirmation})
}
}
if r.enabled {
if err := os.MkdirAll(r.dir, 0o755); err != nil {
issues = append(issues, "Skill-Verzeichnis: "+err.Error())
} else {
entries, err := os.ReadDir(r.dir)
if err != nil {
issues = append(issues, "Skill-Verzeichnis lesen: "+err.Error())
} else {
for _, ent := range entries {
if !ent.IsDir() || strings.HasPrefix(ent.Name(), ".") || strings.HasPrefix(ent.Name(), "_") {
continue
}
skillDir := filepath.Join(r.dir, ent.Name())
manifestPath := filepath.Join(skillDir, "skill.json")
if _, err := os.Stat(manifestPath); err != nil {
continue
}
manifest, err := loadSkillManifest(manifestPath)
if err != nil {
issues = append(issues, ent.Name()+": "+err.Error())
continue
}
enabled := manifest.Enabled == nil || *manifest.Enabled
mod := skillModuleStatus{
ID: manifest.ID, Name: manifest.Name, Version: manifest.Version, Description: manifest.Description,
Source: "plugin", Runtime: manifest.Runtime.Type, Enabled: enabled, Permissions: manifest.Permissions,
}
if !enabled {
modulesByID[manifest.ID] = &mod
continue
}
if manifest.Runtime.Type == "process" && !r.processEnabled {
mod.Error = "Process-Skills sind global deaktiviert"
modulesByID[manifest.ID] = &mod
issues = append(issues, manifest.ID+": "+mod.Error)
continue
}
for _, act := range manifest.Actions {
toolName := strings.TrimSpace(act.ToolName)
if toolName == "" {
toolName = pluginToolName(manifest.ID, act.Name)
}
if _, exists := actions[toolName]; exists {
issues = append(issues, manifest.ID+": Tool-Name kollidiert: "+toolName)
continue
}
act.ToolName = toolName
ref := skillActionRef{
SkillID: manifest.ID, SkillName: manifest.Name, Version: manifest.Version,
Source: "plugin", Dir: skillDir, Runtime: manifest.Runtime, Permissions: manifest.Permissions,
Action: act, ToolName: toolName,
}
actions[toolName] = ref
desc := strings.TrimSpace(act.Description)
if desc == "" {
desc = manifest.Description
}
if desc == "" {
desc = "Plugin-Skill " + manifest.Name
}
defs = append(defs, toolDef(toolName, "[Skill: "+manifest.Name+"] "+desc, act.InputSchema))
mod.Actions = append(mod.Actions, skillActionStatus{Name: act.Name, ToolName: toolName, Description: desc, Mutates: act.Mutates, RequiresConfirmation: act.RequiresConfirmation})
}
modulesByID[manifest.ID] = &mod
}
}
}
}
modules := make([]skillModuleStatus, 0, len(modulesByID))
for _, m := range modulesByID {
sort.Slice(m.Actions, func(i, j int) bool { return m.Actions[i].ToolName < m.Actions[j].ToolName })
modules = append(modules, *m)
}
sort.Slice(defs, func(i, j int) bool { return defs[i].Function.Name < defs[j].Function.Name })
r.mu.Lock()
r.defs = defs
r.actions = actions
r.modules = modules
r.errors = issues
r.loadedAt = time.Now()
r.mu.Unlock()
if r.app != nil {
r.app.trace(ctx, "skills", "reload", "output", map[string]any{"directory": r.dir, "actions": len(actions), "modules": len(modules), "errors": issues})
}
if len(issues) > 0 {
return fmt.Errorf("%d Skill-Problem(e) beim Laden", len(issues))
}
return nil
}
func coreSkillParts(tool string) (string, string) {
if i := strings.Index(tool, "_"); i > 0 {
return tool[:i], tool[i+1:]
}
return "system", tool
}
func pluginToolName(skillID, action string) string {
clean := func(s string) string {
s = strings.ToLower(s)
var b strings.Builder
lastUnderscore := false
for _, ch := range s {
ok := ch >= 'a' && ch <= 'z' || ch >= '0' && ch <= '9'
if ok {
b.WriteRune(ch)
lastUnderscore = false
} else if !lastUnderscore {
b.WriteByte('_')
lastUnderscore = true
}
}
return strings.Trim(b.String(), "_")
}
out := "skill_" + clean(skillID) + "_" + clean(action)
if len(out) > 96 {
out = out[:96]
}
return out
}
func loadSkillManifest(path string) (skillManifest, error) {
var m skillManifest
b, err := os.ReadFile(path)
if err != nil {
return m, err
}
dec := json.NewDecoder(bytes.NewReader(b))
dec.DisallowUnknownFields()
if err := dec.Decode(&m); err != nil {
return m, fmt.Errorf("skill.json ungültig: %w", err)
}
m.Protocol = strings.TrimSpace(m.Protocol)
if m.Protocol == "" {
m.Protocol = skillProtocolV1
}
if m.Protocol != skillProtocolV1 {
return m, fmt.Errorf("nicht unterstütztes Protokoll %q", m.Protocol)
}
m.ID = strings.ToLower(strings.TrimSpace(m.ID))
if !skillIDPattern.MatchString(m.ID) {
return m, fmt.Errorf("id muss %s entsprechen", skillIDPattern.String())
}
if strings.TrimSpace(m.Name) == "" {
return m, errors.New("name fehlt")
}
if strings.TrimSpace(m.Version) == "" {
m.Version = "0.0.0"
}
m.Runtime.Type = strings.ToLower(strings.TrimSpace(m.Runtime.Type))
if m.Runtime.Type == "" {
m.Runtime.Type = "process"
}
if m.Runtime.Type != "process" {
return m, fmt.Errorf("runtime.type %q wird noch nicht unterstützt (aktuell: process)", m.Runtime.Type)
}
if strings.TrimSpace(m.Runtime.Command) == "" {
return m, errors.New("runtime.command fehlt")
}
if len(m.Actions) == 0 {
return m, errors.New("actions ist leer")
}
seen := map[string]bool{}
for i := range m.Actions {
a := &m.Actions[i]
a.Name = strings.TrimSpace(a.Name)
if !skillActionPattern.MatchString(a.Name) {
return m, fmt.Errorf("action[%d].name ungültig", i)
}
if seen[a.Name] {
return m, fmt.Errorf("action %q doppelt", a.Name)
}
seen[a.Name] = true
if a.InputSchema == nil {
a.InputSchema = obj(nil, nil)
}
if err := validateSchemaDefinition(a.InputSchema); err != nil {
return m, fmt.Errorf("action %s input_schema: %w", a.Name, err)
}
if a.OutputSchema != nil {
if err := validateSchemaDefinition(a.OutputSchema); err != nil {
return m, fmt.Errorf("action %s output_schema: %w", a.Name, err)
}
}
}
return m, nil
}
func (r *skillRegistry) Execute(ctx context.Context, name string, args map[string]any) toolResult {
requestID := id("skill")
r.mu.RLock()
ref, ok := r.actions[name]
r.mu.RUnlock()
if !ok {
return failTool(name, requestID, "UNKNOWN_SKILL", "Skill-Aktion ist nicht registriert: "+name)
}
if ref.Core {
res := r.core.Execute(ctx, name, args)
// Core tool results keep their existing request IDs for compatibility.
return res
}
if args == nil {
args = map[string]any{}
}
if err := validateJSONAgainstSchema(args, ref.Action.InputSchema, "input"); err != nil {
return failTool(name, requestID, "INVALID_SKILL_INPUT", err.Error())
}
if ref.Remote {
if r.app == nil || r.app.mesh == nil {
return failTool(name, requestID, "REMOTE_SKILL_UNAVAILABLE", "Skill Mesh ist nicht verfügbar")
}
return r.app.mesh.Invoke(ctx, name, args)
}
return r.executeProcess(ctx, ref, requestID, args)
}
func (r *skillRegistry) executeProcess(ctx context.Context, ref skillActionRef, requestID string, args map[string]any) toolResult {
if !r.processEnabled {
return failTool(ref.ToolName, requestID, "SKILL_RUNTIME_DISABLED", "Process-Skills sind deaktiviert")
}
timeout := 5 * time.Second
if ref.Runtime.TimeoutMS > 0 {
timeout = time.Duration(ref.Runtime.TimeoutMS) * time.Millisecond
}
if timeout > r.maxTimeout {
timeout = r.maxTimeout
}
if timeout < 250*time.Millisecond {
timeout = 250 * time.Millisecond
}
command, err := r.resolveSkillCommand(ref)
if err != nil {
return failTool(ref.ToolName, requestID, "SKILL_EXEC_DENIED", err.Error())
}
callCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
envReq := skillRequestEnvelope{
Protocol: skillProtocolV1, RequestID: requestID, SkillID: ref.SkillID, Action: ref.Action.Name, Input: args,
Context: skillInvocationContext{TraceID: traceIDFromContext(ctx), Now: time.Now().In(r.app.timezone).Format(time.RFC3339), Timezone: r.app.timezone.String()},
}
in, _ := json.Marshal(envReq)
cmd := exec.CommandContext(callCtx, command, ref.Runtime.Args...)
cmd.Dir = ref.Dir
cmd.Stdin = bytes.NewReader(in)
cmd.Env = r.skillEnvironment(ref, requestID, ctx)
stdout := &limitedBuffer{max: r.maxOutputBytes}
stderr := &limitedBuffer{max: minInt(r.maxOutputBytes/4, 256*1024)}
cmd.Stdout = stdout
cmd.Stderr = stderr
started := time.Now()
r.app.trace(ctx, "skills", "invoke_request", "input", map[string]any{"skill_id": ref.SkillID, "action": ref.Action.Name, "tool": ref.ToolName, "runtime": ref.Runtime.Type, "command": command, "arguments": args, "timeout_ms": timeout.Milliseconds()})
runErr := cmd.Run()
duration := time.Since(started)
stderrText := strings.TrimSpace(stderr.String())
if callCtx.Err() == context.DeadlineExceeded {
r.app.traceErr(ctx, "skills", "invoke_response", map[string]any{"skill_id": ref.SkillID, "action": ref.Action.Name, "stderr": stderrText}, callCtx.Err(), started)
return failTool(ref.ToolName, requestID, "SKILL_TIMEOUT", fmt.Sprintf("Skill %s überschritt das Zeitlimit von %s", ref.SkillID, timeout))
}
if stdout.overflow {
return failTool(ref.ToolName, requestID, "SKILL_OUTPUT_LIMIT", "Skill-Ausgabe überschreitet das konfigurierte Größenlimit")
}
if runErr != nil {
msg := runErr.Error()
if stderrText != "" {
msg += ": " + clipSkillString(stderrText, 1200)
}
r.app.traceErr(ctx, "skills", "invoke_response", map[string]any{"skill_id": ref.SkillID, "action": ref.Action.Name, "stderr": stderrText}, runErr, started)
return failTool(ref.ToolName, requestID, "SKILL_PROCESS_FAILED", msg)
}
raw := strings.TrimSpace(stdout.String())
if raw == "" {
return failTool(ref.ToolName, requestID, "SKILL_EMPTY_OUTPUT", "Skill hat keine JSON-Antwort geliefert")
}
var wire skillResponseEnvelope
dec := json.NewDecoder(strings.NewReader(raw))
if err := dec.Decode(&wire); err != nil {
return failTool(ref.ToolName, requestID, "SKILL_INVALID_OUTPUT", "Skill-Ausgabe ist kein gültiges JSON: "+err.Error())
}
if wire.Protocol != "" && wire.Protocol != skillProtocolV1 {
return failTool(ref.ToolName, requestID, "SKILL_PROTOCOL_MISMATCH", "Skill antwortete mit inkompatiblem Protokoll: "+wire.Protocol)
}
if wire.Success && ref.Action.OutputSchema != nil {
if err := validateJSONAgainstSchema(wire.Data, ref.Action.OutputSchema, "data"); err != nil {
return failTool(ref.ToolName, requestID, "INVALID_SKILL_OUTPUT", err.Error())
}
}
res := toolResult{Success: wire.Success, Tool: ref.ToolName, RequestID: requestID, Data: wire.Data, Error: wire.Error, Warnings: wire.Warnings, Entity: wire.Entity, Message: strings.TrimSpace(wire.Message)}
if !wire.Success && res.Error == nil {
res.Error = &toolError{Code: "SKILL_FAILED", Message: "Skill meldete success=false"}
}
mutated := ref.Action.Mutates && wire.Success
if wire.Mutated != nil {
mutated = ref.Action.Mutates && wire.Success && *wire.Mutated
if *wire.Mutated && !ref.Action.Mutates {
res.Warnings = append(res.Warnings, "Skill meldete mutated=true, die Action ist im Manifest jedoch read-only")
}
}
res.Mutated = mutated
r.app.traceTimed(ctx, "skills", "invoke_response", "output", map[string]any{"skill_id": ref.SkillID, "action": ref.Action.Name, "tool": ref.ToolName, "result": res, "stderr": clipSkillString(stderrText, 4000)}, started)
_ = duration
return res
}
func (r *skillRegistry) resolveSkillCommand(ref skillActionRef) (string, error) {
raw := strings.TrimSpace(ref.Runtime.Command)
if raw == "" {
return "", errors.New("runtime.command fehlt")
}
if filepath.IsAbs(raw) {
if !ref.Permissions.SystemExec || !r.allowSystemExec {
return "", fmt.Errorf("absolute/systemweite Executables sind für diesen Skill nicht erlaubt")
}
return raw, nil
}
if strings.ContainsRune(raw, filepath.Separator) || strings.HasPrefix(raw, ".") {
p := filepath.Clean(filepath.Join(ref.Dir, raw))
if !pathWithin(ref.Dir, p) {
return "", errors.New("runtime.command verlässt das Skill-Verzeichnis")
}
resolved, err := filepath.EvalSymlinks(p)
if err == nil && !pathWithin(ref.Dir, resolved) {
return "", errors.New("runtime.command Symlink verlässt das Skill-Verzeichnis")
}
if err := ensureExecutable(p); err != nil {
return "", err
}
return p, nil
}
local := filepath.Join(ref.Dir, raw)
if st, err := os.Stat(local); err == nil && !st.IsDir() {
if err := ensureExecutable(local); err != nil {
return "", err
}
return local, nil
}
if !ref.Permissions.SystemExec || !r.allowSystemExec {
return "", fmt.Errorf("Executable %q ist nicht im Skill-Verzeichnis; system_exec ist nicht freigegeben", raw)
}
p, err := exec.LookPath(raw)
if err != nil {
return "", err
}
return p, nil
}
func ensureExecutable(path string) error {
st, err := os.Stat(path)
if err != nil {
return err
}
if st.IsDir() {
return errors.New("runtime.command ist ein Verzeichnis")
}
if st.Mode().Perm()&0o111 == 0 {
return fmt.Errorf("runtime.command ist nicht ausführbar: %s", path)
}
return nil
}
func pathWithin(base, target string) bool {
b, err1 := filepath.Abs(base)
t, err2 := filepath.Abs(target)
if err1 != nil || err2 != nil {
return false
}
rel, err := filepath.Rel(b, t)
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}
func (r *skillRegistry) skillEnvironment(ref skillActionRef, requestID string, ctx context.Context) []string {
keep := map[string]string{
"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"LANG": "C.UTF-8",
"TZ": r.app.timezone.String(),
"JARVIS_SKILL_PROTOCOL": skillProtocolV1,
"JARVIS_SKILL_ID": ref.SkillID,
"JARVIS_SKILL_ACTION": ref.Action.Name,
"JARVIS_SKILL_REQUEST_ID": requestID,
"JARVIS_SKILL_TRACE_ID": traceIDFromContext(ctx),
}
for k, v := range ref.Runtime.Env {
key := strings.TrimSpace(k)
// Never let a manifest override JARVIS control variables or smuggle
// process-wide secrets from the parent. Values are literal manifest data.
if key == "" || strings.HasPrefix(strings.ToUpper(key), "JARVIS_SKILL_") {
continue
}
keep[key] = v
}
// Optional parity with remote workers: only explicitly named variables are
// inherited from the parent process. This keeps secrets out of skill.json.
for _, rawKey := range ref.Runtime.EnvFrom {
key := strings.TrimSpace(rawKey)
if key == "" || strings.HasPrefix(strings.ToUpper(key), "JARVIS_") {
continue
}
if v, ok := os.LookupEnv(key); ok {
keep[key] = v
}
}
keys := make([]string, 0, len(keep))
for k := range keep {
keys = append(keys, k)
}
sort.Strings(keys)
out := make([]string, 0, len(keys))
for _, k := range keys {
out = append(out, k+"="+keep[k])
}
return out
}
type limitedBuffer struct {
buf bytes.Buffer
max int
overflow bool
}
func (b *limitedBuffer) Write(p []byte) (int, error) {
if b.max <= 0 {
return len(p), nil
}
remain := b.max - b.buf.Len()
if remain <= 0 {
b.overflow = true
return len(p), nil
}
if len(p) > remain {
_, _ = b.buf.Write(p[:remain])
b.overflow = true
return len(p), nil
}
return b.buf.Write(p)
}
func (b *limitedBuffer) String() string { return b.buf.String() }
func traceIDFromContext(ctx context.Context) string { return debugtrace.TraceID(ctx) }
func clipSkillString(s string, max int) string {
if max <= 0 || len(s) <= max {
return s
}
return s[:max] + "…"
}
func validateSchemaDefinition(schema map[string]any) error {
if schema == nil {
return nil
}
typ, _ := schema["type"].(string)
if typ == "" {
return errors.New("type fehlt")
}
switch typ {
case "object", "array", "string", "number", "integer", "boolean", "null":
default:
return fmt.Errorf("type %q wird nicht unterstützt", typ)
}
if typ == "object" {
if props, ok := schema["properties"].(map[string]any); ok {
for name, raw := range props {
child, ok := raw.(map[string]any)
if !ok {
return fmt.Errorf("property %s ist kein Schema-Objekt", name)
}
if err := validateSchemaDefinition(child); err != nil {
return fmt.Errorf("property %s: %w", name, err)
}
}
}
}
if typ == "array" {
if raw, ok := schema["items"]; ok {
child, ok := raw.(map[string]any)
if !ok {
return errors.New("items ist kein Schema-Objekt")
}
if err := validateSchemaDefinition(child); err != nil {
return fmt.Errorf("items: %w", err)
}
}
}
return nil
}
func validateJSONAgainstSchema(value any, schema map[string]any, path string) error {
if schema == nil {
return nil
}
typ, _ := schema["type"].(string)
if typ == "" {
return fmt.Errorf("%s: Schema ohne type", path)
}
switch typ {
case "object":
m, ok := value.(map[string]any)
if !ok {
// Values decoded from typed data may not be map[string]any; normalize.
b, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("%s: object erwartet", path)
}
if err := json.Unmarshal(b, &m); err != nil {
return fmt.Errorf("%s: object erwartet", path)
}
}
props, _ := schema["properties"].(map[string]any)
required := stringSet(schema["required"])
for req := range required {
if _, exists := m[req]; !exists {
return fmt.Errorf("%s.%s fehlt", path, req)
}
}
if ap, ok := schema["additionalProperties"].(bool); ok && !ap {
for k := range m {
if _, exists := props[k]; !exists {
return fmt.Errorf("%s.%s ist nicht erlaubt", path, k)
}
}
}
for k, v := range m {
if raw, exists := props[k]; exists {
if child, ok := raw.(map[string]any); ok {
if err := validateJSONAgainstSchema(v, child, path+"."+k); err != nil {
return err
}
}
}
}
case "array":
arr, ok := value.([]any)
if !ok {
b, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("%s: array erwartet", path)
}
if err := json.Unmarshal(b, &arr); err != nil {
return fmt.Errorf("%s: array erwartet", path)
}
}
if raw, ok := schema["items"].(map[string]any); ok {
for i, v := range arr {
if err := validateJSONAgainstSchema(v, raw, fmt.Sprintf("%s[%d]", path, i)); err != nil {
return err
}
}
}
case "string":
s, ok := value.(string)
if !ok {
return fmt.Errorf("%s: string erwartet", path)
}
if vals, ok := schema["enum"].([]any); ok && len(vals) > 0 {
found := false
for _, v := range vals {
if fmt.Sprint(v) == s {
found = true
break
}
}
if !found {
return fmt.Errorf("%s: Wert %q ist nicht erlaubt", path, s)
}
}
case "boolean":
if _, ok := value.(bool); !ok {
return fmt.Errorf("%s: boolean erwartet", path)
}
case "number", "integer":
n, ok := jsonNumber(value)
if !ok {
return fmt.Errorf("%s: Zahl erwartet", path)
}
if typ == "integer" && n != float64(int64(n)) {
return fmt.Errorf("%s: Ganzzahl erwartet", path)
}
if min, ok := numberFromAny(schema["minimum"]); ok && n < min {
return fmt.Errorf("%s: muss >= %v sein", path, min)
}
if max, ok := numberFromAny(schema["maximum"]); ok && n > max {
return fmt.Errorf("%s: muss <= %v sein", path, max)
}
case "null":
if value != nil {
return fmt.Errorf("%s: null erwartet", path)
}
default:
return fmt.Errorf("%s: nicht unterstützter Schema-Typ %q", path, typ)
}
return nil
}
func stringSet(v any) map[string]bool {
out := map[string]bool{}
switch x := v.(type) {
case []any:
for _, item := range x {
if s, ok := item.(string); ok {
out[s] = true
}
}
case []string:
for _, s := range x {
out[s] = true
}
}
return out
}
func jsonNumber(v any) (float64, bool) {
switch n := v.(type) {
case float64:
return n, true
case float32:
return float64(n), true
case int:
return float64(n), true
case int8:
return float64(n), true
case int16:
return float64(n), true
case int32:
return float64(n), true
case int64:
return float64(n), true
case uint:
return float64(n), true
case uint8:
return float64(n), true
case uint16:
return float64(n), true
case uint32:
return float64(n), true
case uint64:
return float64(n), true
case json.Number:
f, err := n.Float64()
return f, err == nil
}
return 0, false
}
func numberFromAny(v any) (float64, bool) {
if v == nil {
return 0, false
}
if f, ok := jsonNumber(v); ok {
return f, true
}
if s, ok := v.(string); ok {
f, err := strconv.ParseFloat(s, 64)
return f, err == nil
}
return 0, false
}
// Ensure compile-time awareness that limitedBuffer is an io.Writer.
var _ io.Writer = (*limitedBuffer)(nil)
+173
View File
@@ -0,0 +1,173 @@
package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
)
func writeSkillFixture(t *testing.T, root, name, manifest, runner string) string {
t.Helper()
dir := filepath.Join(root, name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "skill.json"), []byte(manifest), 0o644); err != nil {
t.Fatal(err)
}
run := filepath.Join(dir, "run")
if err := os.WriteFile(run, []byte(runner), 0o755); err != nil {
t.Fatal(err)
}
return dir
}
func TestSkillRegistryLoadsAndExecutesProcessSkill(t *testing.T) {
root := t.TempDir()
writeSkillFixture(t, root, "echo", `{
"protocol":"jarvis.skill.v1",
"id":"test.echo",
"name":"Echo Test",
"version":"1.0.0",
"runtime":{"type":"process","command":"./run","timeout_ms":2000},
"actions":[{
"name":"echo","description":"Echo input","triggers":["echo skill"],"mutates":false,
"input_schema":{"type":"object","additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"]},
"output_schema":{"type":"object","additionalProperties":false,"properties":{"echo":{"type":"string"}},"required":["echo"]}
}]
}`, `#!/bin/sh
cat >/dev/null
printf '%s\n' '{"protocol":"jarvis.skill.v1","success":true,"data":{"echo":"ok"},"message":"Echo OK"}'
`)
a := &app{timezone: berlin(t)}
reg := newSkillRegistry(a, nil, root)
tool := "skill_test_echo_echo"
if !reg.Has(tool) {
t.Fatalf("skill %s not loaded: %+v", tool, reg.Status())
}
if !reg.MayApply("bitte den echo skill verwenden") {
t.Fatal("trigger not matched")
}
res := reg.Execute(context.Background(), tool, map[string]any{"text": "Hallo"})
if !res.Success || res.Message != "Echo OK" {
t.Fatalf("res=%+v", res)
}
data, ok := res.Data.(map[string]any)
if !ok || data["echo"] != "ok" {
t.Fatalf("data=%#v", res.Data)
}
}
func TestSkillInputSchemaRejectsBeforeProcess(t *testing.T) {
root := t.TempDir()
marker := filepath.Join(root, "ran")
manifest := `{
"protocol":"jarvis.skill.v1","id":"test.validate","name":"Validate","version":"1",
"runtime":{"type":"process","command":"./run"},
"actions":[{"name":"run","description":"x","input_schema":{"type":"object","additionalProperties":false,"properties":{"count":{"type":"integer","minimum":1}},"required":["count"]}}]
}`
runner := "#!/bin/sh\ntouch " + marker + "\nprintf '%s\\n' '{\"protocol\":\"jarvis.skill.v1\",\"success\":true}'\n"
writeSkillFixture(t, root, "validate", manifest, runner)
a := &app{timezone: berlin(t)}
reg := newSkillRegistry(a, nil, root)
res := reg.Execute(context.Background(), "skill_test_validate_run", map[string]any{"count": 0})
if res.Success || res.Error == nil || res.Error.Code != "INVALID_SKILL_INPUT" {
t.Fatalf("res=%+v", res)
}
if _, err := os.Stat(marker); !os.IsNotExist(err) {
t.Fatalf("process should not run, stat err=%v", err)
}
}
func TestSkillOutputSchemaRejectsInvalidData(t *testing.T) {
root := t.TempDir()
writeSkillFixture(t, root, "badout", `{
"protocol":"jarvis.skill.v1","id":"test.badout","name":"Bad Output","version":"1",
"runtime":{"type":"process","command":"./run"},
"actions":[{"name":"run","description":"x","input_schema":{"type":"object","properties":{},"additionalProperties":false},"output_schema":{"type":"object","properties":{"value":{"type":"integer"}},"required":["value"],"additionalProperties":false}}]
}`, `#!/bin/sh
cat >/dev/null
printf '%s\n' '{"protocol":"jarvis.skill.v1","success":true,"data":{"value":"wrong"}}'
`)
a := &app{timezone: berlin(t)}
reg := newSkillRegistry(a, nil, root)
res := reg.Execute(context.Background(), "skill_test_badout_run", map[string]any{})
if res.Success || res.Error == nil || res.Error.Code != "INVALID_SKILL_OUTPUT" {
t.Fatalf("res=%+v", res)
}
}
func TestSkillSystemExecutableDeniedByDefault(t *testing.T) {
root := t.TempDir()
writeSkillFixture(t, root, "system", `{
"protocol":"jarvis.skill.v1","id":"test.system","name":"System","version":"1",
"runtime":{"type":"process","command":"/bin/sh","args":["-c","printf x"]},
"permissions":{"system_exec":true},
"actions":[{"name":"run","description":"x","input_schema":{"type":"object","properties":{},"additionalProperties":false}}]
}`, "#!/bin/sh\nexit 0\n")
t.Setenv("JARVIS_SKILL_ALLOW_SYSTEM_EXEC", "false")
a := &app{timezone: berlin(t)}
reg := newSkillRegistry(a, nil, root)
res := reg.Execute(context.Background(), "skill_test_system_run", map[string]any{})
if res.Success || res.Error == nil || !strings.Contains(res.Error.Code, "SKILL_EXEC_DENIED") {
t.Fatalf("res=%+v", res)
}
}
func TestToolAgentCanSelectPluginSkill(t *testing.T) {
root := t.TempDir()
writeSkillFixture(t, root, "echo", `{
"protocol":"jarvis.skill.v1","id":"test.echo","name":"Echo Test","version":"1",
"runtime":{"type":"process","command":"./run"},
"actions":[{"name":"echo","description":"Echo","triggers":["plugin echo"],"input_schema":{"type":"object","additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"]},"output_schema":{"type":"object","additionalProperties":false,"properties":{"echo":{"type":"string"}},"required":["echo"]}}]
}`, `#!/bin/sh
cat >/dev/null
printf '%s\n' '{"protocol":"jarvis.skill.v1","success":true,"data":{"echo":"plugin-ok"},"message":"Plugin ausgeführt."}'
`)
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
if _, ok := body["tools"]; ok {
if calls.Add(1) == 1 {
tools, _ := body["tools"].([]any)
found := false
for _, raw := range tools {
m, _ := raw.(map[string]any)
fn, _ := m["function"].(map[string]any)
if fn["name"] == "skill_test_echo_echo" {
found = true
break
}
}
if !found {
t.Error("plugin skill missing from Ollama tool catalog")
}
writeOllamaTurn(w, map[string]any{"role": "assistant", "content": "", "tool_calls": []any{map[string]any{"function": map[string]any{"name": "skill_test_echo_echo", "arguments": map[string]any{"text": "Hallo"}}}}})
return
}
writeOllamaTurn(w, map[string]any{"role": "assistant", "content": "Plugin-Ergebnis gelesen."})
return
}
writeOllamaTurn(w, map[string]any{"role": "assistant", "content": "{}"})
}))
defer srv.Close()
a := toolTestApp(t, srv.URL)
a.skills = newSkillRegistry(a, a.tools, root)
out, err := a.runToolAgent(context.Background(), "nutze plugin echo für Hallo")
if err != nil {
t.Fatal(err)
}
if len(out.Results) != 1 || !out.Results[0].Success || out.Results[0].Tool != "skill_test_echo_echo" {
t.Fatalf("out=%+v", out)
}
if out.Results[0].Message != "Plugin ausgeführt." {
t.Fatalf("result=%+v", out.Results[0])
}
}
+1
View File
@@ -33,6 +33,7 @@ func toolTestApp(t *testing.T, serverURL string) *app {
ai.ConfigureGeneration(cfg.chatOptions(), cfg.plannerOptions())
a := &app{db: db, ai: ai, rag: idx, timezone: berlin(t), context: cfg}
a.tools = newToolRegistry(a)
a.skills = newSkillRegistry(a, a.tools, filepath.Join(t.TempDir(), "skills"))
return a
}
+1
View File
@@ -29,6 +29,7 @@ type toolResult struct {
Tool string `json:"tool"`
RequestID string `json:"request_id"`
Data any `json:"data,omitempty"`
Message string `json:"message,omitempty"`
Error *toolError `json:"error,omitempty"`
Warnings []string `json:"warnings,omitempty"`
Mutated bool `json:"mutated,omitempty"`
+813
View File
@@ -0,0 +1,813 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"os/exec"
"os/signal"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"syscall"
"time"
)
const skillProtocol = "jarvis.skill.v1"
const workerProtocol = "jarvis.skill.worker.v1"
const invokeProtocol = "jarvis.skill.invoke.v1"
type runtimeSpec struct {
Type string `json:"type"`
Command string `json:"command"`
Args []string `json:"args,omitempty"`
TimeoutMS int `json:"timeout_ms,omitempty"`
Env map[string]string `json:"env,omitempty"`
EnvFrom []string `json:"env_from,omitempty"`
}
type permissions struct {
Network bool `json:"network,omitempty"`
SystemExec bool `json:"system_exec,omitempty"`
}
type actionManifest struct {
Name string `json:"name"`
ToolName string `json:"tool_name,omitempty"`
Description string `json:"description"`
InputSchema map[string]any `json:"input_schema"`
OutputSchema map[string]any `json:"output_schema,omitempty"`
Mutates bool `json:"mutates,omitempty"`
RequiresConfirmation bool `json:"requires_confirmation,omitempty"`
Triggers []string `json:"triggers,omitempty"`
}
type manifest struct {
Protocol string `json:"protocol"`
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
Description string `json:"description"`
Enabled *bool `json:"enabled,omitempty"`
Runtime runtimeSpec `json:"runtime"`
Permissions permissions `json:"permissions,omitempty"`
Actions []actionManifest `json:"actions"`
}
type skillRef struct {
Manifest manifest
Action actionManifest
Dir string
}
type publicSkill struct {
Protocol string `json:"protocol"`
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
Description string `json:"description,omitempty"`
Actions []actionManifest `json:"actions"`
}
type invokeContext struct {
TraceID string `json:"trace_id,omitempty"`
Now string `json:"now"`
Timezone string `json:"timezone"`
}
type invokeRequest struct {
Protocol string `json:"protocol"`
RequestID string `json:"request_id"`
SkillID string `json:"skill_id"`
Action string `json:"action"`
Input map[string]any `json:"input"`
Context invokeContext `json:"context"`
}
type wireError struct {
Code string `json:"code"`
Message string `json:"message"`
}
type invokeResponse struct {
Protocol string `json:"protocol"`
Success bool `json:"success"`
Data any `json:"data,omitempty"`
Message string `json:"message,omitempty"`
Error *wireError `json:"error,omitempty"`
Warnings []string `json:"warnings,omitempty"`
Mutated *bool `json:"mutated,omitempty"`
Entity any `json:"entity,omitempty"`
}
type worker struct {
mu sync.RWMutex
skills map[string]manifest
actions map[string]skillRef
accessToken string
workerID string
lease time.Duration
master string
enrollment string
name string
runtime string
runtimeVersion string
publicURL string
skillsDir string
workDir string
allowSystemExec bool
maxOutput int
client *http.Client
}
func main() {
w := &worker{master: trimURL(env("JARVIS_MASTER_URL", "http://jarvis:8080")), enrollment: strings.TrimSpace(os.Getenv("JARVIS_ENROLLMENT_TOKEN")), name: env("JARVIS_WORKER_NAME", "skill-worker"), runtime: env("JARVIS_WORKER_RUNTIME", "generic"), runtimeVersion: env("JARVIS_WORKER_RUNTIME_VERSION", ""), publicURL: trimURL(env("JARVIS_WORKER_PUBLIC_URL", "http://localhost:8090")), skillsDir: env("JARVIS_SKILLS_DIR", "/skills"), allowSystemExec: envBool("JARVIS_WORKER_ALLOW_SYSTEM_EXEC", true), maxOutput: envInt("JARVIS_WORKER_MAX_OUTPUT_KB", 1024) * 1024, client: &http.Client{Timeout: 15 * time.Second}, skills: map[string]manifest{}, actions: map[string]skillRef{}, lease: 30 * time.Second}
if err := w.reload(); err != nil {
log.Printf("Skill load warning: %v", err)
}
mux := http.NewServeMux()
mux.HandleFunc("GET /health", w.health)
mux.HandleFunc("GET /v1/skills", w.listSkills)
mux.HandleFunc("POST /v1/invoke", w.invoke)
mux.HandleFunc("POST /v1/reload", w.reloadHTTP)
srv := &http.Server{Addr: env("JARVIS_WORKER_ADDR", ":8090"), Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 60 * time.Second, WriteTimeout: 180 * time.Second}
go func() {
log.Printf("JARVIS Skill Worker %s runtime=%s listening %s", w.name, w.runtime, srv.Addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
}()
ctx, cancel := context.WithCancel(context.Background())
go w.controlLoop(ctx)
ch := make(chan os.Signal, 1)
signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM)
<-ch
cancel()
w.deregister()
sd, sdCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer sdCancel()
_ = srv.Shutdown(sd)
}
func (w *worker) controlLoop(ctx context.Context) {
for {
if ctx.Err() != nil {
return
}
if err := w.enrollAndRegister(ctx); err != nil {
log.Printf("Enrollment: %v", err)
if !sleepCtx(ctx, 5*time.Second) {
return
}
continue
}
break
}
for {
w.mu.RLock()
lease := w.lease
w.mu.RUnlock()
wait := lease / 3
if wait < 5*time.Second {
wait = 5 * time.Second
}
if !sleepCtx(ctx, wait) {
return
}
if err := w.heartbeat(ctx); err != nil {
log.Printf("Heartbeat: %v; erneutes Enrollment", err)
w.mu.Lock()
w.accessToken = ""
w.workerID = ""
w.mu.Unlock()
for {
if ctx.Err() != nil {
return
}
if err := w.enrollAndRegister(ctx); err == nil {
break
}
if !sleepCtx(ctx, 5*time.Second) {
return
}
}
}
}
}
func sleepCtx(ctx context.Context, d time.Duration) bool {
select {
case <-ctx.Done():
return false
case <-time.After(d):
return true
}
}
func (w *worker) enrollAndRegister(ctx context.Context) error {
if w.enrollment == "" {
return errors.New("JARVIS_ENROLLMENT_TOKEN fehlt")
}
in := map[string]any{"protocol": workerProtocol, "enrollment_token": w.enrollment, "worker": map[string]any{"name": w.name, "runtime": w.runtime, "runtime_version": w.runtimeVersion, "endpoint": w.publicURL, "labels": map[string]string{"runtime": w.runtime}}}
var out struct {
Protocol string `json:"protocol"`
WorkerID string `json:"worker_id"`
AccessToken string `json:"access_token"`
LeaseSeconds int `json:"lease_seconds"`
}
if err := w.doJSON(ctx, http.MethodPost, w.master+"/api/mesh/enroll", "", in, &out); err != nil {
return err
}
if out.WorkerID == "" || out.AccessToken == "" {
return errors.New("Master lieferte keine Worker-ID/Token")
}
w.mu.Lock()
w.workerID = out.WorkerID
w.accessToken = out.AccessToken
if out.LeaseSeconds > 0 {
w.lease = time.Duration(out.LeaseSeconds) * time.Second
}
w.mu.Unlock()
return w.register(ctx)
}
func (w *worker) register(ctx context.Context) error {
w.mu.RLock()
idv, tok := w.workerID, w.accessToken
skills := w.publicSkillsLocked()
w.mu.RUnlock()
in := map[string]any{"protocol": workerProtocol, "skills": skills}
var out map[string]any
if err := w.doJSON(ctx, http.MethodPut, w.master+"/api/mesh/workers/"+url.PathEscape(idv)+"/skills", tok, in, &out); err != nil {
return err
}
if ok, exists := out["ok"].(bool); exists && !ok {
b, _ := json.Marshal(out["issues"])
return fmt.Errorf("Master hat Skill-Registrierung abgelehnt: %s", string(b))
}
return nil
}
func (w *worker) heartbeat(ctx context.Context) error {
w.mu.RLock()
idv, tok := w.workerID, w.accessToken
skillCount := len(w.skills)
w.mu.RUnlock()
if idv == "" || tok == "" {
return errors.New("not enrolled")
}
var out map[string]any
return w.doJSON(ctx, http.MethodPost, w.master+"/api/mesh/workers/"+url.PathEscape(idv)+"/heartbeat", tok, map[string]any{"protocol": workerProtocol, "status": map[string]any{"skills": skillCount}}, &out)
}
func (w *worker) deregister() {
w.mu.RLock()
idv, tok := w.workerID, w.accessToken
w.mu.RUnlock()
if idv == "" || tok == "" {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_ = w.doJSON(ctx, http.MethodDelete, w.master+"/api/mesh/workers/"+url.PathEscape(idv), tok, nil, nil)
}
func (w *worker) doJSON(ctx context.Context, method, endpoint, token string, in, out any) error {
var rd io.Reader
if in != nil {
b, _ := json.Marshal(in)
rd = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, endpoint, rd)
if err != nil {
return err
}
if in != nil {
req.Header.Set("Content-Type", "application/json")
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := w.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, clip(string(raw), 800))
}
if out != nil && len(raw) > 0 {
return json.Unmarshal(raw, out)
}
return nil
}
func (w *worker) reload() error {
entries, err := os.ReadDir(w.skillsDir)
if err != nil {
if os.IsNotExist(err) {
_ = os.MkdirAll(w.skillsDir, 0o755)
return nil
}
return err
}
type candidate struct{ name, dir string }
candidates := []candidate{}
if st, err := os.Stat(filepath.Join(w.skillsDir, "skill.json")); err == nil && !st.IsDir() {
candidates = append(candidates, candidate{name: "root", dir: w.skillsDir})
}
for _, e := range entries {
if !e.IsDir() || strings.HasPrefix(e.Name(), ".") || strings.HasPrefix(e.Name(), "_") {
continue
}
dir := filepath.Join(w.skillsDir, e.Name())
if st, err := os.Stat(filepath.Join(dir, "skill.json")); err != nil || st.IsDir() {
continue
}
candidates = append(candidates, candidate{name: e.Name(), dir: dir})
}
skills := map[string]manifest{}
actions := map[string]skillRef{}
issues := []string{}
for _, c := range candidates {
dir := c.dir
execDir := dir
if strings.TrimSpace(w.workDir) != "" {
target := filepath.Join(w.workDir, c.name)
_ = os.RemoveAll(target)
if err := copySkillDir(dir, target); err != nil {
issues = append(issues, c.name+": workdir copy: "+err.Error())
continue
}
execDir = target
}
b, err := os.ReadFile(filepath.Join(dir, "skill.json"))
if err != nil {
continue
}
var m manifest
dec := json.NewDecoder(bytes.NewReader(b))
dec.DisallowUnknownFields()
if err := dec.Decode(&m); err != nil {
issues = append(issues, c.name+": "+err.Error())
continue
}
if m.Protocol == "" {
m.Protocol = skillProtocol
}
if m.Protocol != skillProtocol || m.ID == "" || m.Name == "" || len(m.Actions) == 0 {
issues = append(issues, c.name+": manifest ungültig")
continue
}
if m.Enabled != nil && !*m.Enabled {
continue
}
if m.Runtime.Type == "" {
m.Runtime.Type = "process"
}
if m.Runtime.Type != "process" || strings.TrimSpace(m.Runtime.Command) == "" {
issues = append(issues, m.ID+": runtime.command fehlt")
continue
}
good := true
for i := range m.Actions {
a := &m.Actions[i]
if a.Name == "" {
good = false
break
}
if a.InputSchema == nil {
a.InputSchema = map[string]any{"type": "object", "properties": map[string]any{}, "additionalProperties": false}
}
key := m.ID + "\x00" + a.Name
actions[key] = skillRef{Manifest: m, Action: *a, Dir: execDir}
}
if good {
skills[m.ID] = m
}
}
w.mu.Lock()
w.skills = skills
w.actions = actions
w.mu.Unlock()
if len(issues) > 0 {
return errors.New(strings.Join(issues, "; "))
}
return nil
}
func (w *worker) publicSkillsLocked() []publicSkill {
out := make([]publicSkill, 0, len(w.skills))
for _, m := range w.skills {
acts := make([]actionManifest, len(m.Actions))
copy(acts, m.Actions)
for i := range acts {
acts[i].ToolName = strings.TrimSpace(acts[i].ToolName)
}
out = append(out, publicSkill{Protocol: skillProtocol, ID: m.ID, Name: m.Name, Version: m.Version, Description: m.Description, Actions: acts})
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
func (w *worker) health(rw http.ResponseWriter, r *http.Request) {
w.mu.RLock()
out := map[string]any{"ok": true, "protocol": workerProtocol, "worker_id": w.workerID, "name": w.name, "runtime": w.runtime, "runtime_version": w.runtimeVersion, "enrolled": w.accessToken != "", "skills": len(w.skills), "actions": len(w.actions)}
w.mu.RUnlock()
writeJSON(rw, 200, out)
}
func (w *worker) listSkills(rw http.ResponseWriter, r *http.Request) {
w.mu.RLock()
out := w.publicSkillsLocked()
w.mu.RUnlock()
writeJSON(rw, 200, map[string]any{"protocol": workerProtocol, "skills": out})
}
func (w *worker) reloadHTTP(rw http.ResponseWriter, r *http.Request) {
if !w.authorized(r) {
http.Error(rw, "unauthorized", 401)
return
}
err := w.reload()
if err == nil {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
_ = w.register(ctx)
cancel()
}
writeJSON(rw, 200, map[string]any{"ok": err == nil, "error": errString(err)})
}
func (w *worker) authorized(r *http.Request) bool {
w.mu.RLock()
tok := w.accessToken
w.mu.RUnlock()
return tok != "" && strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) == tok && strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ")
}
func (w *worker) invoke(rw http.ResponseWriter, r *http.Request) {
if !w.authorized(r) {
http.Error(rw, "unauthorized", 401)
return
}
var in invokeRequest
if err := json.NewDecoder(io.LimitReader(r.Body, 2<<20)).Decode(&in); err != nil {
http.Error(rw, "invalid json", 400)
return
}
if in.Protocol != "" && in.Protocol != invokeProtocol && in.Protocol != skillProtocol {
writeJSON(rw, 400, fail(in.RequestID, "PROTOCOL_MISMATCH", "unsupported protocol"))
return
}
key := in.SkillID + "\x00" + in.Action
w.mu.RLock()
ref, ok := w.actions[key]
w.mu.RUnlock()
if !ok {
writeJSON(rw, 404, fail(in.RequestID, "SKILL_NOT_FOUND", "Skill action not found"))
return
}
if in.Input == nil {
in.Input = map[string]any{}
}
if err := validate(in.Input, ref.Action.InputSchema, "input"); err != nil {
writeJSON(rw, 400, fail(in.RequestID, "INVALID_SKILL_INPUT", err.Error()))
return
}
res := w.execute(r.Context(), ref, in)
writeJSON(rw, 200, res)
}
func (w *worker) execute(ctx context.Context, ref skillRef, in invokeRequest) invokeResponse {
timeout := 5 * time.Second
if ref.Manifest.Runtime.TimeoutMS > 0 {
timeout = time.Duration(ref.Manifest.Runtime.TimeoutMS) * time.Millisecond
}
if timeout > 5*time.Minute {
timeout = 5 * time.Minute
}
call, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmdPath, err := w.resolveCommand(ref)
if err != nil {
return fail(in.RequestID, "SKILL_EXEC_DENIED", err.Error())
}
payload, _ := json.Marshal(in)
cmd := exec.CommandContext(call, cmdPath, ref.Manifest.Runtime.Args...)
cmd.Dir = ref.Dir
cmd.Stdin = bytes.NewReader(payload)
cmd.Env = w.environment(ref, in)
stdout := &limitBuffer{max: w.maxOutput}
stderr := &limitBuffer{max: min(w.maxOutput/4, 256*1024)}
cmd.Stdout = stdout
cmd.Stderr = stderr
err = cmd.Run()
if call.Err() == context.DeadlineExceeded {
return fail(in.RequestID, "SKILL_TIMEOUT", "Skill timed out")
}
if stdout.overflow {
return fail(in.RequestID, "SKILL_OUTPUT_LIMIT", "Skill output too large")
}
if err != nil {
return fail(in.RequestID, "SKILL_PROCESS_FAILED", err.Error()+optionalStderr(stderr.String()))
}
raw := strings.TrimSpace(stdout.String())
if raw == "" {
return fail(in.RequestID, "SKILL_EMPTY_OUTPUT", "Skill returned empty output")
}
var res invokeResponse
if err := json.Unmarshal([]byte(raw), &res); err != nil {
return fail(in.RequestID, "SKILL_INVALID_OUTPUT", err.Error())
}
if res.Protocol == "" {
res.Protocol = invokeProtocol
}
if res.Success && ref.Action.OutputSchema != nil {
if err := validate(res.Data, ref.Action.OutputSchema, "data"); err != nil {
return fail(in.RequestID, "INVALID_SKILL_OUTPUT", err.Error())
}
}
if res.Success && ref.Action.Mutates {
if res.Mutated == nil {
v := true
res.Mutated = &v
}
}
return res
}
func optionalStderr(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
return ": " + clip(s, 1200)
}
func (w *worker) resolveCommand(ref skillRef) (string, error) {
raw := strings.TrimSpace(ref.Manifest.Runtime.Command)
if filepath.IsAbs(raw) {
if !w.allowSystemExec || !ref.Manifest.Permissions.SystemExec {
return "", errors.New("absolute system exec denied")
}
return raw, nil
}
if strings.Contains(raw, "/") || strings.HasPrefix(raw, ".") {
p := filepath.Clean(filepath.Join(ref.Dir, raw))
base, _ := filepath.Abs(ref.Dir)
abs, _ := filepath.Abs(p)
if abs != base && !strings.HasPrefix(abs, base+string(os.PathSeparator)) {
return "", errors.New("command escapes skill dir")
}
return p, nil
}
local := filepath.Join(ref.Dir, raw)
if st, err := os.Stat(local); err == nil && !st.IsDir() {
return local, nil
}
if !w.allowSystemExec || !ref.Manifest.Permissions.SystemExec {
return "", fmt.Errorf("system executable %q denied", raw)
}
return exec.LookPath(raw)
}
func (w *worker) environment(ref skillRef, in invokeRequest) []string {
m := map[string]string{"PATH": os.Getenv("PATH"), "LANG": "C.UTF-8", "TZ": in.Context.Timezone, "JARVIS_SKILL_PROTOCOL": skillProtocol, "JARVIS_SKILL_ID": ref.Manifest.ID, "JARVIS_SKILL_ACTION": ref.Action.Name, "JARVIS_SKILL_REQUEST_ID": in.RequestID, "JARVIS_SKILL_TRACE_ID": in.Context.TraceID, "JARVIS_WORKER_RUNTIME": w.runtime}
for _, key := range []string{"HOME", "TMPDIR", "GOCACHE", "GOMODCACHE", "CARGO_HOME", "CARGO_TARGET_DIR", "DOTNET_CLI_HOME", "NUGET_PACKAGES", "npm_config_cache", "PYTHONPATH", "PYTHONUNBUFFERED"} {
if v := os.Getenv(key); v != "" {
m[key] = v
}
}
for k, v := range ref.Manifest.Runtime.Env {
if strings.HasPrefix(strings.ToUpper(k), "JARVIS_") {
continue
}
m[k] = v
}
// Secrets/config stay in the worker container and are only exposed to a
// skill when the manifest explicitly allowlists the variable name.
for _, rawKey := range ref.Manifest.Runtime.EnvFrom {
key := strings.TrimSpace(rawKey)
if key == "" || strings.HasPrefix(strings.ToUpper(key), "JARVIS_") {
continue
}
if v, ok := os.LookupEnv(key); ok {
m[key] = v
}
}
ks := make([]string, 0, len(m))
for k := range m {
ks = append(ks, k)
}
sort.Strings(ks)
out := []string{}
for _, k := range ks {
out = append(out, k+"="+m[k])
}
return out
}
func copySkillDir(src, dst string) error {
if err := os.MkdirAll(dst, 0o755); err != nil {
return err
}
return filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, path)
if err != nil {
return err
}
if rel == "." {
return nil
}
target := filepath.Join(dst, rel)
info, err := d.Info()
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("symlinks sind im Skill-Verzeichnis nicht erlaubt: %s", rel)
}
if d.IsDir() {
return os.MkdirAll(target, info.Mode().Perm())
}
if !info.Mode().IsRegular() {
return fmt.Errorf("nicht reguläre Datei im Skill: %s", rel)
}
in, err := os.Open(path)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, info.Mode().Perm())
if err != nil {
return err
}
_, cpErr := io.Copy(out, in)
closeErr := out.Close()
if cpErr != nil {
return cpErr
}
return closeErr
})
}
type limitBuffer struct {
buf bytes.Buffer
max int
overflow bool
}
func (b *limitBuffer) Write(p []byte) (int, error) {
remain := b.max - b.buf.Len()
if remain <= 0 {
b.overflow = true
return len(p), nil
}
if len(p) > remain {
b.buf.Write(p[:remain])
b.overflow = true
return len(p), nil
}
return b.buf.Write(p)
}
func (b *limitBuffer) String() string { return b.buf.String() }
func validate(v any, s map[string]any, path string) error {
if s == nil {
return nil
}
typ, _ := s["type"].(string)
switch typ {
case "object":
m, ok := v.(map[string]any)
if !ok {
return fmt.Errorf("%s muss object sein", path)
}
req, _ := s["required"].([]any)
for _, x := range req {
k, _ := x.(string)
if _, ok := m[k]; !ok {
return fmt.Errorf("%s.%s fehlt", path, k)
}
}
if rr, ok := s["required"].([]string); ok {
for _, k := range rr {
if _, ok := m[k]; !ok {
return fmt.Errorf("%s.%s fehlt", path, k)
}
}
}
props, _ := s["properties"].(map[string]any)
for k, val := range m {
raw, exists := props[k]
if !exists {
if ap, ok := s["additionalProperties"].(bool); ok && !ap {
return fmt.Errorf("%s.%s ist nicht erlaubt", path, k)
}
continue
}
child, _ := raw.(map[string]any)
if err := validate(val, child, path+"."+k); err != nil {
return err
}
}
case "array":
arr, ok := v.([]any)
if !ok {
return fmt.Errorf("%s muss array sein", path)
}
if item, ok := s["items"].(map[string]any); ok {
for i, x := range arr {
if err := validate(x, item, fmt.Sprintf("%s[%d]", path, i)); err != nil {
return err
}
}
}
case "string":
if _, ok := v.(string); !ok {
return fmt.Errorf("%s muss string sein", path)
}
case "boolean":
if _, ok := v.(bool); !ok {
return fmt.Errorf("%s muss boolean sein", path)
}
case "integer":
f, ok := number(v)
if !ok || f != float64(int64(f)) {
return fmt.Errorf("%s muss integer sein", path)
}
case "number":
if _, ok := number(v); !ok {
return fmt.Errorf("%s muss number sein", path)
}
}
if en, ok := s["enum"].([]any); ok {
found := false
for _, x := range en {
if fmt.Sprint(x) == fmt.Sprint(v) {
found = true
break
}
}
if !found {
return fmt.Errorf("%s außerhalb enum", path)
}
}
return nil
}
func number(v any) (float64, bool) {
switch x := v.(type) {
case float64:
return x, true
case float32:
return float64(x), true
case int:
return float64(x), true
case int64:
return float64(x), true
case json.Number:
f, e := x.Float64()
return f, e == nil
}
return 0, false
}
func fail(idv, code, msg string) invokeResponse {
return invokeResponse{Protocol: invokeProtocol, Success: false, Error: &wireError{Code: code, Message: msg}}
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func env(k, d string) string {
if v := strings.TrimSpace(os.Getenv(k)); v != "" {
return v
}
return d
}
func envInt(k string, d int) int {
v, err := strconv.Atoi(strings.TrimSpace(os.Getenv(k)))
if err == nil {
return v
}
return d
}
func envBool(k string, d bool) bool {
v := strings.TrimSpace(strings.ToLower(os.Getenv(k)))
if v == "" {
return d
}
return v == "1" || v == "true" || v == "yes" || v == "on"
}
func trimURL(s string) string { return strings.TrimRight(strings.TrimSpace(s), "/") }
func clip(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
func errString(e error) string {
if e == nil {
return ""
}
return e.Error()
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
+101
View File
@@ -0,0 +1,101 @@
package main
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func TestWorkerReloadCopiesReadOnlySkillAndExecutes(t *testing.T) {
skills := t.TempDir()
work := t.TempDir()
dir := filepath.Join(skills, "demo")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
manifestJSON := `{
"protocol":"jarvis.skill.v1",
"id":"test.remote",
"name":"Remote test",
"version":"1.0.0",
"runtime":{"type":"process","command":"./run","timeout_ms":5000},
"actions":[{
"name":"echo",
"description":"echo",
"input_schema":{"type":"object","properties":{"text":{"type":"string"}},"required":["text"],"additionalProperties":false},
"output_schema":{"type":"object","properties":{"text":{"type":"string"}},"required":["text"],"additionalProperties":false}
}]
}`
if err := os.WriteFile(filepath.Join(dir, "skill.json"), []byte(manifestJSON), 0o644); err != nil {
t.Fatal(err)
}
run := `#!/bin/sh
python3 -c 'import json,sys; r=json.load(sys.stdin); json.dump({"protocol":"jarvis.skill.invoke.v1","success":True,"data":{"text":r["input"]["text"]}},sys.stdout)'
`
if err := os.WriteFile(filepath.Join(dir, "run"), []byte(run), 0o755); err != nil {
t.Fatal(err)
}
w := &worker{skillsDir: skills, workDir: work, maxOutput: 1 << 20, runtime: "test", skills: map[string]manifest{}, actions: map[string]skillRef{}}
if err := w.reload(); err != nil {
t.Fatal(err)
}
ref, ok := w.actions["test.remote\x00echo"]
if !ok {
t.Fatalf("action not loaded")
}
if filepath.Dir(ref.Dir) != work {
t.Fatalf("skill did not execute from isolated work copy: %s", ref.Dir)
}
res := w.execute(context.Background(), ref, invokeRequest{Protocol: invokeProtocol, RequestID: "x", SkillID: "test.remote", Action: "echo", Input: map[string]any{"text": "hello"}, Context: invokeContext{Timezone: "UTC"}})
if !res.Success {
t.Fatalf("execution failed: %+v", res)
}
b, _ := json.Marshal(res.Data)
if string(b) != `{"text":"hello"}` {
t.Fatalf("data=%s", b)
}
}
func TestWorkerEnvironmentExplicitEnvFrom(t *testing.T) {
t.Setenv("HUE_APP_KEY", "secret-key")
t.Setenv("UNRELATED_SECRET", "must-not-leak")
w := &worker{runtime: "python"}
ref := skillRef{Manifest: manifest{ID: "test.env", Runtime: runtimeSpec{EnvFrom: []string{"HUE_APP_KEY", "JARVIS_MESH_ENROLLMENT_TOKEN"}}}, Action: actionManifest{Name: "run"}}
env := w.environment(ref, invokeRequest{RequestID: "req", Context: invokeContext{Timezone: "Europe/Berlin"}})
got := map[string]string{}
for _, item := range env {
parts := strings.SplitN(item, "=", 2)
if len(parts) == 2 {
got[parts[0]] = parts[1]
}
}
if got["HUE_APP_KEY"] != "secret-key" {
t.Fatalf("explicit env_from was not passed: %#v", got)
}
if _, ok := got["UNRELATED_SECRET"]; ok {
t.Fatalf("unrelated parent secret leaked into skill env")
}
if _, ok := got["JARVIS_MESH_ENROLLMENT_TOKEN"]; ok {
t.Fatalf("JARVIS control env must never be passed to skills")
}
}
func TestHomeControlSkillPackManifestsLoad(t *testing.T) {
skillsDir := filepath.Clean(filepath.Join("..", "..", "skills", "python"))
w := &worker{skillsDir: skillsDir, maxOutput: 1 << 20, runtime: "python", skills: map[string]manifest{}, actions: map[string]skillRef{}}
if err := w.reload(); err != nil {
t.Fatalf("home-control skill pack did not load: %v", err)
}
for _, id := range []string{"philips.hue", "unifi.network", "unifi.protect", "proxmox.ve", "dockge.compose", "home.network-tools", "notify.ntfy"} {
if _, ok := w.skills[id]; !ok {
t.Fatalf("expected skill %s to be loaded", id)
}
}
if len(w.actions) < 42 {
t.Fatalf("expected home-control actions, got %d", len(w.actions))
}
}