From c9a30f3ef8dd39b23ca400976bcbfce16a8919bb Mon Sep 17 00:00:00 2001 From: arteeh Date: Mon, 5 Oct 2026 19:06:52 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20knowledge:=20add=20owner-only=20pub?= =?UTF-8?q?lic=20MCP=20sharing=20controls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Persist public sharing and tag scope, restore Kubernetes overlays, and expose shared controls in the Knowledge header and Settings. Preserve environment fallback and gate writes on verified owner access. Co-authored-by: clubanderson <407614+clubanderson@users.noreply.github.com> Hive-Run: hivecommons/hive#10676 Hive-Plan: owner-only-public-knowledge-sharing Hive-Spec: issue-10676#design Signed-off-by: arteeh --- .../added-10676-public-knowledge-controls.md | 1 + src/docs/env-vars.md | 4 +- src/docs/public-knowledge-mcp.md | 22 ++- src/pkg/config/config.go | 4 + src/pkg/config/knowledge_config.go | 25 ++-- .../config/public_knowledge_overlay_test.go | 42 ++++++ src/pkg/dashboard/api.go | 2 + src/pkg/dashboard/api_knowledge_public.go | 81 ++++++++++ .../dashboard/api_knowledge_public_test.go | 139 ++++++++++++++++++ src/pkg/dashboard/knowledge_public_ui_test.go | 61 ++++++++ src/pkg/dashboard/mcp_knowledge.go | 8 +- src/pkg/dashboard/server.go | 1 + src/pkg/dashboard/static/index.html | 76 +++++++++- 13 files changed, 449 insertions(+), 17 deletions(-) create mode 100644 changelog.d/added-10676-public-knowledge-controls.md create mode 100644 src/pkg/config/public_knowledge_overlay_test.go create mode 100644 src/pkg/dashboard/api_knowledge_public.go create mode 100644 src/pkg/dashboard/api_knowledge_public_test.go create mode 100644 src/pkg/dashboard/knowledge_public_ui_test.go diff --git a/changelog.d/added-10676-public-knowledge-controls.md b/changelog.d/added-10676-public-knowledge-controls.md new file mode 100644 index 0000000000..6d925d4673 --- /dev/null +++ b/changelog.d/added-10676-public-knowledge-controls.md @@ -0,0 +1 @@ +- Add owner-only public Knowledge MCP sharing controls to the Knowledge header and Settings, with persisted tag scope, enable confirmation, read-only viewer status, and audit logging (#10676). diff --git a/src/docs/env-vars.md b/src/docs/env-vars.md index 640068edfb..3bc31a191b 100644 --- a/src/docs/env-vars.md +++ b/src/docs/env-vars.md @@ -48,8 +48,8 @@ This reference is compiled by hand from the Go source under `src/`, the deployme | `HIVE_METRICS_ENABLED` | No | disabled | Registers Prometheus `/metrics` when set to `1`, `true`, `yes`, or `on`. Requires `HIVE_METRICS_TOKEN` — enabled-but-tokenless returns 403 ([#3804](https://github.com/hivecommons/hive/pull/3804)). | | `HIVE_METRICS_TOKEN` | Yes when metrics enabled | none | Bearer token read by `pkg/dashboard/metrics_prometheus.go` for `/metrics` (`Authorization: Bearer ...`; Prometheus `bearer_token`). `/metrics` bypasses dashboard session auth, so this token is its only guard; enabled-but-tokenless fails closed and the cost/agent series are never served without it. | | `HIVE_METRICS_FILE` | No | `/var/run/hive-metrics/contribute.json` | Contributor metrics JSON file override. | -| `HIVE_PUBLIC_KNOWLEDGE` | No | disabled | Owner switch for the anonymous, **read-only** MCP knowledge endpoint `POST /mcp/knowledge` (`1`, `true`, `yes`, or `on` — same spelling as `HIVE_METRICS_ENABLED`). Off → 404 even for authenticated callers. Read on each request so it can be closed without a pod roll. Only operational fact types (pattern, gotcha, regression, test_scaffold, integration, coverage_rule, general) are served; ideation/governance types, sources, and usage data never leave the hive. See [public-knowledge-mcp.md](public-knowledge-mcp.md) ([#10615](https://github.com/hivecommons/hive/issues/10615)). | -| `HIVE_PUBLIC_KNOWLEDGE_TAGS` | No | none (all public-type facts) | Comma-separated tag allow-list that further narrows what `/mcp/knowledge` serves to facts carrying at least one listed tag (case-insensitive). | +| `HIVE_PUBLIC_KNOWLEDGE` | No | disabled | Fallback owner switch (overridden by explicit `knowledge.public` settings in the dashboard) for the anonymous, **read-only** MCP knowledge endpoint `POST /mcp/knowledge` (`1`, `true`, `yes`, or `on` — same spelling as `HIVE_METRICS_ENABLED`). Off → 404 even for authenticated callers. Read on each request so it can be closed without a pod roll. Only operational fact types (pattern, gotcha, regression, test_scaffold, integration, coverage_rule, general) are served; ideation/governance types, sources, and usage data never leave the hive. See [public-knowledge-mcp.md](public-knowledge-mcp.md) ([#10615](https://github.com/hivecommons/hive/issues/10615)). | +| `HIVE_PUBLIC_KNOWLEDGE_TAGS` | No | none (all public-type facts) | Fallback comma-separated tag allow-list (overridden by `knowledge.public.tags` when the config block is present) that further narrows what `/mcp/knowledge` serves to facts carrying at least one listed tag (case-insensitive). | | `HIVE_COPILOT_INTEGRATION_ID` | No | compiled Copilot integration id | Overrides the integration id used by Copilot model discovery. | | `HIVE_CONTRIBUTORS_DIR` | No | hub default | Contributor registry directory override. | | `HIVE_CONTRIBUTE_SKIP_LABELS` | No | `blocked,tracking,epic,discussion,question,needs-decision,needs-triage` | Comma-separated, case-insensitive label patterns for issues that are not contributor work and must never be offered by the relay. Patterns use `path.Match`-style `*` globs; `blocked` is always unioned into the effective set even if omitted. Same setting as `hub.contribute_skip_labels`. | diff --git a/src/docs/public-knowledge-mcp.md b/src/docs/public-knowledge-mcp.md index 3e1ecf89d9..8ad3c9418f 100644 --- a/src/docs/public-knowledge-mcp.md +++ b/src/docs/public-knowledge-mcp.md @@ -12,6 +12,26 @@ motivating case is a distro's troubleshooting assistant reasoning from the project's own accumulated fixes instead of only what its model was trained on ([#10615](https://github.com/hivecommons/hive/issues/10615)). +## Dashboard control + +The Knowledge section header and **Settings → Knowledge sharing** show whether +public MCP access is enabled, its tag scope, and the endpoint URL. Owners can +change the toggle and comma-separated tags, then apply the settings. Enabling +requires an in-app confirmation. Other authenticated viewers see read-only +status and an owner-only explanation. The URL can be copied from either control. + +`GET /api/knowledge/public` returns `{enabled, tags, source, endpoint, url}` to +authenticated dashboard viewers. Owner-only `PUT /api/knowledge/public` accepts +`{"enabled": false, "tags": []}` and persists `knowledge.public` through runtime +config persistence, including the Kubernetes dashboard overlay. Changes are +audited. A failed save returns an error and restores the previous runtime value. + +An explicit `knowledge.public` config block overrides both environment variables, +including when `enabled` is false or `tags` is empty. Without that block, the +environment controls below remain backward compatible. Settings are checked for +every request, so disabling closes subsequent requests immediately. Requests +already in progress may finish. + ## Owner switch | Variable | Effect | @@ -103,7 +123,7 @@ curl -s https://hive.example.org/mcp/knowledge \ On hosted hives the hub's nginx `auth_request` gate waves `/mcp/knowledge` through (same mechanism as `/api/knowledge/export`, #8294) so an agent with no browser session is not bounced to the login page. This opens nothing by -itself: the spoke re-checks `HIVE_PUBLIC_KNOWLEDGE` and 404s when it is off. +itself: the spoke re-checks the effective public-sharing setting and 404s when it is off. ## Related diff --git a/src/pkg/config/config.go b/src/pkg/config/config.go index beaced6f90..04f3936362 100644 --- a/src/pkg/config/config.go +++ b/src/pkg/config/config.go @@ -5441,6 +5441,10 @@ func loadWithDashboardOverlay(path string, validateOpts ValidateOptions) (*Confi if err := yaml.Unmarshal([]byte(expandEnvVars(string(data))), &overlay); err != nil { return cfg, nil // malformed overlay: fall back to seed, don't fail the reload } + // An explicit public-sharing setting (including false) survives pod restarts. + if overlay.Knowledge.Public != nil { + cfg.Knowledge.Public = overlay.Knowledge.Public + } // Tombstones live in the dashboard overlay because that is the only agent // source the dashboard can write. Adopt them BEFORE the fullness guard below // so a short/empty overlay (one that has no agents yet, or only carries the diff --git a/src/pkg/config/knowledge_config.go b/src/pkg/config/knowledge_config.go index f186725e1c..bde732ab02 100644 --- a/src/pkg/config/knowledge_config.go +++ b/src/pkg/config/knowledge_config.go @@ -8,16 +8,23 @@ type DocSourceConfigYAML struct { Layer string `yaml:"layer"` } +// PublicKnowledgeConfig overrides the environment when explicitly configured. +type PublicKnowledgeConfig struct { + Enabled bool `yaml:"enabled" json:"enabled"` + Tags []string `yaml:"tags" json:"tags"` +} + type KnowledgeConfig struct { - Enabled bool `yaml:"enabled"` - Engine string `yaml:"engine"` - Layers []KnowledgeLayer `yaml:"layers"` - Vaults []VaultConfig `yaml:"vaults"` - GitSources []GitSourceConfigYAML `yaml:"git_sources"` - Documents []DocSourceConfigYAML `yaml:"documents"` - Curator KnowledgeCurator `yaml:"curator"` - Primer KnowledgePrimer `yaml:"primer"` - BeadSynthesizer BeadSynthesizerConfig `yaml:"bead_synthesizer"` + Public *PublicKnowledgeConfig `yaml:"public,omitempty"` + Enabled bool `yaml:"enabled"` + Engine string `yaml:"engine"` + Layers []KnowledgeLayer `yaml:"layers"` + Vaults []VaultConfig `yaml:"vaults"` + GitSources []GitSourceConfigYAML `yaml:"git_sources"` + Documents []DocSourceConfigYAML `yaml:"documents"` + Curator KnowledgeCurator `yaml:"curator"` + Primer KnowledgePrimer `yaml:"primer"` + BeadSynthesizer BeadSynthesizerConfig `yaml:"bead_synthesizer"` } // BeadSynthesizerConfig controls automatic synthesis of completed beads into wiki facts. diff --git a/src/pkg/config/public_knowledge_overlay_test.go b/src/pkg/config/public_knowledge_overlay_test.go new file mode 100644 index 0000000000..439b94274f --- /dev/null +++ b/src/pkg/config/public_knowledge_overlay_test.go @@ -0,0 +1,42 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestPublicKnowledgeDashboardOverlay(t *testing.T) { + dir := t.TempDir() + seedPath := filepath.Join(dir, "hive.yaml") + seed := "project:\n org: testorg\n repos: [repo1]\ngithub:\n token: test\nagents:\n scanner:\n backend: copilot\n model: claude-sonnet-4-6\nknowledge:\n public:\n enabled: true\n tags: [seed]\n" + if err := os.WriteFile(seedPath, []byte(seed), 0600); err != nil { + t.Fatal(err) + } + original := DashboardOverlayFile + DashboardOverlayFile = filepath.Join(dir, "overlay.yaml") + t.Cleanup(func() { DashboardOverlayFile = original }) + t.Setenv("KUBERNETES_SERVICE_HOST", "10.0.0.1") + for _, tc := range []struct { + name, overlay string + enabled bool + tag string + }{ + {"explicit disable", "knowledge:\n public:\n enabled: false\n tags: [public]\n", false, "public"}, + {"absent preserves seed", "knowledge:\n enabled: false\n", true, "seed"}, + } { + t.Run(tc.name, func(t *testing.T) { + if err := os.WriteFile(DashboardOverlayFile, []byte(tc.overlay), 0600); err != nil { + t.Fatal(err) + } + cfg, err := LoadWithDashboardOverlay(seedPath) + if err != nil { + t.Fatal(err) + } + p := cfg.Knowledge.Public + if p == nil || p.Enabled != tc.enabled || len(p.Tags) != 1 || p.Tags[0] != tc.tag { + t.Fatalf("restored public settings: %+v", p) + } + }) + } +} diff --git a/src/pkg/dashboard/api.go b/src/pkg/dashboard/api.go index cf2a621fc5..1dd5952652 100644 --- a/src/pkg/dashboard/api.go +++ b/src/pkg/dashboard/api.go @@ -317,6 +317,8 @@ func (s *Server) RegisterAPI(deps *Dependencies) { s.mux.HandleFunc("GET /api/config/backends", s.handleBackends) s.mux.HandleFunc("GET /api/inference/models/{backend}", s.handleInferenceModels) + s.mux.HandleFunc("GET /api/knowledge/public", s.handlePublicKnowledgeSettings) + s.mux.HandleFunc("PUT /api/knowledge/public", s.handlePublicKnowledgeSettingsUpdate) s.mux.HandleFunc("GET /api/knowledge", s.handleKnowledgeList) s.mux.HandleFunc("GET /api/knowledge/export", s.handleKnowledgeExport) s.mux.HandleFunc("GET /api/knowledge/search", s.handleKnowledgeSearch) diff --git a/src/pkg/dashboard/api_knowledge_public.go b/src/pkg/dashboard/api_knowledge_public.go new file mode 100644 index 0000000000..1765a84de9 --- /dev/null +++ b/src/pkg/dashboard/api_knowledge_public.go @@ -0,0 +1,81 @@ +package dashboard + +import ( + "fmt" + "net/http" + "strings" + + "github.com/hivecommons/hive/pkg/config" +) + +type publicKnowledgeSettings struct { + Enabled bool `json:"enabled"` + Tags []string `json:"tags"` + Source string `json:"source"` + Endpoint string `json:"endpoint"` + URL string `json:"url"` +} + +func normalizePublicKnowledgeTags(tags []string) []string { + result := make([]string, 0, len(tags)) + seen := make(map[string]bool) + for _, tag := range tags { + tag = strings.ToLower(strings.TrimSpace(tag)) + if tag != "" && !seen[tag] { + result = append(result, tag) + seen[tag] = true + } + } + return result +} + +func (s *Server) publicKnowledgeSettings() publicKnowledgeSettings { + s.publicKnowledgeMu.RLock() + defer s.publicKnowledgeMu.RUnlock() + result := publicKnowledgeSettings{Enabled: publicKnowledgeEnabled(), Tags: normalizePublicKnowledgeTags(publicKnowledgeTags()), Source: "env", Endpoint: publicKnowledgeMCPPath} + if s.deps != nil && s.deps.Config != nil && s.deps.Config.Knowledge.Public != nil { + cfg := s.deps.Config.Knowledge.Public + result.Enabled = cfg.Enabled + result.Tags = normalizePublicKnowledgeTags(cfg.Tags) + result.Source = "config" + } + return result +} + +func (s *Server) handlePublicKnowledgeSettings(w http.ResponseWriter, r *http.Request) { + settings := s.publicKnowledgeSettings() + settings.URL = s.oauthPublicOrigin(r) + publicKnowledgeMCPPath + jsonResponse(w, settings) +} + +func (s *Server) handlePublicKnowledgeSettingsUpdate(w http.ResponseWriter, r *http.Request) { + if !requireOwnerRole(w, r) { + return + } + var body struct { + Enabled *bool `json:"enabled"` + Tags []string `json:"tags"` + } + if err := decodeBody(r, &body); err != nil || body.Enabled == nil { + jsonError(w, "enabled must be a boolean and tags must be an array of strings", http.StatusBadRequest) + return + } + if s.deps == nil || s.deps.Config == nil || s.deps.Config.SourcePath == "" { + jsonError(w, "runtime config persistence is unavailable", http.StatusServiceUnavailable) + return + } + tags := normalizePublicKnowledgeTags(body.Tags) + s.publicKnowledgeMu.Lock() + previous := s.deps.Config.Knowledge.Public + s.deps.Config.Knowledge.Public = &config.PublicKnowledgeConfig{Enabled: *body.Enabled, Tags: tags} + if err := s.saveConfig(); err != nil { + s.deps.Config.Knowledge.Public = previous + s.publicKnowledgeMu.Unlock() + s.logger.Error("failed to persist public knowledge settings", "error", err) + jsonError(w, "could not persist public knowledge settings", http.StatusInternalServerError) + return + } + s.auditFromRequest(r, "knowledge_public_update", auditDetail("enabled", fmt.Sprint(*body.Enabled), "tags", strings.Join(tags, ",")), "") + s.publicKnowledgeMu.Unlock() + s.handlePublicKnowledgeSettings(w, r) +} diff --git a/src/pkg/dashboard/api_knowledge_public_test.go b/src/pkg/dashboard/api_knowledge_public_test.go new file mode 100644 index 0000000000..290ab6a031 --- /dev/null +++ b/src/pkg/dashboard/api_knowledge_public_test.go @@ -0,0 +1,139 @@ +package dashboard + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/hivecommons/hive/pkg/config" + "gopkg.in/yaml.v3" +) + +func publicSettingsRequest(s *Server, method, body string, owner bool) *httptest.ResponseRecorder { + r := httptest.NewRequest(method, "/api/knowledge/public", strings.NewReader(body)) + if owner { + r.Header.Set("Authorization", "Bearer dashboard-secret") + } + w := httptest.NewRecorder() + s.Handler().ServeHTTP(w, r) + return w +} + +func TestPublicKnowledgeSettingsPersistenceAndImmediateDisable(t *testing.T) { + t.Setenv(publicKnowledgeEnabledEnv, "1") + t.Setenv(publicKnowledgeTagsEnv, " env-tag ") + s := publicKnowledgeServer(t) + s.deps.Config.SourcePath = filepath.Join(t.TempDir(), "hive.yaml") + s.deps.Config.Dashboard.PublicURL = "https://knowledge.example" + w := publicSettingsRequest(s, "GET", "", true) + var initial publicKnowledgeSettings + if err := json.Unmarshal(w.Body.Bytes(), &initial); err != nil { + t.Fatal(err) + } + if !initial.Enabled || initial.Source != "env" || initial.Tags[0] != "env-tag" || initial.URL != "https://knowledge.example/mcp/knowledge" { + t.Fatalf("unexpected initial settings: %+v", initial) + } + + w = publicSettingsRequest(s, "PUT", `{"enabled":false,"tags":[" Linux ","linux",""]}`, true) + if w.Code != http.StatusOK { + t.Fatalf("PUT: %d %s", w.Code, w.Body.String()) + } + if got := mcpCall(t, s, `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`); got.Code != http.StatusNotFound { + t.Fatalf("disabled endpoint: %d", got.Code) + } + data, err := os.ReadFile(s.deps.Config.SourcePath) + if err != nil { + t.Fatal(err) + } + var restored config.Config + if err := yaml.Unmarshal(data, &restored); err != nil { + t.Fatal(err) + } + p := restored.Knowledge.Public + if p == nil || p.Enabled || len(p.Tags) != 1 || p.Tags[0] != "linux" { + t.Fatalf("persisted settings: %+v", p) + } + s.deps.Config.Knowledge.Public = p + if got := s.publicKnowledgeSettings(); got.Enabled || got.Source != "config" { + t.Fatalf("restored settings: %+v", got) + } + entries := s.audit.Recent(1) + if len(entries) != 1 || entries[0].Action != "knowledge_public_update" { + t.Fatalf("missing audit: %+v", entries) + } + + t.Setenv(publicKnowledgeEnabledEnv, "0") + w = publicSettingsRequest(s, "PUT", `{"enabled":true,"tags":["linux"]}`, true) + if w.Code != http.StatusOK { + t.Fatalf("enable: %d %s", w.Code, w.Body.String()) + } + result := mcpCall(t, s, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"knowledge_export","arguments":{}}}`) + if result.Code != http.StatusOK || !strings.Contains(result.Body.String(), "Caching Pattern") || strings.Contains(result.Body.String(), "Nil Map Gotcha") || strings.Contains(result.Body.String(), "Secret Roadmap") { + t.Fatalf("public projection: %d %s", result.Code, result.Body.String()) + } +} + +func TestPublicKnowledgeSettingsEmptyTagsOverrideEnvironment(t *testing.T) { + t.Setenv(publicKnowledgeEnabledEnv, "0") + t.Setenv(publicKnowledgeTagsEnv, "private") + s := publicKnowledgeServer(t) + s.deps.Config.Knowledge.Public = &config.PublicKnowledgeConfig{Enabled: true} + w := publicSettingsRequest(s, "GET", "", true) + if !strings.Contains(w.Body.String(), `"tags":[]`) { + t.Fatalf("tags should be an empty array: %s", w.Body.String()) + } + result := mcpCall(t, s, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"knowledge_export"}}`) + if !strings.Contains(result.Body.String(), "Nil Map Gotcha") { + t.Fatalf("empty tags must override env scope: %s", result.Body.String()) + } +} + +func TestPublicKnowledgeSettingsOwnerGate(t *testing.T) { + s := publicKnowledgeServer(t) + for _, method := range []string{"GET", "PUT"} { + if w := publicSettingsRequest(s, method, `{"enabled":true,"tags":[]}`, false); w.Code != http.StatusUnauthorized { + t.Errorf("anonymous %s: %d", method, w.Code) + } + } + for _, role := range []string{"read", "read-write", "merger", "owner"} { + r := httptest.NewRequest("PUT", "/api/knowledge/public", strings.NewReader(`{"enabled":true,"tags":[]}`)) + r.Header.Set("X-Hive-Role", role) + // Even an owner label without the auth middleware's proof must fail. + w := httptest.NewRecorder() + s.handlePublicKnowledgeSettingsUpdate(w, r) + if w.Code != http.StatusForbidden { + t.Errorf("%s: %d", role, w.Code) + } + } + if s.deps.Config.Knowledge.Public != nil { + t.Fatal("unauthorized mutation") + } +} + +func TestPublicKnowledgeSettingsInvalidAndFailedSave(t *testing.T) { + t.Setenv(publicKnowledgeEnabledEnv, "0") + s := publicKnowledgeServer(t) + s.deps.Config.SourcePath = filepath.Join(t.TempDir(), "hive.yaml") + for _, body := range []string{`{}`, `{"enabled":null}`, `{"enabled":"yes"}`, `{"enabled":true,"tags":[1]}`} { + if w := publicSettingsRequest(s, "PUT", body, true); w.Code != http.StatusBadRequest { + t.Errorf("%s: %d", body, w.Code) + } + } + // Exercise the real persistence guard without writing any config file. + s.deps.Config.Project.Org = "" + w := publicSettingsRequest(s, "PUT", `{"enabled":true,"tags":[]}`, true) + if w.Code != http.StatusInternalServerError { + t.Fatalf("save failure: %d", w.Code) + } + if s.publicKnowledgeSettings().Enabled || s.deps.Config.Knowledge.Public != nil { + t.Fatal("failed save exposed knowledge") + } + s.deps.Config.SourcePath = "" + if w := publicSettingsRequest(s, "PUT", `{"enabled":true,"tags":[]}`, true); w.Code != http.StatusServiceUnavailable { + t.Fatalf("no persistence: %d", w.Code) + } +} diff --git a/src/pkg/dashboard/knowledge_public_ui_test.go b/src/pkg/dashboard/knowledge_public_ui_test.go new file mode 100644 index 0000000000..fe6b0ebf02 --- /dev/null +++ b/src/pkg/dashboard/knowledge_public_ui_test.go @@ -0,0 +1,61 @@ +package dashboard + +import ( + "os/exec" + "strings" + "testing" +) + +func TestPublicKnowledgeUIConfirmationAndRoles(t *testing.T) { + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node unavailable") + } + html := indexHTML(t) + start := strings.Index(html, " // Public sharing is independent") + end := strings.Index(html, " // ── Knowledge Base ──") + if start < 0 || end < start { + t.Fatal("public sharing UI missing") + } + script := ` +const assert = require('node:assert/strict'); +const window = {_hiveRole: 'read'}; +const dashboardRoleAtLeast = role => role === 'owner'; +const escapeHtml = s => String(s).replaceAll('"', '"').replaceAll('<', '<'); +let enabled = true, allow = false, calls = 0, confirmations = 0; +const button = {disabled: false}; +const container = {querySelector: selector => selector.includes('enabled') ? {checked: enabled} : selector.includes('tags') ? {value: 'linux, public'} : button}; +const document = {getElementById: id => id === 'controls' ? container : null, querySelector: () => null, activeElement: null}; +const hiveConfirm = async () => {confirmations++; return allow;}; +const showToast = () => {}; +const fetch = async (url, opts) => { calls++; assert.equal(url, '/api/knowledge/public'); assert.equal(opts.method, 'PUT'); return {ok: true, json: async () => ({enabled, tags: [], url: 'https://example/mcp/knowledge', source: 'config'})}; }; +` + html[start:end] + ` +(async () => { + _publicKnowledge = {enabled: false, tags: [''], url: 'https://example/mcp/knowledge', source: 'env'}; + let markup = publicKnowledgeControls('controls'); + assert.ok(markup.includes(' disabled')); + assert.ok(markup.includes('Only the owner')); + assert.ok(!markup.includes('data-action="savePublicKnowledge"')); + assert.ok(!markup.includes('value=""')); + await savePublicKnowledge('controls'); + assert.equal(calls, 0); + window._hiveRole = 'owner'; + markup = publicKnowledgeControls('controls'); + assert.ok(markup.includes('data-action="savePublicKnowledge"')); + await savePublicKnowledge('controls'); + assert.equal(confirmations, 1); + assert.equal(calls, 0, 'cancel must not publish'); + allow = true; + await savePublicKnowledge('controls'); + assert.equal(calls, 1); + assert.equal(button.disabled, false); + enabled = false; allow = false; + await savePublicKnowledge('controls'); + assert.equal(calls, 2, 'disable must not require enabling confirmation'); + assert.equal(confirmations, 2); +})().catch(err => { console.error(err); process.exitCode = 1; }); +` + if out, err := exec.Command(node, "-e", script).CombinedOutput(); err != nil { + t.Fatalf("public sharing UI: %v\n%s", err, out) + } +} diff --git a/src/pkg/dashboard/mcp_knowledge.go b/src/pkg/dashboard/mcp_knowledge.go index e64801e98f..85db9f186e 100644 --- a/src/pkg/dashboard/mcp_knowledge.go +++ b/src/pkg/dashboard/mcp_knowledge.go @@ -354,7 +354,8 @@ func publicKnowledgeToolDefs() []mcpToolDef { // anonymous entry point into knowledge and fails closed when the owner switch // is off, regardless of what the auth middleware admitted. func (s *Server) handlePublicKnowledgeMCP(w http.ResponseWriter, r *http.Request) { - if !publicKnowledgeEnabled() { + settings := s.publicKnowledgeSettings() + if !settings.Enabled { http.NotFound(w, r) return } @@ -399,7 +400,7 @@ func (s *Server) handlePublicKnowledgeMCP(w http.ResponseWriter, r *http.Request case "tools/list": resp.Result = map[string]interface{}{"tools": publicKnowledgeToolDefs()} case "tools/call": - result, rpcErr := s.callPublicKnowledgeTool(r, req.Params) + result, rpcErr := s.callPublicKnowledgeTool(r, req.Params, settings.Tags) if rpcErr != nil { resp.Error = rpcErr } else { @@ -435,7 +436,7 @@ type mcpToolCallParams struct { // callPublicKnowledgeTool dispatches tools/call. Unknown tool names are a // JSON-RPC invalid-params error; tool-level problems (fact not found) come // back as isError results per the MCP spec so the model can recover. -func (s *Server) callPublicKnowledgeTool(r *http.Request, raw json.RawMessage) (interface{}, *jsonRPCError) { +func (s *Server) callPublicKnowledgeTool(r *http.Request, raw json.RawMessage, tags []string) (interface{}, *jsonRPCError) { var params mcpToolCallParams if len(raw) > 0 { if err := json.Unmarshal(raw, ¶ms); err != nil { @@ -445,7 +446,6 @@ func (s *Server) callPublicKnowledgeTool(r *http.Request, raw json.RawMessage) ( if !s.ensureKnowledge() { return toolText("Knowledge base not available."), nil } - tags := publicKnowledgeTags() switch params.Name { case "knowledge_search": diff --git a/src/pkg/dashboard/server.go b/src/pkg/dashboard/server.go index 67a3a27258..191c3db828 100644 --- a/src/pkg/dashboard/server.go +++ b/src/pkg/dashboard/server.go @@ -345,6 +345,7 @@ type Server struct { readyAt time.Time githubAppMu sync.RWMutex + publicKnowledgeMu sync.RWMutex githubConfigMu sync.Mutex githubAppRequired bool githubAppInstallURL string diff --git a/src/pkg/dashboard/static/index.html b/src/pkg/dashboard/static/index.html index 878b089275..180a5e2569 100644 --- a/src/pkg/dashboard/static/index.html +++ b/src/pkg/dashboard/static/index.html @@ -1772,6 +1772,10 @@ .repo-pr-pill .pill-role { font-size:var(--fs-2xs); font-weight:700; letter-spacing:0.02em; text-transform:uppercase; margin-left:3px; opacity:0.85; } .overview-card { position: relative; margin: var(--sp-0) var(--sp-0) var(--sp-6); padding: var(--sp-5); border: 1px solid var(--border); border-radius: var(--r-lg); background: var(--surface-2); } .overview-header { display: inline-flex; align-items: center; gap: var(--sp-2); } + #kb-public-sharing { position: relative; font-size: 0.8rem; } + #kb-public-sharing summary { cursor: pointer; } + #kb-public-controls { position: absolute; right: 0; top: 100%; z-index: 30; width: min(26rem, 80vw); padding: 1rem; background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 8px 24px #0004; font-weight: normal; } + #kb-public-controls input:not([type="checkbox"]) { width: 100%; box-sizing: border-box; } .overview-settings-modal { width: min(44rem, calc(100vw - var(--modal-gap) - var(--modal-gap))); } .overview-settings-body { display: grid; gap: var(--sp-4); } .overview-settings-section { display: grid; gap: var(--sp-3); padding-top: var(--sp-3); margin-top: var(--sp-3); border-top: 1px solid var(--border); } @@ -18551,6 +18555,72 @@

