@@ -3,7 +3,9 @@ WORKDIR /src
|
||||
COPY go.mod ./
|
||||
COPY cmd ./cmd
|
||||
COPY internal ./internal
|
||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/neuroforge ./cmd/server && \
|
||||
RUN go test ./... && \
|
||||
go vet ./... && \
|
||||
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/neuroforge ./cmd/server && \
|
||||
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/neuroforge-worker ./cmd/worker && \
|
||||
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/neuroforge-bench ./cmd/bench
|
||||
|
||||
|
||||
@@ -47,6 +47,41 @@ func envInt(name string) (int, bool) {
|
||||
return v, true
|
||||
}
|
||||
|
||||
func validateManagedSecret(name, value string, minLen int) error {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
upper := strings.ToUpper(value)
|
||||
if strings.Contains(upper, "CHANGE_ME") || strings.Contains(upper, "CHANGEME") || strings.Contains(upper, "PLACEHOLDER") {
|
||||
return fmt.Errorf("%s still contains a placeholder", name)
|
||||
}
|
||||
if len(value) < minLen {
|
||||
return fmt.Errorf("%s must be at least %d characters", name, minLen)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateManagedSecretsFromEnv() error {
|
||||
for _, item := range []struct {
|
||||
name string
|
||||
min int
|
||||
}{
|
||||
{"NEUROFORGE_ADMIN_TOKEN", 24},
|
||||
{"NEUROFORGE_APP_API_KEY", 24},
|
||||
{"NEUROFORGE_INTEGRATION_TOKEN", 24},
|
||||
{"NEUROFORGE_CONTROL_READ_TOKEN", 24},
|
||||
{"NEUROFORGE_WORKER_TOKEN", 24},
|
||||
{"NEUROFORGE_METRICS_TOKEN", 24},
|
||||
{"NEUROFORGE_CLUSTER_TOKEN", 24},
|
||||
} {
|
||||
if err := validateManagedSecret(item.name, os.Getenv(item.name), item.min); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func maxIntMain(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
@@ -66,6 +101,10 @@ func run() (retErr error) {
|
||||
listen := flag.String("listen", "", "listen address override")
|
||||
flag.Parse()
|
||||
|
||||
if err := validateManagedSecretsFromEnv(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s, err := store.New(*data)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -79,12 +118,14 @@ func run() (retErr error) {
|
||||
sec := s.Secrets()
|
||||
changed := false
|
||||
for name, dst := range map[string]*string{
|
||||
"OPENAI_API_KEY": &sec.OpenAIAPIKey,
|
||||
"NEUROFORGE_ADMIN_TOKEN": &sec.AdminToken,
|
||||
"NEUROFORGE_APP_API_KEY": &sec.AppAPIKey,
|
||||
"NEUROFORGE_WORKER_TOKEN": &sec.WorkerToken,
|
||||
"NEUROFORGE_METRICS_TOKEN": &sec.MetricsToken,
|
||||
"NEUROFORGE_CLUSTER_TOKEN": &sec.ClusterToken,
|
||||
"OPENAI_API_KEY": &sec.OpenAIAPIKey,
|
||||
"NEUROFORGE_ADMIN_TOKEN": &sec.AdminToken,
|
||||
"NEUROFORGE_APP_API_KEY": &sec.AppAPIKey,
|
||||
"NEUROFORGE_INTEGRATION_TOKEN": &sec.IntegrationToken,
|
||||
"NEUROFORGE_CONTROL_READ_TOKEN": &sec.ControlReadToken,
|
||||
"NEUROFORGE_WORKER_TOKEN": &sec.WorkerToken,
|
||||
"NEUROFORGE_METRICS_TOKEN": &sec.MetricsToken,
|
||||
"NEUROFORGE_CLUSTER_TOKEN": &sec.ClusterToken,
|
||||
} {
|
||||
if v := os.Getenv(name); v != "" {
|
||||
*dst = v
|
||||
@@ -230,6 +271,9 @@ func run() (retErr error) {
|
||||
defer stopMaintenance()
|
||||
go b.RunV6Maintenance(maintenanceCtx)
|
||||
api := httpapi.New(s, b, r, c)
|
||||
if v, ok := envBool("NEUROFORGE_READINESS_OLLAMA_LIVE"); ok {
|
||||
api.SetReadinessOllamaLive(v)
|
||||
}
|
||||
cfg := s.Config()
|
||||
addr := cfg.Listen
|
||||
if *listen != "" {
|
||||
|
||||
@@ -38,17 +38,17 @@ type relinkResult struct {
|
||||
|
||||
func main() {
|
||||
server := flag.String("server", "http://localhost:8080", "NeuroForge server")
|
||||
token := flag.String("token", os.Getenv("NEUROFORGE_WORKER_TOKEN"), "worker token")
|
||||
id := flag.String("id", hostname(), "worker id")
|
||||
interval := flag.Duration("interval", 2*time.Second, "poll interval")
|
||||
flag.Parse()
|
||||
if *token == "" {
|
||||
log.Fatal("worker token required (-token or NEUROFORGE_WORKER_TOKEN)")
|
||||
token := strings.TrimSpace(os.Getenv("NEUROFORGE_WORKER_TOKEN"))
|
||||
if token == "" {
|
||||
log.Fatal("NEUROFORGE_WORKER_TOKEN is required")
|
||||
}
|
||||
client := &http.Client{Timeout: 180 * time.Second}
|
||||
log.Printf("worker %s polling %s", *id, *server)
|
||||
for {
|
||||
job, err := claim(client, *server, *token, *id)
|
||||
job, err := claim(client, *server, token, *id)
|
||||
if err != nil {
|
||||
log.Printf("claim: %v", err)
|
||||
time.Sleep(*interval)
|
||||
@@ -59,7 +59,7 @@ func main() {
|
||||
continue
|
||||
}
|
||||
res, jobErr := run(job)
|
||||
if err := complete(client, *server, *token, *id, job.ID, res, jobErr); err != nil {
|
||||
if err := complete(client, *server, token, *id, job.ID, res, jobErr); err != nil {
|
||||
log.Printf("complete %s: %v", job.ID, err)
|
||||
} else {
|
||||
log.Printf("job %s %s done", job.ID, job.Type)
|
||||
|
||||
@@ -34,10 +34,30 @@ type Engine struct {
|
||||
electionRunning bool
|
||||
stagingMu sync.RWMutex
|
||||
staging StagingPublisherConfig
|
||||
goalCycleMu sync.Mutex
|
||||
goalCycles map[string]struct{}
|
||||
}
|
||||
|
||||
var ErrGoalCycleInProgress = errors.New("goal cycle already in progress")
|
||||
|
||||
func New(s *store.Store, r *provider.Router, c *cost.Manager) *Engine {
|
||||
return &Engine{store: s, router: r, cost: c, http: &http.Client{Timeout: 10 * time.Second}}
|
||||
return &Engine{store: s, router: r, cost: c, http: &http.Client{Timeout: 10 * time.Second}, goalCycles: map[string]struct{}{}}
|
||||
}
|
||||
|
||||
func (e *Engine) beginGoalCycle(goalID string) bool {
|
||||
e.goalCycleMu.Lock()
|
||||
defer e.goalCycleMu.Unlock()
|
||||
if _, ok := e.goalCycles[goalID]; ok {
|
||||
return false
|
||||
}
|
||||
e.goalCycles[goalID] = struct{}{}
|
||||
return true
|
||||
}
|
||||
|
||||
func (e *Engine) endGoalCycle(goalID string) {
|
||||
e.goalCycleMu.Lock()
|
||||
delete(e.goalCycles, goalID)
|
||||
e.goalCycleMu.Unlock()
|
||||
}
|
||||
|
||||
type ChatRequest struct {
|
||||
|
||||
@@ -150,3 +150,68 @@ func TestGoalProgressDoesNotRegressWhenResearchAuditRunsAreTrimmed(t *testing.T)
|
||||
t.Fatalf("counters regressed: %#v", g)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResearchProgressAndStagingRunWhenGoalSummaryLearningDisabled(t *testing.T) {
|
||||
searx := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"results": []map[string]any{{
|
||||
"title": "Vendor evidence", "url": "https://example.com/vendor", "content": "A supported driver package resolves the documented device issue.", "engine": "test", "score": 0.9,
|
||||
}}})
|
||||
}))
|
||||
defer searx.Close()
|
||||
stagingCalls := 0
|
||||
kb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
stagingCalls++
|
||||
if r.Header.Get("Authorization") != "Bearer staging-token-123456789012345678901234" {
|
||||
t.Fatalf("bad staging auth")
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"staging": map[string]any{"key": "KB-STAGING-GOAL", "meta": map[string]any{"integration_action": "created"}}})
|
||||
}))
|
||||
defer kb.Close()
|
||||
|
||||
s, e := policyTestEngine(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/embed" {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"embeddings": [][]float32{{1, 0, 0, 0}}, "prompt_eval_count": 1})
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
cfg := s.Config()
|
||||
cfg.Research.Enabled = true
|
||||
cfg.Research.SearXNG.Enabled = true
|
||||
cfg.Research.SearXNG.BaseURL = searx.URL
|
||||
cfg.Research.Goal.Enabled = true
|
||||
cfg.Research.WebFetch.Enabled = false
|
||||
cfg.Brain.LearningPolicy.Enabled = true
|
||||
cfg.Brain.LearningPolicy.LearnGoalCycles = false
|
||||
cfg.Autonomy.UseLLM = false
|
||||
cfg.Brain.ExternalRelinkWorker = false
|
||||
if err := s.UpdateConfig(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.ConfigureStagingPublisher(StagingPublisherConfig{Enabled: true, URL: kb.URL, Token: "staging-token-123456789012345678901234", MinEvidence: 1, MinSources: 1, MaxEvidence: 4})
|
||||
goal := core.Goal{Title: "Driver research", Description: "collect sourced driver evidence", Target: "1 quellengebundener Wissenseintrag", Status: core.GoalActive, Priority: 80, ResearchEnabled: true}
|
||||
if err := s.UpsertGoal(&goal); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cycle, err := e.RunGoalCycle(context.Background(), goal.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cycle.MemoryID != "" {
|
||||
t.Fatalf("goal-summary memory should be disabled, got %q", cycle.MemoryID)
|
||||
}
|
||||
updated, ok := s.GetGoal(goal.ID)
|
||||
if !ok {
|
||||
t.Fatal("goal missing")
|
||||
}
|
||||
if updated.ResearchEvidence < 1 || updated.Progress <= 0 {
|
||||
t.Fatalf("research progress not updated: %#v", updated)
|
||||
}
|
||||
if stagingCalls < 1 || updated.LastStagingDraftID != "KB-STAGING-GOAL" {
|
||||
t.Fatalf("staging not published: calls=%d goal=%#v", stagingCalls, updated)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(updated.LastError), "learning policy") {
|
||||
t.Fatalf("legacy learning-policy error survived: %q", updated.LastError)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ func (e *Engine) collectGoalDraftEvidence(goalID string, limit int) []draftEvide
|
||||
if limit <= 0 {
|
||||
limit = 12
|
||||
}
|
||||
runs := e.store.ResearchRunsSnapshot(goalID, 20)
|
||||
runs := e.store.ResearchRunsSnapshot(goalID, 200)
|
||||
ids := map[string]struct{}{}
|
||||
out := make([]draftEvidence, 0, limit)
|
||||
for _, run := range runs {
|
||||
@@ -201,9 +201,60 @@ func (e *Engine) collectGoalDraftEvidence(goalID string, limit int) []draftEvide
|
||||
}
|
||||
}
|
||||
}
|
||||
// Research-run telemetry is bounded. Supplement it with durable provenance so
|
||||
// older source-backed evidence remains eligible after the run history window
|
||||
// rolls over. Newest memories are preferred.
|
||||
memories := e.store.MemoriesSnapshot()
|
||||
for i := len(memories) - 1; i >= 0 && len(out) < limit; i-- {
|
||||
m := memories[i]
|
||||
if m.Status != core.MemoryActive || m.Provenance.GoalID != goalID || m.Provenance.Source == "goal-cycle" || m.Provenance.SourceID == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := ids[m.ID]; ok {
|
||||
continue
|
||||
}
|
||||
var src *core.KnowledgeSource
|
||||
if source, ok := e.store.GetSource(m.Provenance.SourceID); ok {
|
||||
src = source
|
||||
}
|
||||
if src == nil {
|
||||
continue
|
||||
}
|
||||
ids[m.ID] = struct{}{}
|
||||
out = append(out, draftEvidence{Memory: m, Source: src})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CheckStagingPublisher verifies both reachability and the configured integration
|
||||
// credential without creating a draft. Knowledge exposes a dedicated auth-checked
|
||||
// health endpoint for this purpose.
|
||||
func (e *Engine) CheckStagingPublisher(ctx context.Context) error {
|
||||
cfg := e.stagingConfig()
|
||||
if !cfg.Enabled {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(cfg.URL) == "" || strings.TrimSpace(cfg.Token) == "" {
|
||||
return errors.New("staging publisher enabled but URL/token is missing")
|
||||
}
|
||||
healthURL := strings.TrimRight(cfg.URL, "/") + "/health"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+cfg.Token)
|
||||
resp, err := e.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("knowledge staging health HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Engine) synthesizeGoalDraft(ctx context.Context, goal *core.Goal, evidence []draftEvidence) (stagingDraftPayload, error) {
|
||||
var b strings.Builder
|
||||
for i, ev := range evidence {
|
||||
|
||||
@@ -25,6 +25,11 @@ type AutonomyResult struct {
|
||||
}
|
||||
|
||||
func (e *Engine) RunGoalCycle(ctx context.Context, goalID string) (core.LearningCycle, error) {
|
||||
if !e.beginGoalCycle(goalID) {
|
||||
return core.LearningCycle{}, ErrGoalCycleInProgress
|
||||
}
|
||||
defer e.endGoalCycle(goalID)
|
||||
|
||||
goal, ok := e.store.GetGoal(goalID)
|
||||
if !ok {
|
||||
return core.LearningCycle{}, errors.New("goal not found")
|
||||
@@ -34,12 +39,66 @@ func (e *Engine) RunGoalCycle(ctx context.Context, goalID string) (core.Learning
|
||||
}
|
||||
cfg := e.store.Config()
|
||||
lp := cfg.Brain.LearningPolicy
|
||||
if !lp.Enabled || !lp.LearnGoalCycles {
|
||||
return core.LearningCycle{}, errors.New("goal learning is disabled by learning policy")
|
||||
}
|
||||
|
||||
// Research, measurable progress and the human-review staging bridge are a
|
||||
// governance path of their own. They must not be blocked by the policy that
|
||||
// controls whether a semantic goal-summary memory may be learned.
|
||||
researchResult := e.researchGoal(ctx, goal)
|
||||
e.refreshGoalResearchProgress(goal, goal.LastEvaluation)
|
||||
e.maybePublishGoalDraft(ctx, goal, researchResult)
|
||||
|
||||
now := time.Now().UTC()
|
||||
goal.LastCycleAt = now
|
||||
interval := goal.IntervalMinutes
|
||||
if interval <= 0 {
|
||||
interval = cfg.Autonomy.DefaultGoalIntervalMinutes
|
||||
}
|
||||
if interval <= 0 {
|
||||
interval = cfg.Autonomy.IntervalMinutes
|
||||
}
|
||||
goal.NextCycleAt = now.Add(time.Duration(maxIntV3(1, interval)) * time.Minute)
|
||||
goal.ConsecutiveErrors = 0
|
||||
goal.LastError = ""
|
||||
if err := e.store.UpsertGoal(goal); err != nil {
|
||||
return core.LearningCycle{}, err
|
||||
}
|
||||
|
||||
researchQueries := []string{}
|
||||
if strings.TrimSpace(researchResult.Query) != "" {
|
||||
researchQueries = strings.Split(researchResult.Query, " | ")
|
||||
}
|
||||
cycle := core.LearningCycle{
|
||||
ID: store.NewID("cycle"),
|
||||
GoalID: goal.ID,
|
||||
CostUSD: researchResult.CostUSD,
|
||||
CreatedAt: now,
|
||||
ResearchRunID: researchResult.RunID,
|
||||
ResearchQueries: researchQueries,
|
||||
SourcesFound: len(researchResult.Results),
|
||||
SourcesIngested: len(researchResult.Sources),
|
||||
ResearchErrors: append([]string(nil), researchResult.Errors...),
|
||||
}
|
||||
|
||||
if !lp.Enabled || !lp.LearnGoalCycles {
|
||||
cycle.Observation = fmt.Sprintf("Research governance cycle completed: %s", goal.ProgressReason)
|
||||
cycle.Prediction = deterministicPrediction(goal, nil, goal.LastEvaluation)
|
||||
cycle.Evaluation = goal.LastEvaluation
|
||||
cycle.Learning = "Goal-summary memory skipped by learning policy; research, progress and staging were processed independently."
|
||||
goal.Prediction = cycle.Prediction
|
||||
goal.NextAction = deterministicNextAction(goal, nil, goal.LastEvaluation)
|
||||
if err := e.store.UpsertGoal(goal); err != nil {
|
||||
return core.LearningCycle{}, err
|
||||
}
|
||||
if err := e.store.AddLearningCycle(cycle); err != nil {
|
||||
return core.LearningCycle{}, err
|
||||
}
|
||||
_ = e.store.AddKnowledgeEvent(core.KnowledgeEvent{Type: "goal.researched", Summary: "Goal research/progress cycle completed without semantic summary learning", Reason: "goal-summary learning disabled by policy", Actor: "goal-research", Metadata: map[string]string{"goal_id": goal.ID, "research_sources": fmt.Sprint(len(researchResult.Sources)), "research_results": fmt.Sprint(len(researchResult.Results)), "staging_id": goal.LastStagingDraftID}})
|
||||
return cycle, nil
|
||||
}
|
||||
|
||||
query := strings.TrimSpace(goal.Title + "\n" + goal.Description + "\nTarget: " + goal.Target)
|
||||
emb, embedCost, err := e.embed(ctx, query)
|
||||
cycle.CostUSD += embedCost
|
||||
if err != nil {
|
||||
return core.LearningCycle{}, err
|
||||
}
|
||||
@@ -54,7 +113,7 @@ func (e *Engine) RunGoalCycle(ctx context.Context, goalID string) (core.Learning
|
||||
observation := summarizeObservation(evidenceHits, warnings)
|
||||
prediction := deterministicPrediction(goal, evidenceHits, evaluation)
|
||||
nextAction := deterministicNextAction(goal, evidenceHits, evaluation)
|
||||
costUSD := embedCost + researchResult.CostUSD
|
||||
costUSD := cycle.CostUSD
|
||||
if cfg.Autonomy.UseLLM {
|
||||
prompt := fmt.Sprintf("GOAL: %s\nDESCRIPTION: %s\nTARGET: %s\nPROGRESS: %.3f\nOBSERVATIONS:\n%s", goal.Title, goal.Description, goal.Target, goal.Progress, observation)
|
||||
route := roleRoute(cfg.Routing.Goal, cfg.Autonomy.Provider, cfg.Autonomy.Model)
|
||||
@@ -89,29 +148,16 @@ func (e *Engine) RunGoalCycle(ctx context.Context, goalID string) (core.Learning
|
||||
goal.Prediction = prediction
|
||||
goal.NextAction = nextAction
|
||||
goal.LastEvaluation = evaluation
|
||||
goal.LastCycleAt = time.Now().UTC()
|
||||
interval := goal.IntervalMinutes
|
||||
if interval <= 0 {
|
||||
interval = cfg.Autonomy.DefaultGoalIntervalMinutes
|
||||
}
|
||||
if interval <= 0 {
|
||||
interval = cfg.Autonomy.IntervalMinutes
|
||||
}
|
||||
goal.NextCycleAt = goal.LastCycleAt.Add(time.Duration(maxIntV3(1, interval)) * time.Minute)
|
||||
goal.ConsecutiveErrors = 0
|
||||
goal.LastError = ""
|
||||
goal.MemoryIDs = appendUniqueV3(goal.MemoryIDs, mem.ID)
|
||||
// Human-review staging is a one-way governance boundary. Publishing failures
|
||||
// are visible on the goal but never invalidate the durable research/learning cycle.
|
||||
e.maybePublishGoalDraft(ctx, goal, researchResult)
|
||||
if err := e.store.UpsertGoal(goal); err != nil {
|
||||
return core.LearningCycle{}, err
|
||||
}
|
||||
researchQueries := []string{}
|
||||
if strings.TrimSpace(researchResult.Query) != "" {
|
||||
researchQueries = strings.Split(researchResult.Query, " | ")
|
||||
}
|
||||
cycle := core.LearningCycle{ID: store.NewID("cycle"), GoalID: goal.ID, Observation: observation, Prediction: prediction, Evaluation: evaluation, Learning: learning, MemoryID: mem.ID, CostUSD: costUSD, CreatedAt: time.Now().UTC(), ResearchRunID: researchResult.RunID, ResearchQueries: researchQueries, SourcesFound: len(researchResult.Results), SourcesIngested: len(researchResult.Sources), ResearchErrors: append([]string(nil), researchResult.Errors...)}
|
||||
cycle.Observation = observation
|
||||
cycle.Prediction = prediction
|
||||
cycle.Evaluation = evaluation
|
||||
cycle.Learning = learning
|
||||
cycle.MemoryID = mem.ID
|
||||
cycle.CostUSD = costUSD
|
||||
if err := e.store.AddLearningCycle(cycle); err != nil {
|
||||
return core.LearningCycle{}, err
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@ package brain
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"neuroforge/internal/core"
|
||||
)
|
||||
@@ -112,3 +114,57 @@ func TestGoalResearchPersistsTransparentTrace(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoalCycleSingleFlightRejectsConcurrentRun(t *testing.T) {
|
||||
started := make(chan struct{}, 1)
|
||||
release := make(chan struct{})
|
||||
searx := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case started <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
<-release
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"results": []map[string]any{}})
|
||||
}))
|
||||
defer searx.Close()
|
||||
|
||||
s, e := policyTestEngine(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/embed" {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"embeddings": [][]float32{{1, 0, 0, 0}}})
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
cfg := s.Config()
|
||||
cfg.Research.Enabled = true
|
||||
cfg.Research.SearXNG.Enabled = true
|
||||
cfg.Research.SearXNG.BaseURL = searx.URL
|
||||
cfg.Research.Goal.Enabled = true
|
||||
cfg.Research.WebFetch.Enabled = false
|
||||
cfg.Brain.LearningPolicy.LearnGoalCycles = false
|
||||
if err := s.UpdateConfig(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
goal := core.Goal{Title: "single flight", Description: "concurrency guard", Status: core.GoalActive, Priority: 70, ResearchEnabled: true}
|
||||
if err := s.UpsertGoal(&goal); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
firstDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := e.RunGoalCycle(context.Background(), goal.ID)
|
||||
firstDone <- err
|
||||
}()
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("first goal cycle did not enter research")
|
||||
}
|
||||
if _, err := e.RunGoalCycle(context.Background(), goal.ID); !errors.Is(err, ErrGoalCycleInProgress) {
|
||||
t.Fatalf("second cycle error=%v, want %v", err, ErrGoalCycleInProgress)
|
||||
}
|
||||
close(release)
|
||||
if err := <-firstDone; err != nil {
|
||||
t.Fatalf("first cycle failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,6 +395,8 @@ type Config struct {
|
||||
type Secrets struct {
|
||||
OpenAIAPIKey string `json:"openai_api_key"`
|
||||
AppAPIKey string `json:"app_api_key"`
|
||||
IntegrationToken string `json:"integration_token,omitempty"`
|
||||
ControlReadToken string `json:"control_read_token,omitempty"`
|
||||
WorkerToken string `json:"worker_token"`
|
||||
AdminToken string `json:"admin_token"`
|
||||
MetricsToken string `json:"metrics_token,omitempty"`
|
||||
|
||||
@@ -6,9 +6,11 @@ import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
@@ -25,13 +27,14 @@ import (
|
||||
var webFS embed.FS
|
||||
|
||||
type Server struct {
|
||||
store *store.Store
|
||||
brain *brain.Engine
|
||||
router *provider.Router
|
||||
cost *cost.Manager
|
||||
mux *http.ServeMux
|
||||
metrics *metricsRegistry
|
||||
inflight atomic.Int64
|
||||
store *store.Store
|
||||
brain *brain.Engine
|
||||
router *provider.Router
|
||||
cost *cost.Manager
|
||||
mux *http.ServeMux
|
||||
metrics *metricsRegistry
|
||||
inflight atomic.Int64
|
||||
readinessOllamaLive bool
|
||||
}
|
||||
|
||||
func New(s *store.Store, b *brain.Engine, r *provider.Router, c *cost.Manager) *Server {
|
||||
@@ -39,6 +42,8 @@ func New(s *store.Store, b *brain.Engine, r *provider.Router, c *cost.Manager) *
|
||||
x.routes()
|
||||
return x
|
||||
}
|
||||
|
||||
func (s *Server) SetReadinessOllamaLive(enabled bool) { s.readinessOllamaLive = enabled }
|
||||
func (s *Server) Handler() http.Handler {
|
||||
var h http.Handler = s.mux
|
||||
h = s.requestLimits(h)
|
||||
@@ -61,7 +66,7 @@ func (s *Server) routes() {
|
||||
s.mux.Handle("POST /api/v1/search/vector", s.appAuth(http.HandlerFunc(s.searchVector)))
|
||||
s.mux.Handle("POST /api/v1/memory/import", s.appAuth(http.HandlerFunc(s.importMemory)))
|
||||
s.mux.Handle("POST /api/v1/feedback", s.appAuth(http.HandlerFunc(s.feedback)))
|
||||
s.mux.Handle("GET /api/v1/stats", s.appAuth(http.HandlerFunc(s.stats)))
|
||||
s.mux.Handle("GET /api/v1/stats", s.controlReadAuth(http.HandlerFunc(s.stats)))
|
||||
s.mux.Handle("GET /api/v1/goals", s.appAuth(http.HandlerFunc(s.goalsList)))
|
||||
s.mux.Handle("POST /api/v1/goals", s.appAuth(http.HandlerFunc(s.goalsCreate)))
|
||||
s.mux.Handle("GET /api/v1/goals/{id}", s.appAuth(http.HandlerFunc(s.goalsGet)))
|
||||
@@ -79,14 +84,14 @@ func (s *Server) routes() {
|
||||
s.mux.Handle("GET /api/v1/sources", s.appAuth(http.HandlerFunc(s.sourcesList)))
|
||||
s.mux.Handle("GET /api/v1/sources/{id}", s.appAuth(http.HandlerFunc(s.sourceGet)))
|
||||
s.mux.Handle("POST /api/v1/research", s.appAuth(http.HandlerFunc(s.researchSearch)))
|
||||
s.mux.Handle("POST /api/v1/integrations/knowledge/upsert", s.appAuth(http.HandlerFunc(s.integrationKnowledgeUpsert)))
|
||||
s.mux.Handle("DELETE /api/v1/integrations/knowledge/{namespace}/{document_id}", s.appAuth(http.HandlerFunc(s.integrationKnowledgeDelete)))
|
||||
s.mux.Handle("POST /api/v1/integrations/knowledge/search", s.appAuth(http.HandlerFunc(s.integrationKnowledgeSearch)))
|
||||
s.mux.Handle("POST /api/v1/integrations/events", s.appAuth(http.HandlerFunc(s.integrationEvent)))
|
||||
s.mux.Handle("POST /api/v1/integrations/outcomes", s.appAuth(http.HandlerFunc(s.integrationValidatedOutcome)))
|
||||
s.mux.Handle("POST /api/v1/integrations/outcomes/search", s.appAuth(http.HandlerFunc(s.integrationValidatedOutcomeSearch)))
|
||||
s.mux.Handle("GET /api/v1/integrations/graph/research", s.appAuth(http.HandlerFunc(s.integrationResearchGraph)))
|
||||
s.mux.Handle("GET /api/v1/integrations/graph/brain", s.appAuth(http.HandlerFunc(s.integrationBrainGraph)))
|
||||
s.mux.Handle("POST /api/v1/integrations/knowledge/upsert", s.integrationAuth(http.HandlerFunc(s.integrationKnowledgeUpsert)))
|
||||
s.mux.Handle("DELETE /api/v1/integrations/knowledge/{namespace}/{document_id}", s.integrationAuth(http.HandlerFunc(s.integrationKnowledgeDelete)))
|
||||
s.mux.Handle("POST /api/v1/integrations/knowledge/search", s.integrationAuth(http.HandlerFunc(s.integrationKnowledgeSearch)))
|
||||
s.mux.Handle("POST /api/v1/integrations/events", s.integrationAuth(http.HandlerFunc(s.integrationEvent)))
|
||||
s.mux.Handle("POST /api/v1/integrations/outcomes", s.integrationAuth(http.HandlerFunc(s.integrationValidatedOutcome)))
|
||||
s.mux.Handle("POST /api/v1/integrations/outcomes/search", s.integrationAuth(http.HandlerFunc(s.integrationValidatedOutcomeSearch)))
|
||||
s.mux.Handle("GET /api/v1/integrations/graph/research", s.controlReadAuth(http.HandlerFunc(s.integrationResearchGraph)))
|
||||
s.mux.Handle("GET /api/v1/integrations/graph/brain", s.controlReadAuth(http.HandlerFunc(s.integrationBrainGraph)))
|
||||
|
||||
s.mux.Handle("POST /internal/v1/cluster/request-vote", s.clusterAuth(http.HandlerFunc(s.clusterRequestVote)))
|
||||
s.mux.Handle("POST /internal/v1/cluster/heartbeat", s.clusterAuth(http.HandlerFunc(s.clusterHeartbeat)))
|
||||
@@ -232,6 +237,33 @@ func (s *Server) appAuth(next http.Handler) http.Handler {
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
func (s *Server) integrationAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sec := s.store.Secrets()
|
||||
adminOK := secureEqual(r.Header.Get("X-Admin-Token"), sec.AdminToken)
|
||||
integrationOK := secureEqual(bearer(r), sec.IntegrationToken)
|
||||
if !adminOK && !integrationOK {
|
||||
s.err(w, http.StatusUnauthorized, errors.New("invalid integration token or admin token"))
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) controlReadAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sec := s.store.Secrets()
|
||||
adminOK := secureEqual(r.Header.Get("X-Admin-Token"), sec.AdminToken)
|
||||
controlOK := secureEqual(bearer(r), sec.ControlReadToken)
|
||||
appOK := secureEqual(bearer(r), sec.AppAPIKey)
|
||||
if !adminOK && !controlOK && !appOK {
|
||||
s.err(w, http.StatusUnauthorized, errors.New("invalid control/app read token or admin token"))
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) workerAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !secureEqual(bearer(r), s.store.Secrets().WorkerToken) {
|
||||
@@ -571,7 +603,7 @@ func (s *Server) adminPutModelRouting(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) adminSecretsStatus(w http.ResponseWriter, r *http.Request) {
|
||||
sec := s.store.Secrets()
|
||||
s.json(w, 200, map[string]any{"openai_configured": sec.OpenAIAPIKey != "", "app_key_configured": sec.AppAPIKey != "", "worker_token_configured": sec.WorkerToken != "", "metrics_token_configured": sec.MetricsToken != "", "shard_tokens": len(sec.ShardAPIToken), "cluster_token_configured": sec.ClusterToken != ""})
|
||||
s.json(w, 200, map[string]any{"openai_configured": sec.OpenAIAPIKey != "", "app_key_configured": sec.AppAPIKey != "", "integration_token_configured": sec.IntegrationToken != "", "control_read_token_configured": sec.ControlReadToken != "", "worker_token_configured": sec.WorkerToken != "", "metrics_token_configured": sec.MetricsToken != "", "shard_tokens": len(sec.ShardAPIToken), "cluster_token_configured": sec.ClusterToken != ""})
|
||||
}
|
||||
func maskedSecret(v string) string {
|
||||
if v == "" {
|
||||
@@ -586,35 +618,61 @@ func (s *Server) adminGetSecrets(w http.ResponseWriter, r *http.Request) {
|
||||
sec := s.store.Secrets()
|
||||
reveal := r.URL.Query().Get("reveal") == "1" && s.store.Config().Security.AllowSecretReveal
|
||||
if reveal {
|
||||
s.json(w, 200, map[string]any{"revealed": true, "app_api_key": sec.AppAPIKey, "worker_token": sec.WorkerToken, "metrics_token": sec.MetricsToken, "shard_api_tokens": sec.ShardAPIToken, "cluster_token": sec.ClusterToken})
|
||||
s.json(w, 200, map[string]any{"revealed": true, "app_api_key": sec.AppAPIKey, "integration_token": sec.IntegrationToken, "control_read_token": sec.ControlReadToken, "worker_token": sec.WorkerToken, "metrics_token": sec.MetricsToken, "shard_api_tokens": sec.ShardAPIToken, "cluster_token": sec.ClusterToken})
|
||||
return
|
||||
}
|
||||
maskedShards := map[string]string{}
|
||||
for k, v := range sec.ShardAPIToken {
|
||||
maskedShards[k] = maskedSecret(v)
|
||||
}
|
||||
s.json(w, 200, map[string]any{"revealed": false, "reveal_allowed": s.store.Config().Security.AllowSecretReveal, "app_api_key": maskedSecret(sec.AppAPIKey), "worker_token": maskedSecret(sec.WorkerToken), "metrics_token": maskedSecret(sec.MetricsToken), "shard_api_tokens": maskedShards, "cluster_token": maskedSecret(sec.ClusterToken)})
|
||||
s.json(w, 200, map[string]any{"revealed": false, "reveal_allowed": s.store.Config().Security.AllowSecretReveal, "app_api_key": maskedSecret(sec.AppAPIKey), "integration_token": maskedSecret(sec.IntegrationToken), "control_read_token": maskedSecret(sec.ControlReadToken), "worker_token": maskedSecret(sec.WorkerToken), "metrics_token": maskedSecret(sec.MetricsToken), "shard_api_tokens": maskedShards, "cluster_token": maskedSecret(sec.ClusterToken)})
|
||||
}
|
||||
func (s *Server) adminPutSecrets(w http.ResponseWriter, r *http.Request) {
|
||||
var q struct {
|
||||
OpenAIAPIKey string `json:"openai_api_key,omitempty"`
|
||||
AppAPIKey string `json:"app_api_key,omitempty"`
|
||||
WorkerToken string `json:"worker_token,omitempty"`
|
||||
MetricsToken string `json:"metrics_token,omitempty"`
|
||||
ShardAPIToken map[string]string `json:"shard_api_tokens,omitempty"`
|
||||
ClusterToken string `json:"cluster_token,omitempty"`
|
||||
OpenAIAPIKey string `json:"openai_api_key,omitempty"`
|
||||
AppAPIKey string `json:"app_api_key,omitempty"`
|
||||
IntegrationToken string `json:"integration_token,omitempty"`
|
||||
ControlReadToken string `json:"control_read_token,omitempty"`
|
||||
WorkerToken string `json:"worker_token,omitempty"`
|
||||
MetricsToken string `json:"metrics_token,omitempty"`
|
||||
ShardAPIToken map[string]string `json:"shard_api_tokens,omitempty"`
|
||||
ClusterToken string `json:"cluster_token,omitempty"`
|
||||
}
|
||||
if err := decode(r, &q); err != nil {
|
||||
s.err(w, 400, err)
|
||||
return
|
||||
}
|
||||
sec := s.store.Secrets()
|
||||
envLocked := func(name string) bool {
|
||||
_, ok := os.LookupEnv(name)
|
||||
return ok && strings.TrimSpace(os.Getenv(name)) != ""
|
||||
}
|
||||
for name, value := range map[string]string{
|
||||
"OPENAI_API_KEY": q.OpenAIAPIKey,
|
||||
"NEUROFORGE_APP_API_KEY": q.AppAPIKey,
|
||||
"NEUROFORGE_INTEGRATION_TOKEN": q.IntegrationToken,
|
||||
"NEUROFORGE_CONTROL_READ_TOKEN": q.ControlReadToken,
|
||||
"NEUROFORGE_WORKER_TOKEN": q.WorkerToken,
|
||||
"NEUROFORGE_METRICS_TOKEN": q.MetricsToken,
|
||||
"NEUROFORGE_CLUSTER_TOKEN": q.ClusterToken,
|
||||
} {
|
||||
if value != "" && envLocked(name) {
|
||||
s.err(w, http.StatusConflict, fmt.Errorf("%s is environment-managed and cannot be changed through the admin API", name))
|
||||
return
|
||||
}
|
||||
}
|
||||
if q.OpenAIAPIKey != "" {
|
||||
sec.OpenAIAPIKey = q.OpenAIAPIKey
|
||||
}
|
||||
if q.AppAPIKey != "" {
|
||||
sec.AppAPIKey = q.AppAPIKey
|
||||
}
|
||||
if q.IntegrationToken != "" {
|
||||
sec.IntegrationToken = q.IntegrationToken
|
||||
}
|
||||
if q.ControlReadToken != "" {
|
||||
sec.ControlReadToken = q.ControlReadToken
|
||||
}
|
||||
if q.WorkerToken != "" {
|
||||
sec.WorkerToken = q.WorkerToken
|
||||
}
|
||||
@@ -737,6 +795,67 @@ func (s *Server) livez(w http.ResponseWriter, r *http.Request) {
|
||||
s.json(w, http.StatusOK, map[string]any{"ok": true, "status": "alive", "time": time.Now().UTC(), "version": "0.8.2"})
|
||||
}
|
||||
|
||||
func configuredModelAvailable(models map[string]bool, configured string) bool {
|
||||
configured = strings.TrimSpace(configured)
|
||||
if configured == "" {
|
||||
return false
|
||||
}
|
||||
if models[configured] {
|
||||
return true
|
||||
}
|
||||
if !strings.Contains(configured, ":") && models[configured+":latest"] {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func checkConfiguredOllamaModels(ctx context.Context, cfg core.Config) (bool, any) {
|
||||
type tagsResponse struct {
|
||||
Models []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"models"`
|
||||
}
|
||||
details := map[string]any{}
|
||||
anyEnabled := false
|
||||
for _, node := range cfg.Ollama {
|
||||
if !node.Enabled || strings.TrimSpace(node.BaseURL) == "" {
|
||||
continue
|
||||
}
|
||||
anyEnabled = true
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(node.BaseURL, "/")+"/api/tags", nil)
|
||||
if err != nil {
|
||||
details[node.ID] = err.Error()
|
||||
continue
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
details[node.ID] = err.Error()
|
||||
continue
|
||||
}
|
||||
var tags tagsResponse
|
||||
decodeErr := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&tags)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 || decodeErr != nil {
|
||||
details[node.ID] = fmt.Sprintf("HTTP %d / invalid tags response", resp.StatusCode)
|
||||
continue
|
||||
}
|
||||
models := map[string]bool{}
|
||||
for _, model := range tags.Models {
|
||||
models[strings.TrimSpace(model.Name)] = true
|
||||
}
|
||||
chatOK := configuredModelAvailable(models, node.ChatModel)
|
||||
embedOK := configuredModelAvailable(models, node.EmbeddingModel)
|
||||
details[node.ID] = map[string]any{"reachable": true, "chat_model": node.ChatModel, "chat_present": chatOK, "embedding_model": node.EmbeddingModel, "embedding_present": embedOK}
|
||||
if chatOK && embedOK {
|
||||
return true, details
|
||||
}
|
||||
}
|
||||
if !anyEnabled {
|
||||
return false, map[string]any{"error": "no enabled Ollama node configured"}
|
||||
}
|
||||
return false, details
|
||||
}
|
||||
|
||||
func (s *Server) readyz(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := s.store.Config()
|
||||
sec := s.store.Secrets()
|
||||
@@ -764,7 +883,26 @@ func (s *Server) readyz(w http.ResponseWriter, r *http.Request) {
|
||||
components["embedding_route_configured"] = embedReady
|
||||
clusterReady := !cfg.Cluster.Enabled || obs.ClusterLeaderID != ""
|
||||
components["cluster"] = clusterReady
|
||||
ready := configOK && chatReady && embedReady && clusterReady && (!cfg.API.RequireKey || sec.AppAPIKey != "")
|
||||
ollamaLiveReady := true
|
||||
if s.readinessOllamaLive {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 4*time.Second)
|
||||
var detail any
|
||||
ollamaLiveReady, detail = checkConfiguredOllamaModels(ctx, cfg)
|
||||
cancel()
|
||||
components["ollama_live_models"] = detail
|
||||
}
|
||||
stagingReady := true
|
||||
if s.brain != nil {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 4*time.Second)
|
||||
if err := s.brain.CheckStagingPublisher(ctx); err != nil {
|
||||
stagingReady = false
|
||||
components["kb_staging"] = err.Error()
|
||||
} else {
|
||||
components["kb_staging"] = true
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
ready := configOK && chatReady && embedReady && clusterReady && ollamaLiveReady && stagingReady && (!cfg.API.RequireKey || sec.AppAPIKey != "")
|
||||
status := http.StatusOK
|
||||
if !ready {
|
||||
status = http.StatusServiceUnavailable
|
||||
|
||||
@@ -24,7 +24,7 @@ func integrationRequest(t *testing.T, s *Server, method, path, token, body strin
|
||||
|
||||
func TestIntegrationKnowledgeLifecycleAndNamespaceIsolation(t *testing.T) {
|
||||
s, _ := newMetricsTestServer(t)
|
||||
key := s.store.Secrets().AppAPIKey
|
||||
key := s.store.Secrets().IntegrationToken
|
||||
|
||||
unauth := integrationRequest(t, s, http.MethodPost, "/api/v1/integrations/knowledge/upsert", "", `{"namespace":"agent","document_id":"KB-1","chunks":[{"index":0,"text":"vpn","vector":[1,0],"content_hash":"a"}]}`)
|
||||
if unauth.Code != http.StatusUnauthorized {
|
||||
@@ -107,3 +107,27 @@ func TestIntegrationKnowledgeLifecycleAndNamespaceIsolation(t *testing.T) {
|
||||
t.Fatalf("deleted document still searchable: %s", search.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedTokensCannotCrossTrustBoundaries(t *testing.T) {
|
||||
s, _ := newMetricsTestServer(t)
|
||||
sec := s.store.Secrets()
|
||||
|
||||
// The general App key is deliberately insufficient for integration writes.
|
||||
if rr := integrationRequest(t, s, http.MethodPost, "/api/v1/integrations/events", sec.AppAPIKey, `{"type":"test","source":"agent","message":"x"}`); rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("app key wrote integration event: status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
// Control is read-only and cannot write integration events or generic learn.
|
||||
if rr := integrationRequest(t, s, http.MethodPost, "/api/v1/integrations/events", sec.ControlReadToken, `{"type":"test","source":"control","message":"x"}`); rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("control token wrote integration event: status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if rr := integrationRequest(t, s, http.MethodPost, "/api/v1/learn", sec.ControlReadToken, `{"text":"must not learn"}`); rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("control token reached /learn: status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
// Integration credentials are not generic app credentials either.
|
||||
if rr := integrationRequest(t, s, http.MethodPost, "/api/v1/learn", sec.IntegrationToken, `{"text":"must not learn"}`); rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("integration token reached /learn: status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if rr := integrationRequest(t, s, http.MethodGet, "/api/v1/stats", sec.ControlReadToken, ""); rr.Code != http.StatusOK {
|
||||
t.Fatalf("control token cannot read stats: status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,16 +11,16 @@ import (
|
||||
"neuroforge/internal/core"
|
||||
)
|
||||
|
||||
func appGraphRequest(t *testing.T, s *Server, path string) *httptest.ResponseRecorder {
|
||||
func controlGraphRequest(t *testing.T, s *Server, path string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+s.store.Secrets().AppAPIKey)
|
||||
req.Header.Set("Authorization", "Bearer "+s.store.Secrets().ControlReadToken)
|
||||
rr := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
func TestIntegrationGraphEndpointsRequireAppKey(t *testing.T) {
|
||||
func TestIntegrationGraphEndpointsRequireControlReadAuth(t *testing.T) {
|
||||
s, _ := newMetricsTestServer(t)
|
||||
for _, path := range []string{"/api/v1/integrations/graph/brain", "/api/v1/integrations/graph/research"} {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
@@ -38,7 +38,7 @@ func TestIntegrationBrainGraphIsBoundedAndRedacted(t *testing.T) {
|
||||
if err := s.store.AddMemory(m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rr := appGraphRequest(t, s, "/api/v1/integrations/graph/brain?max_nodes=50")
|
||||
rr := controlGraphRequest(t, s, "/api/v1/integrations/graph/brain?max_nodes=50")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
@@ -72,7 +72,7 @@ func TestIntegrationResearchGraphShowsProvenanceChain(t *testing.T) {
|
||||
if _, err := s.store.FinishResearchRun(run.ID, "completed", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rr := appGraphRequest(t, s, "/api/v1/integrations/graph/research?runs=2&max_events=50")
|
||||
rr := controlGraphRequest(t, s, "/api/v1/integrations/graph/research?runs=2&max_events=50")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
@@ -68,3 +68,60 @@ func TestReadinessEndpoint(t *testing.T) {
|
||||
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadinessLiveOllamaRequiresChatAndEmbeddingModels(t *testing.T) {
|
||||
models := []string{"gemma3:latest"}
|
||||
ollama := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/tags" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
items := make([]map[string]string, 0, len(models))
|
||||
for _, name := range models {
|
||||
items = append(items, map[string]string{"name": name})
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"models": items})
|
||||
}))
|
||||
defer ollama.Close()
|
||||
|
||||
s, _ := newMetricsTestServer(t)
|
||||
cfg := s.store.Config()
|
||||
cfg.Ollama[0].BaseURL = ollama.URL
|
||||
cfg.Ollama[0].ChatModel = "gemma3"
|
||||
cfg.Ollama[0].EmbeddingModel = "embeddinggemma"
|
||||
if err := s.store.UpdateConfig(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.SetReadinessOllamaLive(true)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("missing embedding model: status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), `"embedding_present":false`) {
|
||||
t.Fatalf("readiness does not expose missing embedding model: %s", rr.Body.String())
|
||||
}
|
||||
|
||||
models = append(models, "embeddinggemma:latest")
|
||||
rr = httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("both models present: status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvironmentManagedSecretCannotBeRotatedThroughAdminAPI(t *testing.T) {
|
||||
s, _ := newMetricsTestServer(t)
|
||||
t.Setenv("NEUROFORGE_APP_API_KEY", "environment-owned-app-key-1234567890")
|
||||
admin := s.store.Secrets().AdminToken
|
||||
req := httptest.NewRequest(http.MethodPut, "/admin/api/secrets", strings.NewReader(`{"app_api_key":"different-runtime-value-1234567890"}`))
|
||||
req.Header.Set("X-Admin-Token", admin)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rr := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusConflict {
|
||||
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestValidatedOutcomeLearnsTrustedProvenance(t *testing.T) {
|
||||
|
||||
body := `{"outcome_id":"out-1","run_id":"run-1","ticket_id":42,"decision":"accepted","ticket_input":"VPN verbindet nicht","proposed_reply":"VPN Client neu starten","confirmed_reply":"VPN Client neu starten","category_id":5,"category_name":"VPN","knowledge_id":"kb-vpn","actor":"tech-a"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/integrations/outcomes", strings.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+sec.AppAPIKey)
|
||||
req.Header.Set("Authorization", "Bearer "+sec.IntegrationToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rr := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rr, req)
|
||||
@@ -62,7 +62,7 @@ func TestValidatedOutcomeRejectsUnconfirmedDecision(t *testing.T) {
|
||||
s, _ := newMetricsTestServer(t)
|
||||
sec := s.store.Secrets()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/integrations/outcomes", strings.NewReader(`{"outcome_id":"o","run_id":"r","ticket_id":1,"decision":"rejected","ticket_input":"x","confirmed_reply":"y","actor":"tech"}`))
|
||||
req.Header.Set("Authorization", "Bearer "+sec.AppAPIKey)
|
||||
req.Header.Set("Authorization", "Bearer "+sec.IntegrationToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rr := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rr, req)
|
||||
@@ -101,7 +101,7 @@ func TestValidatedOutcomeCorrectionSupersedesPriorMemoryAndSearchesOnlyActiveRev
|
||||
sec := s.store.Secrets()
|
||||
post := func(body string) map[string]any {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/integrations/outcomes", strings.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+sec.AppAPIKey)
|
||||
req.Header.Set("Authorization", "Bearer "+sec.IntegrationToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rr := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rr, req)
|
||||
@@ -139,7 +139,7 @@ func TestValidatedOutcomeCorrectionSupersedesPriorMemoryAndSearchesOnlyActiveRev
|
||||
}
|
||||
|
||||
search := httptest.NewRequest(http.MethodPost, "/api/v1/integrations/outcomes/search", strings.NewReader(`{"text":"Drucker korrigierte Loesung","k":10,"min_similarity":0}`))
|
||||
search.Header.Set("Authorization", "Bearer "+sec.AppAPIKey)
|
||||
search.Header.Set("Authorization", "Bearer "+sec.IntegrationToken)
|
||||
search.Header.Set("Content-Type", "application/json")
|
||||
rr := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rr, search)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"neuroforge/internal/brain"
|
||||
"neuroforge/internal/core"
|
||||
)
|
||||
|
||||
@@ -91,7 +92,11 @@ func (s *Server) goalResume(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) goalCycle(w http.ResponseWriter, r *http.Request) {
|
||||
cycle, err := s.brain.RunGoalCycle(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
s.err(w, 400, err)
|
||||
if errors.Is(err, brain.ErrGoalCycleInProgress) {
|
||||
s.err(w, http.StatusConflict, err)
|
||||
return
|
||||
}
|
||||
s.err(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
s.json(w, 200, cycle)
|
||||
|
||||
@@ -177,6 +177,12 @@ func New(dir string) (*Store, error) {
|
||||
if s.secrets.AppAPIKey == "" {
|
||||
s.secrets.AppAPIKey = randomID(24)
|
||||
}
|
||||
if s.secrets.IntegrationToken == "" {
|
||||
s.secrets.IntegrationToken = randomID(24)
|
||||
}
|
||||
if s.secrets.ControlReadToken == "" {
|
||||
s.secrets.ControlReadToken = randomID(24)
|
||||
}
|
||||
if s.secrets.WorkerToken == "" {
|
||||
s.secrets.WorkerToken = randomID(24)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user