Update auf Skills
This commit is contained in:
+34
-13
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
@@ -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++
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
|
||||
@@ -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)
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user