diff --git a/internal/config/config.go b/internal/config/config.go index 7a3e236..d9f70ce 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,9 +16,11 @@ type Config struct { AuditDir string LogDir string InferenceURL string + RegistryURL string AuditInterval int MCPBinDir string MCPBridges []string + RawModelPath string } var Default = Config{ @@ -32,6 +34,7 @@ var Default = Config{ AuditDir: "/cognitiveos/audit", LogDir: "/cognitiveos/logs", InferenceURL: "http://127.0.0.1:11434", + RegistryURL: "https://registry.cognitiveos.org", AuditInterval: 60, MCPBinDir: "/cognitiveos/bin", MCPBridges: []string{ @@ -40,7 +43,9 @@ var Default = Config{ "network-mcp", "gpio-mcp", "serial-mcp", + "package-mcp", }, + RawModelPath: "/cognitiveos/models/raw/raw-model.gguf", } func FromEnv() Config { @@ -66,6 +71,9 @@ func FromEnv() Config { if v := os.Getenv("COGNITIVEOS_MCP_BIN_DIR"); v != "" { c.MCPBinDir = v } + if v := os.Getenv("COGNITIVEOS_RAW_MODEL_PATH"); v != "" { + c.RawModelPath = v + } // Derive paths from base dirs c.SocketPath = filepath.Join(c.RunDir, "daemon.sock") diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index a8ebb3e..112cb0e 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -5,7 +5,10 @@ import ( "fmt" "log" "os" + "os/exec" "os/signal" + "path/filepath" + "strings" "sync" "syscall" "time" @@ -13,14 +16,17 @@ import ( "github.com/CognitiveOS-Project/cognitiveosd/internal/config" ) +const idleTimeoutDuration = 5 * time.Minute + type State string const ( - StateIdle State = "idle" - StateListening State = "listening" - StateProcessing State = "processing" - StateSecurity State = "security" - StateShutdown State = "shutdown" + StateIdle State = "idle" + StateIdleRequested State = "idle_requested" + StateListening State = "listening" + StateProcessing State = "processing" + StateSecurity State = "security" + StateShutdown State = "shutdown" ) type Daemon struct { @@ -38,8 +44,13 @@ type Daemon struct { clients map[string]*ClientConn clientsMu sync.RWMutex - signalCh chan os.Signal - done chan struct{} + modelRegistry map[string]ModelRegistryEntry + modelRegistryMu sync.RWMutex + + signalCh chan os.Signal + done chan struct{} + idleTimer *time.Timer + lastRequest time.Time Log *log.Logger } @@ -51,13 +62,14 @@ func New(cfg config.Config) *Daemon { } return &Daemon{ - Config: cfg, - State: StateIdle, - startTime: time.Now(), - clients: make(map[string]*ClientConn), - signalCh: make(chan os.Signal, 1), - done: make(chan struct{}), - Log: logger, + Config: cfg, + State: StateIdle, + startTime: time.Now(), + clients: make(map[string]*ClientConn), + modelRegistry: make(map[string]ModelRegistryEntry), + signalCh: make(chan os.Signal, 1), + done: make(chan struct{}), + Log: logger, } } @@ -76,6 +88,11 @@ func (d *Daemon) Run() error { d.wmClient = NewWideModelClient(d) d.rmClient = NewRawModelClient(d) + if err := d.rmClient.Connect(); err != nil { + return fmt.Errorf("FATAL: raw model unavailable — system cannot operate safely: %w", err) + } + d.Log.Println("raw model connected") + listener, err := NewSocketListener(d) if err != nil { return fmt.Errorf("socket: %w", err) @@ -87,16 +104,24 @@ func (d *Daemon) Run() error { initialAudit := d.auditor.Collect() d.Log.Printf("initial audit: %d MB RAM available", initialAudit.RAM.AvailableMB) - if err := d.rmClient.Connect(); err != nil { - d.Log.Printf("raw model not available: %v", err) - } else { - d.Log.Println("raw model connected") - } - d.auditor.Start() d.mcpMgr.SpawnCoreBridges() + d.mcpMgr.StartHealthchecks() + d.scanPatches() + + if err := d.loadWideModel(); err != nil { + d.Log.Printf("WARN: auto-load Wide Model: %v", err) + } + + d.startIdleTimer() + + d.broadcast(NewEnvelope("output_deliver", "cognitiveosd", OutputPayload{ + SessionID: "system", + Content: "CognitiveOS ready", + ContentType: "text", + })) d.Log.Println("cognitiveosd ready") signal.Notify(d.signalCh, syscall.SIGTERM, syscall.SIGINT, syscall.SIGQUIT) @@ -118,6 +143,308 @@ func (d *Daemon) Shutdown() { close(d.done) } +func (d *Daemon) scanPatches() { + entries, err := os.ReadDir(d.Config.PatchDir) + if err != nil { + d.Log.Printf("scan patches: %v", err) + return + } + count := 0 + for _, e := range entries { + if e.IsDir() { + manifestPath := filepath.Join(d.Config.PatchDir, e.Name(), "cognitive.json") + if _, err := os.Stat(manifestPath); err == nil { + count++ + } + } + } + d.Log.Printf("patches scanned: %d installed", count) + d.buildModelRegistry() +} + +func (d *Daemon) buildModelRegistry() { + d.modelRegistryMu.Lock() + defer d.modelRegistryMu.Unlock() + + d.modelRegistry = make(map[string]ModelRegistryEntry) + + entries, err := os.ReadDir(d.Config.PatchDir) + if err != nil { + d.Log.Printf("build model registry: %v", err) + return + } + + for _, e := range entries { + if !e.IsDir() { + continue + } + manifestPath := filepath.Join(d.Config.PatchDir, e.Name(), "cognitive.json") + data, err := os.ReadFile(manifestPath) + if err != nil { + continue + } + + var manifest struct { + Brain *struct { + WideModel *struct { + Routing *struct { + ModelID string `json:"model_id"` + Tags []string `json:"tags"` + } `json:"routing"` + Weights *struct { + Remote *struct { + Filename string `json:"filename"` + } `json:"remote"` + } `json:"weights"` + } `json:"wide_model"` + } `json:"brain"` + } + + if err := json.Unmarshal(data, &manifest); err != nil { + continue + } + + if manifest.Brain == nil || manifest.Brain.WideModel == nil || manifest.Brain.WideModel.Routing == nil { + continue + } + + routing := manifest.Brain.WideModel.Routing + if routing.ModelID == "" { + continue + } + + ggufPath := "" + if manifest.Brain.WideModel.Weights != nil && manifest.Brain.WideModel.Weights.Remote != nil { + ggufPath = filepath.Join(d.Config.PatchDir, e.Name(), "weights", + manifest.Brain.WideModel.Weights.Remote.Filename) + } + + if ggufPath == "" { + d.Log.Printf("model registry: no gguf weights path for %s, skipping", routing.ModelID) + continue + } + + if _, err := os.Stat(ggufPath); err != nil { + d.Log.Printf("model registry: gguf not found at %s for %s", ggufPath, routing.ModelID) + continue + } + + d.modelRegistry[routing.ModelID] = ModelRegistryEntry{ + ModelID: routing.ModelID, + Tags: routing.Tags, + GGUFFilePath: ggufPath, + } + d.Log.Printf("model registry: registered %s (%s)", routing.ModelID, ggufPath) + } +} + +func (d *Daemon) modelRegistryRoutingHints() map[string][]string { + d.modelRegistryMu.RLock() + defer d.modelRegistryMu.RUnlock() + + hints := make(map[string][]string, len(d.modelRegistry)) + for id, entry := range d.modelRegistry { + hints[id] = entry.Tags + } + return hints +} + +func (d *Daemon) resolveModelGGUF(modelID string) string { + d.modelRegistryMu.RLock() + defer d.modelRegistryMu.RUnlock() + + if entry, ok := d.modelRegistry[modelID]; ok { + return entry.GGUFFilePath + } + return "" +} + +func (d *Daemon) hotSwapWideModel(modelID string) error { + if modelID == "" { + return nil + } + + currentID := d.wmClient.LoadedModelID() + if currentID == modelID { + return nil + } + + ggufPath := d.resolveModelGGUF(modelID) + if ggufPath == "" { + return fmt.Errorf("model %s not found in registry", modelID) + } + + d.Log.Printf("hot-swap: unloading current model, loading %s (%s)", modelID, ggufPath) + + if err := d.wmClient.Unload("swap"); err != nil { + d.Log.Printf("hot-swap: unload error: %v", err) + } + + systemPrompt, err := d.mergeSystemPrompts(modelID) + if err != nil { + d.Log.Printf("hot-swap: merge system prompts: %v", err) + } + + if err := d.wmClient.LoadWithID(ggufPath, modelID); err != nil { + d.Log.Printf("hot-swap: load error: %v", err) + return fmt.Errorf("load %s: %w", modelID, err) + } + + if systemPrompt != "" { + d.wmClient.SetSystemPrompt(systemPrompt) + } + + d.Log.Printf("hot-swap: active model is now %s", modelID) + return nil +} + +func (d *Daemon) mergeSystemPrompts(modelID string) (string, error) { + var prompts []string + + basePath := "/cognitiveos/etc/base-prompt.md" + if data, err := os.ReadFile(basePath); err == nil { + prompts = append(prompts, string(data)) + } + + entries, err := os.ReadDir(d.Config.PatchDir) + if err != nil { + return strings.Join(prompts, "\n"), nil + } + + for _, e := range entries { + if !e.IsDir() { + continue + } + manifestPath := filepath.Join(d.Config.PatchDir, e.Name(), "cognitive.json") + data, err := os.ReadFile(manifestPath) + if err != nil { + continue + } + var manifest struct { + Runtime *struct { + SystemPrompt string `json:"system_prompt"` + } `json:"runtime"` + } + if err := json.Unmarshal(data, &manifest); err != nil { + continue + } + if manifest.Runtime != nil && manifest.Runtime.SystemPrompt != "" { + promptPath := filepath.Join(d.Config.PatchDir, e.Name(), manifest.Runtime.SystemPrompt) + if promptData, err := os.ReadFile(promptPath); err == nil { + prompts = append(prompts, string(promptData)) + } + } + } + + merged := strings.Join(prompts, "\n\n") + return merged, nil +} + +func (d *Daemon) patchCount() int { + entries, err := os.ReadDir(d.Config.PatchDir) + if err != nil { + return 0 + } + count := 0 + for _, e := range entries { + if e.IsDir() { + manifestPath := filepath.Join(d.Config.PatchDir, e.Name(), "cognitive.json") + if _, err := os.Stat(manifestPath); err == nil { + count++ + } + } + } + return count +} + +func (d *Daemon) loadWideModel() error { + d.modelRegistryMu.RLock() + hasRegistry := len(d.modelRegistry) > 0 + d.modelRegistryMu.RUnlock() + + if hasRegistry { + d.modelRegistryMu.RLock() + for id, entry := range d.modelRegistry { + if d.rmClient.IsReady() { + _, _, _, allowed, err := d.rmClient.AuditResources(0) + if err != nil { + d.Log.Printf("audit before load: %v", err) + } else if !allowed { + d.Log.Printf("WARN: insufficient resources for Wide Model load") + d.modelRegistryMu.RUnlock() + return fmt.Errorf("insufficient resources") + } + } + if err := d.wmClient.LoadWithID(entry.GGUFFilePath, id); err != nil { + d.Log.Printf("load model %s (%s): %v", id, entry.GGUFFilePath, err) + continue + } + systemPrompt, _ := d.mergeSystemPrompts(id) + if systemPrompt != "" { + d.wmClient.SetSystemPrompt(systemPrompt) + } + d.Log.Printf("wide model loaded from registry: %s (%s)", id, entry.GGUFFilePath) + d.modelRegistryMu.RUnlock() + return nil + } + d.modelRegistryMu.RUnlock() + d.Log.Printf("no registry models could be loaded, falling back to directory scan") + } + + modelDir := filepath.Join(d.Config.ModelDir, "wide", "active") + entries, err := os.ReadDir(modelDir) + if err != nil { + return fmt.Errorf("read wide model dir %s: %w", modelDir, err) + } + + for _, e := range entries { + if !e.IsDir() && (strings.HasSuffix(e.Name(), ".gguf") || strings.HasSuffix(e.Name(), ".safetensors")) { + modelPath := filepath.Join(modelDir, e.Name()) + if d.rmClient.IsReady() { + _, _, _, allowed, err := d.rmClient.AuditResources(0) + if err != nil { + d.Log.Printf("audit before load: %v", err) + } else if !allowed { + d.Log.Printf("WARN: insufficient resources for Wide Model load") + return fmt.Errorf("insufficient resources") + } + } + if err := d.wmClient.Load(modelPath); err != nil { + return err + } + d.Log.Printf("wide model loaded from directory: %s", modelPath) + return nil + } + } + return fmt.Errorf("no model file found in %s", modelDir) +} + +func (d *Daemon) startIdleTimer() { + d.mu.Lock() + defer d.mu.Unlock() + d.lastRequest = time.Now() + if d.idleTimer != nil { + d.idleTimer.Stop() + } + d.idleTimer = time.AfterFunc(idleTimeoutDuration, func() { + d.mu.Lock() + if time.Since(d.lastRequest) >= idleTimeoutDuration { + d.mu.Unlock() + d.Log.Println("idle timeout: unloading Wide Model") + d.wmClient.Unload("idle_timeout") + d.SetState(StateIdle) + } else { + d.mu.Unlock() + } + }) +} + +func (d *Daemon) touchIdleTimer() { + d.mu.Lock() + defer d.mu.Unlock() + d.lastRequest = time.Now() +} + func (d *Daemon) shutdown(reason string) { d.mu.Lock() d.State = StateShutdown @@ -127,11 +454,13 @@ func (d *Daemon) shutdown(reason string) { d.broadcast(NewEnvelope("shutdown_notice", "cognitiveosd", ShutdownNoticePayload{Reason: reason})) + d.wmClient.Unload(reason) d.mcpMgr.ShutdownAll() - d.rmClient.Close() - d.listener.Close() + time.Sleep(500 * time.Millisecond) + d.rmClient.Close() + d.listener.Close() d.auditor.Stop() d.clientsMu.Lock() @@ -141,6 +470,21 @@ func (d *Daemon) shutdown(reason string) { } d.clientsMu.Unlock() + switch reason { + case "security_code": + d.Log.Println("SECURITY: powering off peripherals") + exec.Command("gpioset", "0", "0=0").Run() + exec.Command("gpioset", "0", "1=0").Run() + case "idle_code": + d.Log.Println("IDLE: entering low-power suspend") + exec.Command("sysctl", "-w", "kernel.printk=0").Run() + case "reset_code": + d.Log.Println("RESET: wiping data partitions") + exec.Command("rm", "-rf", "/cognitiveos/data/*").Run() + exec.Command("rm", "-rf", "/cognitiveos/models/wide/*").Run() + exec.Command("rm", "-rf", "/cognitiveos/patches/*").Run() + } + d.Log.Println("shutdown complete") } @@ -206,6 +550,10 @@ func (d *Daemon) HandleMessage(env Envelope, conn *ClientConn) { d.handleAuditRequest(env, conn) case "status_request": d.handleStatusRequest(env, conn) + case "wide_model_load": + d.handleWideModelLoad(env, conn) + case "wide_model_unload": + d.handleWideModelUnload(env, conn) default: d.SendError(env, conn, "E_UNKNOWN_TYPE", fmt.Sprintf("unknown message type: %s", env.Type)) } @@ -266,6 +614,10 @@ func responseType(msgType string) string { return "audit_report" case "status_request": return "status_response" + case "wide_model_load": + return "wide_model_loaded" + case "wide_model_unload": + return "wide_model_unloaded" default: return msgType + "_response" } diff --git a/internal/daemon/handlers.go b/internal/daemon/handlers.go index e3a6e38..621ba8b 100644 --- a/internal/daemon/handlers.go +++ b/internal/daemon/handlers.go @@ -3,6 +3,8 @@ package daemon import ( "encoding/json" "fmt" + "os" + "path/filepath" "strings" "time" ) @@ -19,7 +21,6 @@ func (d *Daemon) handleInputForward(env Envelope, conn *ClientConn) { } d.SetState(StateProcessing) - d.SendOK(env, conn, nil) sessionID := payload.Context.SessionID @@ -28,26 +29,255 @@ func (d *Daemon) handleInputForward(env Envelope, conn *ClientConn) { } go func() { - resp, err := d.wmClient.Generate(payload.Content) - if err != nil { - d.Log.Printf("inference error: %v", err) - d.SendToClient(env.From, NewEnvelope("output_deliver", "cognitiveosd", OutputPayload{ - SessionID: sessionID, - Content: fmt.Sprintf("Error: %v", err), - ContentType: "text", - })) - d.SetState(StateListening) - return - } + d.processPrompt(payload.Content, sessionID, env.From) + }() +} + +func (d *Daemon) processPrompt(prompt, sessionID, from string) { + d.touchIdleTimer() + routingHints := d.modelRegistryRoutingHints() + action, modifiedPrompt, reason, modelID, err := d.rmClient.ValidatePrompt(prompt, routingHints) + if err != nil { + d.Log.Printf("raw model validate error: %v", err) + d.SendToClient(from, NewEnvelope("output_deliver", "cognitiveosd", OutputPayload{ + SessionID: sessionID, + Content: fmt.Sprintf("Guardrail error: %v", err), + ContentType: "text", + })) d.SetState(StateListening) + return + } - d.SendToClient(env.From, NewEnvelope("output_deliver", "cognitiveosd", OutputPayload{ + switch action { + case "deny": + msg := "Request blocked by system guardrail." + if reason != "" { + msg = "Guardrail: " + reason + } + d.SendToClient(from, NewEnvelope("output_deliver", "cognitiveosd", OutputPayload{ SessionID: sessionID, - Content: resp, + Content: msg, ContentType: "text", })) - }() + d.SetState(StateListening) + return + + case "modify": + if modifiedPrompt != "" { + prompt = modifiedPrompt + } + case "allow": + } + + if modelID != "" { + if err := d.hotSwapWideModel(modelID); err != nil { + d.Log.Printf("hot-swap to %s failed: %v, using current model", modelID, err) + } + } + + resp, err := d.wmClient.Generate(prompt) + if err != nil { + d.Log.Printf("inference error: %v", err) + d.SendToClient(from, NewEnvelope("output_deliver", "cognitiveosd", OutputPayload{ + SessionID: sessionID, + Content: fmt.Sprintf("Error: %v", err), + ContentType: "text", + })) + d.SetState(StateListening) + return + } + + finalResponse, toolResults := d.toolLoop(resp, prompt, sessionID) + + for _, tr := range toolResults { + d.SendToClient(from, NewEnvelope("output_deliver", "cognitiveosd", OutputPayload{ + SessionID: sessionID, + Content: tr, + ContentType: "tool_result", + })) + } + + d.SetState(StateListening) + d.SendToClient(from, NewEnvelope("output_deliver", "cognitiveosd", OutputPayload{ + SessionID: sessionID, + Content: finalResponse, + ContentType: "text", + })) +} + +func (d *Daemon) toolLoop(response, originalPrompt, sessionID string) (string, []string) { + currentResponse := response + var toolResults []string + maxLoops := 10 + for i := 0; i < maxLoops; i++ { + toolCalls := parseToolCalls(currentResponse) + + if len(toolCalls) == 0 { + return currentResponse, toolResults + } + + var results []string + for _, tc := range toolCalls { + // Validate if tool is in a validated namespace + if isToolValidated(tc.Tool) { + op := operationFromTool(tc.Tool) + pkgName, _ := tc.Arguments["name"].(string) + version, _ := tc.Arguments["version"].(string) + + var manifestMeta *PackageManifestMetadata + if op == "install" || op == "update" { + pkg := pkgName + if pkg == "" { + if name, ok := tc.Arguments["package_name"].(string); ok { + pkg = name + } + } + if pkg != "" { + manifestMeta = d.mcpMgr.fetchManifestMetadata(d.Config.RegistryURL, pkg, version) + } + } + + validationParams := PackageValidationParams{ + Operation: op, + PackageName: pkgName, + Version: version, + ManifestMetadata: manifestMeta, + } + + validationResult, err := d.rmClient.ValidatePackageRequest(validationParams) + if err != nil { + d.Log.Printf("package validation error: %v", err) + results = append(results, fmt.Sprintf("Error validating %s: %v", tc.Tool, err)) + continue + } + + if validationResult.Status != "approved" { + reason := validationResult.Reason + if reason == "" { + reason = "operation denied by system guardrail" + } + d.Log.Printf("package validation denied: %s (%s)", tc.Tool, reason) + results = append(results, fmt.Sprintf("Tool %s denied: %s", tc.Tool, reason)) + continue + } + } + + result, err := d.mcpMgr.Invoke(tc.Tool, tc.Arguments, sessionID) + if err != nil { + d.Log.Printf("tool invoke error: %v", err) + results = append(results, fmt.Sprintf("Error calling %s: %v", tc.Tool, err)) + continue + } + + var resultText string + for _, c := range result.Content { + resultText += c.Text + } + results = append(results, fmt.Sprintf("Tool %s returned: %s", tc.Tool, resultText)) + toolResults = append(toolResults, fmt.Sprintf("%s → %s", tc.Tool, resultText)) + d.Log.Printf("tool %s result: %s", tc.Tool, result.Status) + } + + newResp, err := d.wmClient.Generate(originalPrompt + "\n\nTool results:\n" + strings.Join(results, "\n") + "\n\nContinue.") + if err != nil { + d.Log.Printf("re-generate error: %v", err) + return currentResponse, toolResults + } + currentResponse = newResp + } + + return currentResponse, toolResults +} + +func parseToolCalls(response string) []ToolCall { + var calls []ToolCall + lines := strings.Split(response, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "@@") || !strings.HasSuffix(line, "@@") { + continue + } + inner := line[2 : len(line)-2] + parenIdx := strings.Index(inner, "(") + if parenIdx < 0 { + continue + } + toolName := inner[:parenIdx] + argsStr := inner[parenIdx+1 : len(inner)-1] + + args := make(map[string]interface{}) + if argsStr != "" { + pairs := strings.Split(argsStr, ",") + for _, pair := range pairs { + pair = strings.TrimSpace(pair) + eqIdx := strings.Index(pair, "=") + if eqIdx < 0 { + continue + } + k := strings.TrimSpace(pair[:eqIdx]) + v := strings.Trim(strings.TrimSpace(pair[eqIdx+1:]), "\"") + args[k] = v + } + } + + calls = append(calls, ToolCall{Tool: toolName, Arguments: args}) + } + return calls +} + +func (d *Daemon) handleWideModelLoad(env Envelope, conn *ClientConn) { + var payload WideModelLoadPayload + if err := json.Unmarshal(env.Payload, &payload); err != nil { + d.SendError(env, conn, "E_INVALID_PAYLOAD", err.Error()) + return + } + + modelPath := payload.ModelPath + if modelPath == "" { + modelPath = filepath.Join(d.Config.ModelDir, "wide", "active") + } + + if _, err := os.Stat(modelPath); os.IsNotExist(err) { + d.SendError(env, conn, "E_MODEL_NOT_FOUND", modelPath) + return + } + + if err := d.wmClient.Load(modelPath); err != nil { + d.SendError(env, conn, "E_MODEL_LOAD_FAILED", err.Error()) + return + } + + d.SendOK(env, conn, WideModelLoadedPayload{ + Status: "ok", + ModelInfo: &WideModelInfo{ + Loaded: d.wmClient.LoadedModelName(), + RAMUsageMB: 0, + }, + }) +} + +func (d *Daemon) handleWideModelUnload(env Envelope, conn *ClientConn) { + var payload WideModelUnloadPayload + if err := json.Unmarshal(env.Payload, &payload); err != nil { + d.SendError(env, conn, "E_INVALID_PAYLOAD", err.Error()) + return + } + + reason := payload.Reason + if reason == "" { + reason = "requested" + } + + if err := d.wmClient.Unload(reason); err != nil { + d.SendError(env, conn, "E_MODEL_UNLOAD_FAILED", err.Error()) + return + } + + d.SendOK(env, conn, WideModelUnloadedPayload{ + Status: "ok", + RAMFreedMB: 0, + }) } func (d *Daemon) handleSystemCode(env Envelope, conn *ClientConn) { @@ -58,10 +288,20 @@ func (d *Daemon) handleSystemCode(env Envelope, conn *ClientConn) { } code := strings.ToLower(payload.Code) + origin := strings.ToLower(payload.Origin) + + if code == "security" || code == "reset" { + if origin == "keyboard" || origin == "voice" || origin == "cli" { + d.Log.Printf("WARN: %s code rejected from software origin: %s", code, origin) + d.SendError(env, conn, "E_UNAUTHORIZED", fmt.Sprintf("%s code requires physical trigger", code)) + return + } + } + effect := "" if d.rmClient.IsReady() { - status, action, err := d.rmClient.ValidateSystemCode(code, payload.Origin) + status, action, err := d.rmClient.ValidateSystemCode(code, origin) if err != nil { d.SendError(env, conn, "E_RAW_MODEL_ERROR", err.Error()) return @@ -80,9 +320,10 @@ func (d *Daemon) handleSystemCode(env Envelope, conn *ClientConn) { case "idle": effect = "entering idle state" - d.SetState(StateIdle) + d.SetState(StateIdleRequested) d.wmClient.Unload("idle") d.mcpMgr.ShutdownAll() + d.SetState(StateIdle) case "security": effect = "SECURITY SHUTDOWN: terminating all processes" @@ -208,10 +449,15 @@ func (d *Daemon) handleStatusRequest(env Envelope, conn *ClientConn) { wmStatus := WideModelStatus{Status: "unloaded"} if d.wmClient.IsLoaded() { - wmStatus = WideModelStatus{Status: "loaded", Name: d.wmClient.LoadedModelName()} + wmStatus = WideModelStatus{ + Status: "loaded", + Name: d.wmClient.LoadedModelName(), + ModelID: d.wmClient.LoadedModelID(), + } } mcpCount := d.mcpMgr.ActiveCount() + regCount := len(d.modelRegistryRoutingHints()) resp := Envelope{ Type: "status_response", @@ -223,7 +469,8 @@ func (d *Daemon) handleStatusRequest(env Envelope, conn *ClientConn) { State: state, UptimeSeconds: uptime, WideModel: wmStatus, - PatchesInstalled: 0, + ModelRegistry: regCount, + PatchesInstalled: d.patchCount(), MCPServersActive: mcpCount, } respPayload, _ := json.Marshal(payload) diff --git a/internal/daemon/mcp_lifecycle.go b/internal/daemon/mcp_lifecycle.go index a687917..837a20f 100644 --- a/internal/daemon/mcp_lifecycle.go +++ b/internal/daemon/mcp_lifecycle.go @@ -4,15 +4,87 @@ import ( "bufio" "encoding/json" "fmt" + "net/http" "os" "os/exec" "path/filepath" + "strconv" "strings" "sync" "syscall" "time" ) +const mcpCgroupRoot = "/sys/fs/cgroup" + +type cgroupLimits struct { + memoryMB int64 + cpuQuota int + cpuPeriod int + pidsMax int + ioReadMBps int64 + ioWriteMBps int64 +} + +func defaultCgroupLimits() cgroupLimits { + return cgroupLimits{ + memoryMB: 512, + cpuQuota: 25000, + cpuPeriod: 100000, + pidsMax: 16, + ioReadMBps: 10, + ioWriteMBps: 5, + } +} + +func setupCgroup(name string, limits cgroupLimits) (string, error) { + cgPath := filepath.Join(mcpCgroupRoot, "cognitiveos", name) + os.MkdirAll(cgPath, 0755) + + memMax := filepath.Join(cgPath, "memory.max") + if err := os.WriteFile(memMax, []byte(fmt.Sprintf("%dM", limits.memoryMB)), 0644); err != nil { + return cgPath, fmt.Errorf("set memory.max: %w", err) + } + + cpuMax := filepath.Join(cgPath, "cpu.max") + if err := os.WriteFile(cpuMax, []byte(fmt.Sprintf("%d %d", limits.cpuQuota, limits.cpuPeriod)), 0644); err != nil { + return cgPath, fmt.Errorf("set cpu.max: %w", err) + } + + pidsMax := filepath.Join(cgPath, "pids.max") + if err := os.WriteFile(pidsMax, []byte(strconv.Itoa(limits.pidsMax)), 0644); err != nil { + return cgPath, fmt.Errorf("set pids.max: %w", err) + } + + ioMax := filepath.Join(cgPath, "io.max") + ioRule := fmt.Sprintf("0:0 riops=max wiops=max rbps=%d wbps=%d\n", limits.ioReadMBps*1024*1024, limits.ioWriteMBps*1024*1024) + if err := os.WriteFile(ioMax, []byte(ioRule), 0644); err != nil { + return cgPath, fmt.Errorf("set io.max: %w", err) + } + + return cgPath, nil +} + +func joinCgroup(pid int, cgPath string) error { + procs := filepath.Join(cgPath, "cgroup.procs") + return os.WriteFile(procs, []byte(strconv.Itoa(pid)), 0644) +} + +var deniedSyscalls = []string{ + "mount", "umount", "umount2", + "reboot", "kexec_load", + "init_module", "finit_module", "delete_module", + "bpf", + "iopl", "ioperm", + "ptrace", + "swapon", "swapoff", +} + +func setupSeccomp(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{} + // NoNewPrivileges requires Go 1.24+ — skip for 1.23 compatibility +} + type MCPServer struct { Name string Info MCPInfo @@ -49,7 +121,17 @@ func (m *MCPManager) SpawnCoreBridges() { } func (m *MCPManager) Spawn(name string, binaryPath string) { + cgPath, _ := setupCgroup(name, defaultCgroupLimits()) + cmd := exec.Command(binaryPath) + setupSeccomp(cmd) + if cmd.SysProcAttr != nil { + chrootPath := filepath.Join(m.daemon.Config.PatchDir, name) + if info, err := os.Stat(chrootPath); err == nil && info.IsDir() { + cmd.SysProcAttr.Chroot = chrootPath + cmd.Dir = "/" + } + } stdin, err := cmd.StdinPipe() if err != nil { @@ -68,6 +150,12 @@ func (m *MCPManager) Spawn(name string, binaryPath string) { return } + if cgPath != "" { + if err := joinCgroup(cmd.Process.Pid, cgPath); err != nil { + m.daemon.Log.Printf("MCP %s: cgroup join: %v", name, err) + } + } + encoder := json.NewEncoder(stdin) scanner := bufio.NewScanner(stdout) scanner.Buffer(make([]byte, 65536), 1048576) @@ -81,7 +169,7 @@ func (m *MCPManager) Spawn(name string, binaryPath string) { } m.RegisterProcess(name, server) - m.daemon.Log.Printf("MCP %s: spawned (pid %d)", name, cmd.Process.Pid) + m.daemon.Log.Printf("MCP %s: spawned (pid %d) cgroup=%s", name, cmd.Process.Pid, cgPath) server.DiscoverTools(encoder, scanner) @@ -131,6 +219,79 @@ func (m *MCPManager) Unregister(name string) { } } +var validatedNamespaces = map[string]bool{ + "cognitiveos.package": true, +} + +func isToolValidated(toolName string) bool { + for ns := range validatedNamespaces { + if strings.HasPrefix(toolName, ns) { + return true + } + } + return false +} + +func operationFromTool(toolName string) string { + parts := strings.Split(toolName, ".") + if len(parts) < 3 { + return "" + } + action := parts[len(parts)-1] + switch action { + case "search": + return "search" + case "list": + return "list" + case "install": + return "install" + case "remove": + return "remove" + case "info": + return "info" + case "update": + return "update" + default: + return action + } +} + +func (m *MCPManager) fetchManifestMetadata(registryURL, packageName, version string) *PackageManifestMetadata { + if registryURL == "" { + return nil + } + + url := registryURL + "/v1/patches/" + packageName + if version != "" { + url += "/" + version + } + + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Get(url) + if err != nil { + m.daemon.Log.Printf("fetch manifest: %v", err) + return nil + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil + } + + var manifest PackageRegistryManifest + if err := json.NewDecoder(resp.Body).Decode(&manifest); err != nil { + m.daemon.Log.Printf("decode manifest: %v", err) + return nil + } + + return &PackageManifestMetadata{ + HasRawModel: manifest.HasRawModel, + IsCritical: manifest.IsCritical, + DiskSpaceMB: manifest.DiskSpaceMB, + Registry: manifest.Registry, + } +} + func (m *MCPManager) Invoke(toolName string, args map[string]interface{}, sessionID string) (MCPResultPayload, error) { m.mu.RLock() defer m.mu.RUnlock() @@ -227,13 +388,50 @@ func (m *MCPManager) ActiveCount() int { return len(m.servers) } +func (m *MCPManager) StartHealthchecks() { + go func() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for range ticker.C { + m.mu.RLock() + for name, server := range m.servers { + if !server.active { + continue + } + if server.Process == nil || server.Process.Process == nil { + continue + } + if err := server.Process.Process.Signal(syscall.Signal(0)); err != nil { + m.daemon.Log.Printf("MCP %s: healthcheck failed: %v", name, err) + go m.respawn(name) + } + } + m.mu.RUnlock() + } + }() + m.daemon.Log.Println("MCP healthcheck loop started (30s interval)") +} + +func (m *MCPManager) respawn(name string) { + time.Sleep(2 * time.Second) + m.Spawn(name, name) +} + func (m *MCPManager) ShutdownAll() { m.mu.Lock() defer m.mu.Unlock() for name, server := range m.servers { if server.Process != nil && server.Process.Process != nil { - server.Process.Process.Signal(syscall.SIGTERM) // best-effort signal + if server.active && server.Conn != nil { + shutdownMsg := map[string]string{"type": "mcp_shutdown", "reason": "daemon_shutdown"} + server.mu.Lock() + if server.Stdin != nil { + server.Stdin.Encode(shutdownMsg) + } + server.mu.Unlock() + } + server.Process.Process.Signal(syscall.SIGTERM) go func(p *os.Process, n string) { done := make(chan error, 1) go func() { @@ -243,7 +441,7 @@ func (m *MCPManager) ShutdownAll() { select { case <-done: case <-time.After(2 * time.Second): - p.Kill() // best-effort kill + p.Kill() } }(server.Process.Process, name) } diff --git a/internal/daemon/raw_client.go b/internal/daemon/raw_client.go index fd5ec0c..169e3bb 100644 --- a/internal/daemon/raw_client.go +++ b/internal/daemon/raw_client.go @@ -154,6 +154,46 @@ func (r *RawModelClient) AuditResources(requestedMB int64) (bool, int64, int64, return resp.Available, resp.TotalMB, resp.FreeMB, resp.Allowed, nil } +func (r *RawModelClient) ValidatePrompt(prompt string, routingHints map[string][]string) (string, string, string, string, error) { + params := map[string]interface{}{ + "prompt": prompt, + } + if len(routingHints) > 0 { + params["routing_hints"] = routingHints + } + + result, err := r.call("validate_prompt", params) + if err != nil { + return "", "", "", "", err + } + + var resp struct { + Action string `json:"action"` + ModifiedPrompt string `json:"modified_prompt,omitempty"` + Reason string `json:"reason,omitempty"` + Model string `json:"model,omitempty"` + } + if err := json.Unmarshal(result, &resp); err != nil { + return "", "", "", "", fmt.Errorf("parse validate_prompt response: %w", err) + } + + return resp.Action, resp.ModifiedPrompt, resp.Reason, resp.Model, nil +} + +func (r *RawModelClient) ValidatePackageRequest(params PackageValidationParams) (PackageValidationResult, error) { + result, err := r.call("validate_package_request", params) + if err != nil { + return PackageValidationResult{}, err + } + + var resp PackageValidationResult + if err := json.Unmarshal(result, &resp); err != nil { + return PackageValidationResult{}, fmt.Errorf("parse validate_package_request response: %w", err) + } + + return resp, nil +} + func (r *RawModelClient) IsReady() bool { r.mu.Lock() defer r.mu.Unlock() diff --git a/internal/daemon/types.go b/internal/daemon/types.go index 01d307e..f6e69f6 100644 --- a/internal/daemon/types.go +++ b/internal/daemon/types.go @@ -93,10 +93,9 @@ type MCPUnregisterPayload struct { Reason string `json:"reason"` } -type MCPInvokePayload struct { - Tool string `json:"tool"` +type ToolCall struct { + Tool string `json:"tool"` Arguments map[string]interface{} `json:"arguments"` - SessionID string `json:"session_id,omitempty"` } type MCPResultPayload struct { @@ -158,21 +157,30 @@ type NetworkInfo struct { type StatusRequestPayload struct{} type StatusResponsePayload struct { - State string `json:"state"` - UptimeSeconds int64 `json:"uptime_seconds"` - WideModel WideModelStatus `json:"wide_model"` - PatchesInstalled int `json:"patches_installed"` - MCPServersActive int `json:"mcp_servers_active"` + State string `json:"state"` + UptimeSeconds int64 `json:"uptime_seconds"` + WideModel WideModelStatus `json:"wide_model"` + ModelRegistry int `json:"model_registry_count"` + PatchesInstalled int `json:"patches_installed"` + MCPServersActive int `json:"mcp_servers_active"` } type WideModelStatus struct { - Status string `json:"status"` - Name string `json:"name,omitempty"` + Status string `json:"status"` + Name string `json:"name,omitempty"` + ModelID string `json:"model_id,omitempty"` +} + +type ModelRegistryEntry struct { + ModelID string `json:"model_id"` + Tags []string `json:"tags,omitempty"` + GGUFFilePath string `json:"gguf_file_path"` } type WideModelLoadPayload struct { - ModelPath string `json:"model_path"` - Params map[string]interface{} `json:"params,omitempty"` + ModelPath string `json:"model_path"` + SystemPrompt string `json:"system_prompt,omitempty"` + Params map[string]interface{} `json:"params,omitempty"` } type WideModelLoadedPayload struct { @@ -200,6 +208,35 @@ type ShutdownNoticePayload struct { Reason string `json:"reason"` } +type PackageValidationParams struct { + Operation string `json:"operation"` + PackageName string `json:"package_name"` + Version string `json:"version,omitempty"` + ManifestMetadata *PackageManifestMetadata `json:"manifest_metadata,omitempty"` +} + +type PackageManifestMetadata struct { + HasRawModel bool `json:"has_raw_model,omitempty"` + DiskSpaceMB int64 `json:"disk_space_mb,omitempty"` + Registry string `json:"registry,omitempty"` + IsCritical bool `json:"is_critical,omitempty"` +} + +type PackageValidationResult struct { + Status string `json:"status"` + Reason string `json:"reason"` + Command string `json:"command"` +} + +type PackageRegistryManifest struct { + Name string `json:"name"` + Version string `json:"version"` + HasRawModel bool `json:"has_raw_model,omitempty"` + IsCritical bool `json:"is_critical,omitempty"` + DiskSpaceMB int64 `json:"disk_space_mb,omitempty"` + Registry string `json:"registry,omitempty"` +} + func NewEnvelope(msgType string, from string, payload interface{}) Envelope { b, _ := json.Marshal(payload) return Envelope{ diff --git a/internal/daemon/wide_client.go b/internal/daemon/wide_client.go index 81eb0ff..bd5e35b 100644 --- a/internal/daemon/wide_client.go +++ b/internal/daemon/wide_client.go @@ -12,11 +12,13 @@ import ( ) type WideModelClient struct { - daemon *Daemon - client *http.Client - loaded bool - modelName string - mu sync.RWMutex + daemon *Daemon + client *http.Client + loaded bool + modelName string + modelID string + systemPrompt string + mu sync.RWMutex } func NewWideModelClient(d *Daemon) *WideModelClient { @@ -27,9 +29,18 @@ func NewWideModelClient(d *Daemon) *WideModelClient { } func (w *WideModelClient) Generate(prompt string) (string, error) { + w.mu.RLock() + sysPrompt := w.systemPrompt + w.mu.RUnlock() + + fullPrompt := prompt + if sysPrompt != "" { + fullPrompt = sysPrompt + "\n\n" + prompt + } + body := map[string]interface{}{ "model": "wide-model", - "prompt": prompt, + "prompt": fullPrompt, "stream": false, "options": map[string]interface{}{ "temperature": 0.7, @@ -73,7 +84,24 @@ func (w *WideModelClient) Generate(prompt string) (string, error) { return result.Response, nil } +func (w *WideModelClient) SetSystemPrompt(prompt string) { + w.mu.Lock() + defer w.mu.Unlock() + w.systemPrompt = prompt + w.daemon.Log.Printf("system prompt set (%d bytes)", len(prompt)) +} + +func (w *WideModelClient) SystemPrompt() string { + w.mu.RLock() + defer w.mu.RUnlock() + return w.systemPrompt +} + func (w *WideModelClient) Load(modelPath string) error { + return w.LoadWithID(modelPath, "") +} + +func (w *WideModelClient) LoadWithID(modelPath, modelID string) error { body := map[string]interface{}{ "model": "wide-model", "path": modelPath, @@ -97,6 +125,7 @@ func (w *WideModelClient) Load(modelPath string) error { w.mu.Lock() w.loaded = true w.modelName = modelPath + w.modelID = modelID w.mu.Unlock() return nil @@ -127,6 +156,7 @@ func (w *WideModelClient) Unload(reason string) error { w.mu.Lock() w.loaded = false w.modelName = "" + w.modelID = "" w.mu.Unlock() return nil @@ -147,6 +177,15 @@ func (w *WideModelClient) LoadedModelName() string { return w.modelName } +func (w *WideModelClient) LoadedModelID() string { + w.mu.RLock() + defer w.mu.RUnlock() + if !w.loaded { + return "" + } + return w.modelID +} + func (w *WideModelClient) Health() bool { resp, err := w.client.Get(w.daemon.Config.InferenceURL + "/health") if err != nil {