Changeset 0.25.0 (#160)

This commit is contained in:
2026-03-08 16:54:17 +00:00
parent 937be26578
commit 2b01d540d6
63 changed files with 6942 additions and 2773 deletions

View File

@@ -287,12 +287,21 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
var personaID string // tracks active persona for KB scoping
var personaThinkingBudget *int // persona-level thinking budget for hook injection (v0.22.1)
// v0.25.0: Query channel metadata once — used by group leader check, tool context,
// and execution context. Avoids redundant SELECTs on channels.
var channelType string
var channelTeamID *string
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
SELECT COALESCE(type, 'direct'), team_id FROM channels WHERE id = $1
`), channelID).Scan(&channelType, &channelTeamID)
teamID := ""
if channelTeamID != nil {
teamID = *channelTeamID
}
// v0.23.2: Group leader default — when type=group and no @mention, use the leader persona
if req.PersonaID == "" && extractFirstMention(req.Content) == "" {
var channelType string
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
SELECT COALESCE(type, 'direct') FROM channels WHERE id = $1
`), channelID).Scan(&channelType)
if channelType == "group" {
// Find the leader persona via channel_participants → persona_group_members
var leaderPersonaID string
@@ -493,6 +502,16 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
// Resolve effective workspace: channel.workspace_id > project.workspace_id
workspaceID, _ := h.stores.Channels.ResolveWorkspaceID(c.Request.Context(), channelID)
// ── Tool context (v0.25.0) ─────────────────
// Uses channel metadata queried above (channelType, teamID).
tctx := tools.ToolContext{
ChannelType: channelType,
WorkspaceID: workspaceID,
TeamID: teamID,
IsVisitor: isSessionAuth(c),
// PersonaID set below after persona resolution
}
// ── @mention routing (v0.23.0 / v0.23.1) ─────────────────────────────────
// Resolve @persona-handle, @model-id, or @username.
// - User mention → skip completion, notify recipient (v0.23.1)
@@ -637,9 +656,10 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
}
// Attach tool definitions if model supports tool calling and tools are available
tctx.PersonaID = personaID // finalize after all persona resolution (@mention, group leader, etc.)
hasBrowserTools := h.hub != nil && h.hub.IsConnected(userID)
if caps.ToolCalling && (tools.HasTools() || hasBrowserTools) {
provReq.Tools = h.buildToolDefs(c.Request.Context(), userID, hasBrowserTools, req.DisabledTools, workspaceID)
provReq.Tools = h.buildToolDefs(c.Request.Context(), userID, hasBrowserTools, req.DisabledTools, tctx, personaID)
}
// ── Permission pre-flight ─────────────────────────────────────────────
@@ -787,7 +807,12 @@ func (h *CompletionHandler) multiModelStream(
}
hasBrowserTools := h.hub != nil && h.hub.IsConnected(userID)
if caps.ToolCalling && (tools.HasTools() || hasBrowserTools) {
provReq.Tools = h.buildToolDefs(c.Request.Context(), userID, hasBrowserTools, req.DisabledTools, workspaceID)
tctx := tools.ToolContext{
WorkspaceID: workspaceID,
PersonaID: personaID,
IsVisitor: isSessionAuth(c),
}
provReq.Tools = h.buildToolDefs(c.Request.Context(), userID, hasBrowserTools, req.DisabledTools, tctx, personaID)
}
// Apply provider-specific request hooks (v0.22.1)
@@ -823,25 +848,21 @@ func (h *CompletionHandler) multiModelStream(
// buildToolDefs converts registered tools to provider-format tool definitions.
// If includeBrowser is true, also fetches tool schemas from enabled browser extensions.
// Tools whose names appear in disabledTools are excluded.
// Workspace tools are excluded when workspaceID is empty (no workspace bound).
func (h *CompletionHandler) buildToolDefs(ctx context.Context, userID string, includeBrowser bool, disabledTools []string, workspaceID string) []providers.ToolDef {
// v0.25.0: Tools self-declare availability via predicates on ToolContext.
// v0.25.0: Persona tool grants apply as a second-pass allowlist when personaID
// has explicit grants. Empty grants = inherit all (backward compat).
func (h *CompletionHandler) buildToolDefs(ctx context.Context, userID string, includeBrowser bool, disabledTools []string, tctx tools.ToolContext, personaID string) []providers.ToolDef {
// Build disabled set for O(1) lookup
disabled := make(map[string]bool, len(disabledTools))
for _, name := range disabledTools {
disabled[name] = true
}
// If no workspace is bound, disable workspace + git tools
if workspaceID == "" {
for _, name := range tools.WorkspaceToolNames() {
disabled[name] = true
}
for _, name := range tools.GitToolNames() {
disabled[name] = true
}
}
allTools := tools.AllDefinitionsFiltered(disabled)
// v0.25.0: Tools self-declare availability via predicates.
// AvailableFor evaluates each tool's Availability() against tctx,
// then applies the disabled set. Replaces manual WorkspaceToolNames()
// and GitToolNames() suppression.
allTools := tools.AvailableFor(tctx, disabled)
defs := make([]providers.ToolDef, 0, len(allTools))
for _, t := range allTools {
defs = append(defs, providers.ToolDef{
@@ -892,6 +913,28 @@ func (h *CompletionHandler) buildToolDefs(ctx context.Context, userID string, in
}
}
// v0.25.0: Persona tool grants — second-pass allowlist.
// If the active persona has explicit tool grants, restrict to only those tools.
// Empty grants = persona inherits all context-available tools (backward compat).
if personaID != "" && h.stores.Personas != nil {
grants, err := h.stores.Personas.GetToolGrants(ctx, personaID)
if err != nil {
log.Printf("⚠️ Failed to load tool grants for persona %s: %v", personaID, err)
} else if len(grants) > 0 {
allowed := make(map[string]bool, len(grants))
for _, g := range grants {
allowed[g] = true
}
filtered := defs[:0]
for _, d := range defs {
if allowed[d.Function.Name] {
filtered = append(filtered, d)
}
}
defs = filtered
}
}
return defs
}

View File

@@ -547,7 +547,24 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
workspaceID, _ := h.stores.Channels.ResolveWorkspaceID(c.Request.Context(), channelID)
hasBrowserTools := h.hub != nil && h.hub.IsConnected(userID)
if caps.ToolCalling && (tools.HasTools() || hasBrowserTools) {
provReq.Tools = comp.buildToolDefs(c.Request.Context(), userID, hasBrowserTools, req.DisabledTools, workspaceID)
// v0.25.0: Build ToolContext with channel metadata
var chType string
var chTeamID *string
_ = database.DB.QueryRowContext(c.Request.Context(), database.Q(`
SELECT COALESCE(type, 'direct'), team_id FROM channels WHERE id = $1
`), channelID).Scan(&chType, &chTeamID)
msgTeamID := ""
if chTeamID != nil {
msgTeamID = *chTeamID
}
tctx := tools.ToolContext{
ChannelType: chType,
WorkspaceID: workspaceID,
TeamID: msgTeamID,
PersonaID: personaID,
IsVisitor: isSessionAuth(c),
}
provReq.Tools = comp.buildToolDefs(c.Request.Context(), userID, hasBrowserTools, req.DisabledTools, tctx, personaID)
}
// ── Stream the response (shared loop handles tools, reasoning, SSE) ──

View File

@@ -306,3 +306,48 @@ func (h *PersonaHandler) SetPersonaKBs(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
// ── Persona Tool Grants (v0.25.0) ───────────
// GetPersonaToolGrants returns the tool names granted to a persona.
// Empty list = persona inherits all context-available tools.
func (h *PersonaHandler) GetPersonaToolGrants(c *gin.Context) {
personaID := c.Param("id")
grants, err := h.stores.Personas.GetToolGrants(c.Request.Context(), personaID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load tool grants"})
return
}
c.JSON(http.StatusOK, gin.H{"data": grants})
}
// SetPersonaToolGrants replaces the tool grants for a persona.
// Sending an empty tool_names array clears all grants (persona inherits all tools).
func (h *PersonaHandler) SetPersonaToolGrants(c *gin.Context) {
personaID := c.Param("id")
var req struct {
ToolNames []string `json:"tool_names"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Convert tool names to Grant objects
grants := make([]models.Grant, 0, len(req.ToolNames))
for _, name := range req.ToolNames {
grants = append(grants, models.Grant{
PersonaID: personaID,
GrantType: models.GrantTypeTool,
GrantRef: name,
})
}
if err := h.stores.Personas.SetGrants(c.Request.Context(), personaID, grants); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update tool grants"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}

297
server/handlers/surfaces.go Normal file
View File

@@ -0,0 +1,297 @@
package handlers
import (
"archive/zip"
"encoding/json"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// SurfaceHandler manages surface lifecycle (admin-only).
type SurfaceHandler struct {
stores store.Stores
surfacesDir string // e.g. /data/surfaces — where archives are extracted
}
func NewSurfaceHandler(s store.Stores, surfacesDir ...string) *SurfaceHandler {
dir := ""
if len(surfacesDir) > 0 {
dir = surfacesDir[0]
}
return &SurfaceHandler{stores: s, surfacesDir: dir}
}
// ListSurfaces returns all registered surfaces with their enabled state.
// GET /api/v1/admin/surfaces
func (h *SurfaceHandler) ListSurfaces(c *gin.Context) {
surfaces, err := h.stores.Surfaces.List(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list surfaces"})
return
}
if surfaces == nil {
surfaces = []store.SurfaceRegistration{}
}
c.JSON(http.StatusOK, gin.H{"surfaces": surfaces})
}
// GetSurface returns a single surface by ID.
// GET /api/v1/admin/surfaces/:id
func (h *SurfaceHandler) GetSurface(c *gin.Context) {
id := c.Param("id")
surface, err := h.stores.Surfaces.Get(c.Request.Context(), id)
if err != nil || surface == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "surface not found"})
return
}
c.JSON(http.StatusOK, surface)
}
// EnableSurface enables a surface.
// PUT /api/v1/admin/surfaces/:id/enable
func (h *SurfaceHandler) EnableSurface(c *gin.Context) {
id := c.Param("id")
if err := h.stores.Surfaces.SetEnabled(c.Request.Context(), id, true); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "surface not found"})
return
}
c.JSON(http.StatusOK, gin.H{"id": id, "enabled": true})
}
// DisableSurface disables a surface. Chat cannot be disabled.
// PUT /api/v1/admin/surfaces/:id/disable
func (h *SurfaceHandler) DisableSurface(c *gin.Context) {
id := c.Param("id")
// Chat and Admin are required — cannot be disabled
if id == "chat" || id == "admin" {
c.JSON(http.StatusBadRequest, gin.H{"error": id + " surface cannot be disabled"})
return
}
if err := h.stores.Surfaces.SetEnabled(c.Request.Context(), id, false); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "surface not found"})
return
}
c.JSON(http.StatusOK, gin.H{"id": id, "enabled": false})
}
// DeleteSurface uninstalls an extension surface. Core surfaces cannot be deleted.
// DELETE /api/v1/admin/surfaces/:id
func (h *SurfaceHandler) DeleteSurface(c *gin.Context) {
id := c.Param("id")
// Check it's not a core surface
surface, err := h.stores.Surfaces.Get(c.Request.Context(), id)
if err != nil || surface == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "surface not found"})
return
}
if surface.Source == "core" {
c.JSON(http.StatusBadRequest, gin.H{"error": "core surfaces cannot be uninstalled"})
return
}
if err := h.stores.Surfaces.Delete(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete surface"})
return
}
// TODO: Delete static assets from SURFACE_ASSET_DIR/{id}/
// TODO: Delete templates from SURFACE_TEMPLATE_DIR/{id}/
c.JSON(http.StatusOK, gin.H{"id": id, "deleted": true})
}
// InstallSurface uploads and installs a .surface archive.
// POST /api/v1/admin/surfaces/install (multipart: file)
func (h *SurfaceHandler) InstallSurface(c *gin.Context) {
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "no file uploaded"})
return
}
defer file.Close()
// Validate extension
if !strings.HasSuffix(header.Filename, ".surface") && !strings.HasSuffix(header.Filename, ".zip") {
c.JSON(http.StatusBadRequest, gin.H{"error": "file must be a .surface or .zip archive"})
return
}
// Size limit: 50MB
if header.Size > 50*1024*1024 {
c.JSON(http.StatusBadRequest, gin.H{"error": "archive too large (max 50MB)"})
return
}
// Read into temp file for zip processing
tmpFile, err := os.CreateTemp("", "surface-*.zip")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"})
return
}
tmpPath := tmpFile.Name()
defer os.Remove(tmpPath)
if _, err := io.Copy(tmpFile, file); err != nil {
tmpFile.Close()
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read upload"})
return
}
tmpFile.Close()
// Open as zip
zr, err := zip.OpenReader(tmpPath)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid zip archive"})
return
}
defer zr.Close()
// Find and parse manifest.json
var manifest map[string]any
for _, f := range zr.File {
name := filepath.Base(f.Name)
if name == "manifest.json" && !f.FileInfo().IsDir() {
rc, err := f.Open()
if err != nil {
continue
}
data, err := io.ReadAll(rc)
rc.Close()
if err != nil {
continue
}
if err := json.Unmarshal(data, &manifest); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid manifest.json: " + err.Error()})
return
}
break
}
}
if manifest == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "archive missing manifest.json"})
return
}
// Validate required fields
surfaceID, _ := manifest["id"].(string)
title, _ := manifest["title"].(string)
if surfaceID == "" || title == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "manifest must have 'id' and 'title'"})
return
}
// Check for conflicts with core surfaces
existing, _ := h.stores.Surfaces.Get(c.Request.Context(), surfaceID)
if existing != nil && existing.Source == "core" {
c.JSON(http.StatusConflict, gin.H{"error": "cannot overwrite core surface: " + surfaceID})
return
}
// Extract static assets (js/, css/, assets/) to surfacesDir/{id}/
if h.surfacesDir != "" {
destDir := filepath.Join(h.surfacesDir, surfaceID)
os.MkdirAll(destDir, 0755)
for _, f := range zr.File {
if f.FileInfo().IsDir() {
continue
}
// Only extract js/, css/, assets/ directories
name := f.Name
// Strip leading directory if archive has one (e.g., "my-surface/js/foo.js" → "js/foo.js")
if idx := strings.Index(name, "/"); idx > 0 {
rest := name[idx+1:]
if strings.HasPrefix(rest, "js/") || strings.HasPrefix(rest, "css/") || strings.HasPrefix(rest, "assets/") {
name = rest
} else if strings.HasPrefix(name, "js/") || strings.HasPrefix(name, "css/") || strings.HasPrefix(name, "assets/") {
// Already relative
} else {
continue // Skip non-static files (templates, manifest)
}
}
destPath := filepath.Join(destDir, name)
// Security: prevent path traversal
if !strings.HasPrefix(filepath.Clean(destPath), filepath.Clean(destDir)) {
continue
}
os.MkdirAll(filepath.Dir(destPath), 0755)
rc, err := f.Open()
if err != nil {
continue
}
out, err := os.Create(destPath)
if err != nil {
rc.Close()
continue
}
io.Copy(out, rc)
out.Close()
rc.Close()
}
log.Printf("[surfaces] Extracted %s to %s", surfaceID, destDir)
}
// Register in database
if err := h.stores.Surfaces.Seed(c.Request.Context(), surfaceID, title, "extension", manifest); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to register surface"})
return
}
c.JSON(http.StatusOK, gin.H{
"id": surfaceID,
"title": title,
"source": "extension",
"enabled": true,
})
}
// ListEnabledSurfaces returns enabled surface IDs (non-admin, for nav).
// GET /api/v1/surfaces
func (h *SurfaceHandler) ListEnabledSurfaces(c *gin.Context) {
surfaces, err := h.stores.Surfaces.List(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list surfaces"})
return
}
// Return only enabled surfaces with minimal info for nav
type navSurface struct {
ID string `json:"id"`
Title string `json:"title"`
Route string `json:"route"`
}
var enabled []navSurface
for _, s := range surfaces {
if !s.Enabled {
continue
}
route, _ := s.Manifest["route"].(string)
enabled = append(enabled, navSurface{
ID: s.ID,
Title: s.Title,
Route: route,
})
}
if enabled == nil {
enabled = []navSurface{}
}
c.JSON(http.StatusOK, gin.H{"surfaces": enabled})
}