What sh // fetchNousStatus() — deferred to _deferredInit + // Public sharing is independent of knowledge primer registration. + let _publicKnowledge = null; + function publicKnowledgeControls(id) { + const owner = dashboardRoleAtLeast(window._hiveRole || 'read', 'owner'); + const p = _publicKnowledge; + if (!p) return '

Public sharing status unavailable.

'; + const disabled = owner ? '' : ' disabled'; + return '
' + + '

Public read-only MCP: ' + (p.enabled ? 'Enabled' : 'Disabled') + '

' + + '

Anyone with this URL can read operational facts. Private ideation and governance facts remain excluded.

' + + '' + + '

' + + '

' + + '

Settings source: ' + escapeHtml(p.source) + '

' + + (owner ? '' : '

Only the owner can change public sharing.

') + '
'; + } + function renderPublicKnowledge(force) { + const header = document.querySelector('#knowledge-section .dash-card-actions'); + if (header) { + let sharing = document.getElementById('kb-public-sharing'); + if (!sharing) { + sharing = document.createElement('details'); + sharing.id = 'kb-public-sharing'; + sharing.setAttribute('data-stop', '1'); + header.appendChild(sharing); + } + if (!sharing.open || force || !document.getElementById('kb-public-controls')) sharing.innerHTML = 'Public MCP: ' + (_publicKnowledge ? (_publicKnowledge.enabled ? 'on' : 'off') : 'unknown') + '' + publicKnowledgeControls('kb-public-controls'); + } + const settings = document.getElementById('settings-public-sharing'); + if (settings && (force || !document.getElementById('settings-public-controls'))) settings.innerHTML = publicKnowledgeControls('settings-public-controls'); + } + async function loadPublicKnowledge(force) { + try { + const r = await fetch('/api/knowledge/public'); + if (!r.ok) throw new Error('Could not load public sharing status'); + _publicKnowledge = await r.json(); + } catch (e) { _publicKnowledge = null; } + renderPublicKnowledge(force); + } + async function savePublicKnowledge(id) { + if (!dashboardRoleAtLeast(window._hiveRole || 'read', 'owner')) return; + const container = document.getElementById(id); + if (!container || !_publicKnowledge) return; + const enabled = container.querySelector('.public-knowledge-enabled').checked; + const tags = container.querySelector('.public-knowledge-tags').value.split(',').map(t => t.trim()).filter(Boolean); + if (enabled && !await hiveConfirm({title: 'Enable public knowledge sharing?', message: 'Anyone with the endpoint URL will be able to read and export operational facts matching these tags. Empty tags share all operational facts. This endpoint is read-only.', confirmLabel: 'Enable public sharing'})) return; + const button = container.querySelector('[data-action="savePublicKnowledge"]'); + button.disabled = true; + try { + const r = await fetch('/api/knowledge/public', {method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({enabled, tags})}); + if (!r.ok) throw new Error(await r.text()); + _publicKnowledge = await r.json(); + const details = document.getElementById('kb-public-sharing'); + if (details) details.open = false; + if (document.activeElement) document.activeElement.blur(); + renderPublicKnowledge(true); + showToast('Public knowledge sharing updated', 'success'); + } catch (e) { showToast('Could not save sharing settings: ' + e.message, 'error'); } + finally { button.disabled = false; } + } + async function copyPublicKnowledgeURL() { + if (!_publicKnowledge) return; + try { await navigator.clipboard.writeText(_publicKnowledge.url); showToast('Endpoint URL copied', 'success'); } + catch (e) { showToast('Could not copy URL; select and copy the endpoint field.', 'error'); } + } + // ── Knowledge Base ── let _kbCache = null; let _kbSelectedLayer = 'all'; @@ -18624,6 +18694,7 @@

