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 || {});