What sh } async function fetchKnowledgeStats() { + loadPublicKnowledge(); try { const [r, , , docsR] = await Promise.all([fetch('/api/knowledge/stats'), fetchBeadSynthStatus(), fetchFactHistory(), fetch('/api/knowledge/documents')]); if (!r.ok) return; @@ -19351,6 +19422,7 @@

What sh function renderKnowledge() { refreshSectionCardShell('knowledge-section'); + renderPublicKnowledge(); const panel = document.getElementById('knowledge-panel'); const badge = document.getElementById('kb-status-badge'); if (!panel) return; @@ -26726,7 +26798,7 @@

Configure LiteLLM

// which is read-only hub-synced user/role data. It sits next to Model // Gateways because both are backend configuration. const GOVERNOR_BOB_TAB = 'Bob'; - const GOVERNOR_CONFIG_TABS = ['General', 'Appearance', 'Agents', 'Thresholds', 'Labels', 'Repos', 'Budget', 'Notifications', 'Health', 'Sensing', 'Logging', 'Features', 'Project Observability', 'Advisory', 'Work Source', 'Hub', 'Forge App', 'Model Gateways', GOVERNOR_BOB_TAB, 'Variables', 'Security']; + const GOVERNOR_CONFIG_TABS = ['General', 'Appearance', 'Knowledge sharing', 'Agents', 'Thresholds', 'Labels', 'Repos', 'Budget', 'Notifications', 'Health', 'Sensing', 'Logging', 'Features', 'Project Observability', 'Advisory', 'Work Source', 'Hub', 'Forge App', 'Model Gateways', GOVERNOR_BOB_TAB, 'Variables', 'Security']; const PIPELINE_STAGES = ['resolve-beads', 'track-prs', 'stale-check', 'repo-scan', 'coverage-gate', 'prompt-compose', 'budget-check', 'api-collect', 'final-compose']; const PIPELINE_STAGE_TIPS = { 'resolve-beads': 'Fetch and inject knowledge beads (facts, patterns, gotchas) relevant to the agent\'s current task context.', @@ -27367,6 +27439,7 @@

Configure LiteLLM

// configured" when upstream_watch is off. if (tabId === 'Features') loadUpstreamWatch(); if (tabId === 'Appearance') loadThemeConfig(); + if (tabId === 'Knowledge sharing') loadPublicKnowledge(true); } // Keep the Save button's enabled state in lockstep with the repos/default // invariant on every render (requirement #4). Cheap and idempotent — a @@ -27549,6 +27622,7 @@

Configure LiteLLM

case 'Logging': return renderGovLogging(d.logging || {}); case 'Security': return renderGovSecurity(d.security || {}); case 'Access': return renderGovSecurity(d.security || {}); + case 'Knowledge sharing': return '

Knowledge sharing

Changes apply immediately using the button below.

' + publicKnowledgeControls('settings-public-controls') + '
'; case 'Features': return renderGovFeatures(d.features || {}, d.auto_merge || {}, d.review || {}); case 'Project Observability': return renderGovProjectObservability(d.project_observability || {}); case 'Advisory': return renderGovAdvisory(d.advisory || {});