V0.38.2 library packages (#235)
Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
31
server/database/migrations/023_ext_dependencies.sql
Normal file
31
server/database/migrations/023_ext_dependencies.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
-- ==========================================
|
||||
-- Chat Switchboard — 023 Extension Dependencies
|
||||
-- ==========================================
|
||||
-- v0.38.2: Library package dependency tracking.
|
||||
-- Consumers declare which libraries they depend on.
|
||||
-- Libraries with active consumers cannot be uninstalled.
|
||||
-- ==========================================
|
||||
|
||||
-- Add 'library' to the packages type constraint.
|
||||
ALTER TABLE packages DROP CONSTRAINT IF EXISTS packages_type_check;
|
||||
ALTER TABLE packages ADD CONSTRAINT packages_type_check
|
||||
CHECK (type IN ('surface', 'extension', 'full', 'workflow', 'library'));
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ext_dependencies (
|
||||
consumer_id TEXT NOT NULL,
|
||||
library_id TEXT NOT NULL,
|
||||
version_spec TEXT NOT NULL DEFAULT '>=0.0.0',
|
||||
resolved_ver TEXT NOT NULL DEFAULT '0.0.0',
|
||||
PRIMARY KEY (consumer_id, library_id),
|
||||
FOREIGN KEY (consumer_id) REFERENCES packages(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (library_id) REFERENCES packages(id) ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ext_deps_consumer ON ext_dependencies(consumer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ext_deps_library ON ext_dependencies(library_id);
|
||||
|
||||
COMMENT ON TABLE ext_dependencies IS 'v0.38.2: Package dependency graph — consumers → libraries';
|
||||
COMMENT ON COLUMN ext_dependencies.consumer_id IS 'Package that depends on the library';
|
||||
COMMENT ON COLUMN ext_dependencies.library_id IS 'Library package being depended upon';
|
||||
COMMENT ON COLUMN ext_dependencies.version_spec IS 'Semver version spec declared by consumer (e.g. ">=1.0.0")';
|
||||
COMMENT ON COLUMN ext_dependencies.resolved_ver IS 'Actual library version at dependency creation time';
|
||||
62
server/database/migrations/sqlite/023_ext_dependencies.sql
Normal file
62
server/database/migrations/sqlite/023_ext_dependencies.sql
Normal file
@@ -0,0 +1,62 @@
|
||||
-- ==========================================
|
||||
-- Chat Switchboard — 023 Extension Dependencies
|
||||
-- ==========================================
|
||||
-- v0.38.2: Library package dependency tracking.
|
||||
-- SQLite version.
|
||||
--
|
||||
-- Also adds 'library' to the packages type CHECK constraint.
|
||||
-- SQLite doesn't support ALTER CONSTRAINT, so we rebuild the table.
|
||||
-- ==========================================
|
||||
|
||||
-- Step 1: Rebuild packages table with updated CHECK constraint.
|
||||
CREATE TABLE IF NOT EXISTS packages_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT 'surface'
|
||||
CHECK (type IN ('surface', 'extension', 'full', 'workflow', 'library')),
|
||||
version TEXT NOT NULL DEFAULT '0.0.0',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
author TEXT NOT NULL DEFAULT '',
|
||||
tier TEXT NOT NULL DEFAULT 'browser'
|
||||
CHECK (tier IN ('browser', 'starlark', 'sidecar')),
|
||||
is_system INTEGER NOT NULL DEFAULT 0,
|
||||
scope TEXT NOT NULL DEFAULT 'global'
|
||||
CHECK (scope IN ('global', 'team', 'personal')),
|
||||
team_id TEXT REFERENCES teams(id) ON DELETE CASCADE,
|
||||
installed_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
manifest TEXT NOT NULL DEFAULT '{}',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('active', 'pending_review', 'suspended')),
|
||||
schema_version INTEGER NOT NULL DEFAULT 0,
|
||||
package_settings TEXT NOT NULL DEFAULT '{}',
|
||||
source TEXT NOT NULL DEFAULT 'core'
|
||||
CHECK (source IN ('core', 'builtin', 'extension', 'registry')),
|
||||
installed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
INSERT INTO packages_new SELECT * FROM packages;
|
||||
DROP TABLE packages;
|
||||
ALTER TABLE packages_new RENAME TO packages;
|
||||
|
||||
-- Recreate indexes (dropped with old table)
|
||||
CREATE INDEX IF NOT EXISTS idx_packages_type ON packages(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_packages_enabled ON packages(enabled);
|
||||
CREATE INDEX IF NOT EXISTS idx_packages_team ON packages(team_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_packages_source ON packages(source);
|
||||
CREATE INDEX IF NOT EXISTS idx_packages_status ON packages(status);
|
||||
|
||||
-- Step 2: Dependencies table.
|
||||
CREATE TABLE IF NOT EXISTS ext_dependencies (
|
||||
consumer_id TEXT NOT NULL,
|
||||
library_id TEXT NOT NULL,
|
||||
version_spec TEXT NOT NULL DEFAULT '>=0.0.0',
|
||||
resolved_ver TEXT NOT NULL DEFAULT '0.0.0',
|
||||
PRIMARY KEY (consumer_id, library_id),
|
||||
FOREIGN KEY (consumer_id) REFERENCES packages(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (library_id) REFERENCES packages(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ext_deps_consumer ON ext_dependencies(consumer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ext_deps_library ON ext_dependencies(library_id);
|
||||
69
server/handlers/dependencies.go
Normal file
69
server/handlers/dependencies.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"chat-switchboard/models"
|
||||
)
|
||||
|
||||
// ── Extension Dependencies (v0.38.2) ──────────────────
|
||||
// Admin-only read endpoints for dependency graph visibility.
|
||||
// Dependencies are created/deleted implicitly by InstallPackage/DeletePackage.
|
||||
|
||||
// ListDependencies returns all libraries that a package depends on.
|
||||
// GET /api/v1/admin/packages/:id/dependencies
|
||||
func (h *PackageHandler) ListDependencies(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if h.stores.Dependencies == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"data": []any{}})
|
||||
return
|
||||
}
|
||||
deps, err := h.stores.Dependencies.ListByConsumer(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list dependencies"})
|
||||
return
|
||||
}
|
||||
if deps == nil {
|
||||
deps = []models.ExtDependency{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": deps})
|
||||
}
|
||||
|
||||
// ListConsumers returns all packages that depend on a library.
|
||||
// GET /api/v1/admin/packages/:id/consumers
|
||||
func (h *PackageHandler) ListConsumers(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if h.stores.Dependencies == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"data": []any{}})
|
||||
return
|
||||
}
|
||||
deps, err := h.stores.Dependencies.ListByLibrary(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list consumers"})
|
||||
return
|
||||
}
|
||||
if deps == nil {
|
||||
deps = []models.ExtDependency{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": deps})
|
||||
}
|
||||
|
||||
// ListAllDependencies returns the full dependency graph.
|
||||
// GET /api/v1/admin/dependencies
|
||||
func (h *PackageHandler) ListAllDependencies(c *gin.Context) {
|
||||
if h.stores.Dependencies == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"data": []any{}})
|
||||
return
|
||||
}
|
||||
deps, err := h.stores.Dependencies.ListAll(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list dependencies"})
|
||||
return
|
||||
}
|
||||
if deps == nil {
|
||||
deps = []models.ExtDependency{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": deps})
|
||||
}
|
||||
@@ -13,8 +13,10 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.starlark.net/starlark"
|
||||
|
||||
"chat-switchboard/database"
|
||||
"chat-switchboard/models"
|
||||
"chat-switchboard/sandbox"
|
||||
"chat-switchboard/store"
|
||||
)
|
||||
@@ -29,6 +31,7 @@ type PackageHandler struct {
|
||||
stores store.Stores
|
||||
packagesDir string // e.g. /data/packages — where archives are extracted
|
||||
sandbox *sandbox.Sandbox // v0.30.0: for schema migration scripts
|
||||
runner *sandbox.Runner // v0.38.2: for test-tool execution
|
||||
}
|
||||
|
||||
func NewPackageHandler(s store.Stores, packagesDir ...string) *PackageHandler {
|
||||
@@ -44,6 +47,11 @@ func (h *PackageHandler) SetSandbox(sb *sandbox.Sandbox) {
|
||||
h.sandbox = sb
|
||||
}
|
||||
|
||||
// SetRunner attaches the Starlark runner for test-tool execution (v0.38.2).
|
||||
func (h *PackageHandler) SetRunner(r *sandbox.Runner) {
|
||||
h.runner = r
|
||||
}
|
||||
|
||||
// ListPackages returns all registered packages.
|
||||
// GET /api/v1/admin/packages
|
||||
// GET /api/v1/admin/surfaces (alias)
|
||||
@@ -126,6 +134,20 @@ func (h *PackageHandler) DeletePackage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// v0.38.2: Libraries with active consumers cannot be uninstalled.
|
||||
if pkg.Type == "library" && h.stores.Dependencies != nil {
|
||||
hasConsumers, _ := h.stores.Dependencies.HasConsumers(c.Request.Context(), id)
|
||||
if hasConsumers {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "cannot uninstall: library has active consumers"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// v0.38.2: Clean up dependency records when uninstalling a consumer.
|
||||
if h.stores.Dependencies != nil {
|
||||
h.stores.Dependencies.DeleteAllForConsumer(c.Request.Context(), id)
|
||||
}
|
||||
|
||||
// v0.29.2: Drop namespaced DB tables before removing the package record.
|
||||
if err := DropExtTables(c.Request.Context(), database.DB, h.stores, id); err != nil {
|
||||
log.Printf("[ext-db] schema drop failed for %s: %v", id, err)
|
||||
@@ -281,8 +303,8 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
||||
if pkgType == "" {
|
||||
pkgType = "surface"
|
||||
}
|
||||
if pkgType != "surface" && pkgType != "extension" && pkgType != "full" && pkgType != "workflow" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "manifest type must be 'surface', 'extension', 'full', or 'workflow'"})
|
||||
if pkgType != "surface" && pkgType != "extension" && pkgType != "full" && pkgType != "workflow" && pkgType != "library" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "manifest type must be 'surface', 'extension', 'full', 'workflow', or 'library'"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -323,6 +345,21 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "workflow packages require a 'workflow_definition' in the manifest"})
|
||||
return
|
||||
}
|
||||
case "library":
|
||||
// v0.38.2: Libraries must declare exports, cannot have tools/pipes/route.
|
||||
exports, _ := manifest["exports"].([]any)
|
||||
if len(exports) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "library packages require an 'exports' array"})
|
||||
return
|
||||
}
|
||||
if hasTools || hasPipes {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "library packages cannot have tools or pipes"})
|
||||
return
|
||||
}
|
||||
if hasRoute {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "library packages cannot have a route"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Check for conflicts with core packages
|
||||
@@ -470,6 +507,47 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// v0.38.2: Create dependency records from manifest "dependencies" map.
|
||||
// Libraries must be installed first; consumers declare them.
|
||||
if deps, ok := manifest["dependencies"].(map[string]any); ok && len(deps) > 0 {
|
||||
// Clear stale dependency records from a previous install of the same consumer.
|
||||
if h.stores.Dependencies != nil {
|
||||
h.stores.Dependencies.DeleteAllForConsumer(c.Request.Context(), pkgID)
|
||||
}
|
||||
for libID, vSpec := range deps {
|
||||
lib, err := h.stores.Packages.Get(c.Request.Context(), libID)
|
||||
if err != nil || lib == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "dependency not found: " + libID})
|
||||
return
|
||||
}
|
||||
if lib.Type != "library" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is not a library: " + libID})
|
||||
return
|
||||
}
|
||||
if lib.Status != "active" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "dependency is not active: " + libID})
|
||||
return
|
||||
}
|
||||
versionSpec, _ := vSpec.(string)
|
||||
if versionSpec == "" {
|
||||
versionSpec = ">=0.0.0"
|
||||
}
|
||||
if h.stores.Dependencies != nil {
|
||||
if err := h.stores.Dependencies.Create(c.Request.Context(), &models.ExtDependency{
|
||||
ConsumerID: pkgID,
|
||||
LibraryID: libID,
|
||||
VersionSpec: versionSpec,
|
||||
ResolvedVer: lib.Version,
|
||||
}); err != nil {
|
||||
log.Printf("[packages] dependency record failed for %s → %s: %v", pkgID, libID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create dependency record"})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
log.Printf("[packages] %s: %d dependencies recorded", pkgID, len(deps))
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": pkgID,
|
||||
"title": title,
|
||||
@@ -650,3 +728,78 @@ func (h *PackageHandler) ListEnabledSurfaces(c *gin.Context) {
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": enabled})
|
||||
}
|
||||
|
||||
// ── Test Tool (v0.38.2) ─────────────────────────
|
||||
// POST /admin/packages/:id/test-tool
|
||||
// Invokes a starlark extension's on_tool_call entry point directly,
|
||||
// without going through the AI chat loop. Admin-only test harness.
|
||||
|
||||
// TestToolRequest is the JSON body for test-tool invocation.
|
||||
type TestToolRequest struct {
|
||||
ToolName string `json:"tool_name" binding:"required"`
|
||||
Arguments map[string]any `json:"arguments"`
|
||||
}
|
||||
|
||||
// TestTool invokes a package's on_tool_call entry point for testing.
|
||||
func (h *PackageHandler) TestTool(c *gin.Context) {
|
||||
if h.runner == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "starlark runner not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
pkgID := c.Param("id")
|
||||
pkg, err := h.stores.Packages.Get(c.Request.Context(), pkgID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
|
||||
return
|
||||
}
|
||||
if pkg.Tier != "starlark" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "test-tool requires tier=starlark"})
|
||||
return
|
||||
}
|
||||
if !pkg.Enabled {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "package is disabled"})
|
||||
return
|
||||
}
|
||||
|
||||
var req TestToolRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Build a call dict identical to what executeExtensionTool sends.
|
||||
// Build the arguments as an interface{} map for jsonToStarlark.
|
||||
var args interface{}
|
||||
if req.Arguments != nil {
|
||||
argsJSON, _ := json.Marshal(req.Arguments)
|
||||
_ = json.Unmarshal(argsJSON, &args)
|
||||
}
|
||||
|
||||
callDict := starlark.NewDict(3)
|
||||
_ = callDict.SetKey(starlark.String("tool_name"), starlark.String(req.ToolName))
|
||||
_ = callDict.SetKey(starlark.String("tool_call_id"), starlark.String("test-"+pkgID))
|
||||
_ = callDict.SetKey(starlark.String("arguments"), jsonToStarlark(args))
|
||||
|
||||
userID, _ := c.Get("user_id")
|
||||
rc := &sandbox.RunContext{UserID: fmt.Sprintf("%v", userID)}
|
||||
|
||||
val, output, err := h.runner.CallEntryPoint(
|
||||
c.Request.Context(), pkg, "on_tool_call",
|
||||
starlark.Tuple{callDict}, nil, rc,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": err.Error(),
|
||||
"output": output,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Serialize the Starlark return value to Go types.
|
||||
result := starlarkValueToGo(val)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"result": result,
|
||||
"output": output,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1357,6 +1357,7 @@ func main() {
|
||||
}
|
||||
pkgAdm := handlers.NewPackageHandler(stores, packagesDir)
|
||||
pkgAdm.SetSandbox(sandbox.New(sandbox.DefaultConfig())) // v0.30.0: schema migrations
|
||||
pkgAdm.SetRunner(starlarkRunner) // v0.38.2: test-tool
|
||||
|
||||
// Package registry — must be registered before /packages/:id (v0.30.0)
|
||||
registryH := handlers.NewRegistryHandler(stores, packagesDir, pkgAdm)
|
||||
@@ -1371,6 +1372,10 @@ func main() {
|
||||
admin.DELETE("/packages/:id", pkgAdm.DeletePackage)
|
||||
admin.GET("/packages/:id/settings", pkgAdm.GetPackageSettings) // v0.30.0
|
||||
admin.PUT("/packages/:id/settings", pkgAdm.UpdatePackageSettings) // v0.30.0
|
||||
admin.GET("/packages/:id/dependencies", pkgAdm.ListDependencies) // v0.38.2
|
||||
admin.GET("/packages/:id/consumers", pkgAdm.ListConsumers) // v0.38.2
|
||||
admin.POST("/packages/:id/test-tool", pkgAdm.TestTool) // v0.38.2
|
||||
admin.GET("/dependencies", pkgAdm.ListAllDependencies) // v0.38.2
|
||||
|
||||
// Package export (v0.30.0)
|
||||
pkgExport := handlers.NewPackageExportHandler(stores, packagesDir)
|
||||
|
||||
13
server/models/ext_dependency.go
Normal file
13
server/models/ext_dependency.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package models
|
||||
|
||||
// ── Extension Dependencies (v0.38.2) ──────────────────
|
||||
|
||||
// ExtDependency records that a consumer package depends on a library package.
|
||||
// Created at consumer install time from the manifest's "dependencies" map.
|
||||
// Immutable — update by uninstalling/reinstalling the consumer.
|
||||
type ExtDependency struct {
|
||||
ConsumerID string `json:"consumer_id" db:"consumer_id"`
|
||||
LibraryID string `json:"library_id" db:"library_id"`
|
||||
VersionSpec string `json:"version_spec" db:"version_spec"`
|
||||
ResolvedVer string `json:"resolved_ver" db:"resolved_ver"`
|
||||
}
|
||||
155
server/sandbox/lib_module.go
Normal file
155
server/sandbox/lib_module.go
Normal file
@@ -0,0 +1,155 @@
|
||||
// Package sandbox — lib_module.go
|
||||
//
|
||||
// v0.38.2: Library loading module for Starlark extensions.
|
||||
// No permission required — any starlark package can use lib.require().
|
||||
//
|
||||
// Starlark API:
|
||||
// gitea = lib.require("gitea-client") → struct with library's exports
|
||||
//
|
||||
// The loaded library runs with its own permission context, not the
|
||||
// consumer's. Results are cached per ExecPackage invocation.
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"go.starlark.net/starlark"
|
||||
"go.starlark.net/starlarkstruct"
|
||||
|
||||
"chat-switchboard/models"
|
||||
)
|
||||
|
||||
// libContext carries per-invocation state for lib.require() calls.
|
||||
// Created once per ExecPackage and threaded through buildModules.
|
||||
type libContext struct {
|
||||
cache map[string]starlark.Value // library ID → frozen exports struct
|
||||
loading map[string]bool // cycle detection
|
||||
}
|
||||
|
||||
func newLibContext() *libContext {
|
||||
return &libContext{
|
||||
cache: make(map[string]starlark.Value),
|
||||
loading: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// BuildLibModule creates the "lib" module.
|
||||
// consumerPkgID is the package calling lib.require() — used to validate
|
||||
// the dependency record exists.
|
||||
func BuildLibModule(ctx context.Context, r *Runner, consumerPkgID string, rc *RunContext, lc *libContext) *starlarkstruct.Module {
|
||||
return MakeModule("lib", starlark.StringDict{
|
||||
"require": starlark.NewBuiltin("lib.require", func(
|
||||
thread *starlark.Thread, b *starlark.Builtin,
|
||||
args starlark.Tuple, kwargs []starlark.Tuple,
|
||||
) (starlark.Value, error) {
|
||||
var libraryID string
|
||||
if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 1, &libraryID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 1. Check cache
|
||||
if cached, ok := lc.cache[libraryID]; ok {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
// 2. Cycle detection
|
||||
if lc.loading[libraryID] {
|
||||
return nil, fmt.Errorf("lib.require: circular dependency: %q", libraryID)
|
||||
}
|
||||
lc.loading[libraryID] = true
|
||||
defer func() { delete(lc.loading, libraryID) }()
|
||||
|
||||
// 3. Verify dependency is declared
|
||||
if r.stores.Dependencies == nil {
|
||||
return nil, fmt.Errorf("lib.require: dependency store not available")
|
||||
}
|
||||
deps, err := r.stores.Dependencies.ListByConsumer(ctx, consumerPkgID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lib.require: failed to check dependencies: %w", err)
|
||||
}
|
||||
declared := false
|
||||
for _, dep := range deps {
|
||||
if dep.LibraryID == libraryID {
|
||||
declared = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !declared {
|
||||
return nil, fmt.Errorf("lib.require: package %q does not declare dependency on %q", consumerPkgID, libraryID)
|
||||
}
|
||||
|
||||
// 4. Load library's PackageRegistration
|
||||
libPkg, err := r.stores.Packages.Get(ctx, libraryID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lib.require: library %q not found: %w", libraryID, err)
|
||||
}
|
||||
if libPkg.Type != "library" {
|
||||
return nil, fmt.Errorf("lib.require: package %q is type %q, not library", libraryID, libPkg.Type)
|
||||
}
|
||||
if libPkg.Status != models.PackageStatusActive {
|
||||
return nil, fmt.Errorf("lib.require: library %q is %s, not active", libraryID, libPkg.Status)
|
||||
}
|
||||
if libPkg.Tier != models.ExtTierStarlark {
|
||||
return nil, fmt.Errorf("lib.require: library %q is tier %s, not starlark", libraryID, libPkg.Tier)
|
||||
}
|
||||
|
||||
// 5. Build library's module set (library's own permissions)
|
||||
libModules, err := r.buildModulesWithLibCtx(ctx, libraryID, libPkg.Manifest, rc, lc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lib.require: failed to build modules for library %q: %w", libraryID, err)
|
||||
}
|
||||
|
||||
// 6. Load library script from disk
|
||||
libScript, err := r.loadScript(libPkg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lib.require: %w", err)
|
||||
}
|
||||
|
||||
// 7. Execute with library-scoped loader
|
||||
libLoader := r.packageLoader(libraryID, libModules)
|
||||
result, err := r.sandbox.ExecWithLoader(ctx, libraryID+"/script.star", libScript, libModules, libLoader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lib.require: error executing library %q: %w", libraryID, err)
|
||||
}
|
||||
|
||||
// 8. Extract exports from manifest
|
||||
exports := extractExports(libPkg.Manifest)
|
||||
if len(exports) == 0 {
|
||||
return nil, fmt.Errorf("lib.require: library %q declares no exports", libraryID)
|
||||
}
|
||||
|
||||
members := make(starlark.StringDict, len(exports))
|
||||
for _, name := range exports {
|
||||
val, ok := result.Globals[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("lib.require: library %q does not export %q", libraryID, name)
|
||||
}
|
||||
members[name] = val
|
||||
}
|
||||
|
||||
// 9. Freeze into struct, cache, return
|
||||
frozen := starlarkstruct.FromStringDict(starlark.String(libraryID), members)
|
||||
lc.cache[libraryID] = frozen
|
||||
|
||||
log.Printf(" 📦 lib.require: loaded %s (%d exports) for %s", libraryID, len(exports), consumerPkgID)
|
||||
return frozen, nil
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
// extractExports reads the "exports" array from a package manifest.
|
||||
func extractExports(manifest map[string]any) []string {
|
||||
raw, ok := manifest["exports"].([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
exports := make([]string, 0, len(raw))
|
||||
for _, v := range raw {
|
||||
if s, ok := v.(string); ok {
|
||||
exports = append(exports, s)
|
||||
}
|
||||
}
|
||||
return exports
|
||||
}
|
||||
@@ -13,6 +13,9 @@
|
||||
// at startup. db.read grants query/view/list_tables; db.write adds
|
||||
// insert/update/delete. If both are granted, db.write wins (superset).
|
||||
//
|
||||
// v0.38.2: Adds lib.load() for library packages. Libraries run with
|
||||
// their own permission context. Per-invocation lib cache + cycle detection.
|
||||
//
|
||||
// The runner is the bridge between the package/permission system
|
||||
// and the sandboxed Starlark interpreter.
|
||||
package sandbox
|
||||
@@ -119,8 +122,11 @@ func (r *Runner) ExecPackage(ctx context.Context, pkg *store.PackageRegistration
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// v0.38.2: per-invocation lib context for lib.load() caching + cycle detection
|
||||
lc := newLibContext()
|
||||
|
||||
// Build modules based on granted permissions
|
||||
modules, err := r.buildModules(ctx, pkg.ID, pkg.Manifest, rc)
|
||||
modules, err := r.buildModulesWithLibCtx(ctx, pkg.ID, pkg.Manifest, rc, lc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to build modules for %q: %w", pkg.ID, err)
|
||||
}
|
||||
@@ -256,10 +262,17 @@ func (r *Runner) CallEntryPoint(ctx context.Context, pkg *store.PackageRegistrat
|
||||
}
|
||||
|
||||
// buildModules assembles the module map based on granted permissions.
|
||||
// Delegates to buildModulesWithLibCtx with a nil lib context (no lib.load support).
|
||||
func (r *Runner) buildModules(ctx context.Context, packageID string, manifest map[string]any, rc *RunContext) (map[string]starlark.Value, error) {
|
||||
return r.buildModulesWithLibCtx(ctx, packageID, manifest, rc, nil)
|
||||
}
|
||||
|
||||
// buildModulesWithLibCtx assembles the module map based on granted permissions.
|
||||
// The manifest is passed through so modules can read their config
|
||||
// (e.g., http module reads network_access, provider reads requires_provider).
|
||||
// The RunContext carries per-invocation state for user-scoped modules.
|
||||
func (r *Runner) buildModules(ctx context.Context, packageID string, manifest map[string]any, rc *RunContext) (map[string]starlark.Value, error) {
|
||||
// The libContext enables lib.load() caching and cycle detection (v0.38.2).
|
||||
func (r *Runner) buildModulesWithLibCtx(ctx context.Context, packageID string, manifest map[string]any, rc *RunContext, lc *libContext) (map[string]starlark.Value, error) {
|
||||
if r.stores.ExtPermissions == nil {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -332,5 +345,11 @@ func (r *Runner) buildModules(ctx context.Context, packageID string, manifest ma
|
||||
}
|
||||
modules["settings"] = BuildSettingsModule(ctx, r.stores, packageID, userID)
|
||||
|
||||
// v0.38.2: lib module — always injected, no permission required.
|
||||
// Allows any starlark package to load declared library dependencies.
|
||||
if lc != nil {
|
||||
modules["lib"] = BuildLibModule(ctx, r, packageID, rc, lc)
|
||||
}
|
||||
|
||||
return modules, nil
|
||||
}
|
||||
|
||||
@@ -8820,6 +8820,74 @@ paths:
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
# ── Extension Dependencies (v0.38.2) ─────────────────────────────────
|
||||
/api/v1/admin/packages/{id}/dependencies:
|
||||
get:
|
||||
tags: [Admin, Extensions]
|
||||
summary: List libraries this package depends on
|
||||
description: "v0.38.2: Returns dependency records where this package is the consumer."
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/ResourceID'
|
||||
responses:
|
||||
'200':
|
||||
description: Dependencies list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ExtDependency'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
/api/v1/admin/packages/{id}/consumers:
|
||||
get:
|
||||
tags: [Admin, Extensions]
|
||||
summary: List packages that depend on this library
|
||||
description: "v0.38.2: Returns dependency records where this package is the library."
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/ResourceID'
|
||||
responses:
|
||||
'200':
|
||||
description: Consumers list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ExtDependency'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
/api/v1/admin/dependencies:
|
||||
get:
|
||||
tags: [Admin, Extensions]
|
||||
summary: List all package dependencies
|
||||
description: "v0.38.2: Returns the full dependency graph."
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
'200':
|
||||
description: Full dependency graph
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ExtDependency'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
# ── Extension Connections (v0.38.1) ─────────────────────────────────
|
||||
/api/v1/connections:
|
||||
get:
|
||||
@@ -12266,6 +12334,22 @@ components:
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
ExtDependency:
|
||||
type: object
|
||||
description: "v0.38.2: Package dependency record (consumer → library)"
|
||||
properties:
|
||||
consumer_id:
|
||||
type: string
|
||||
description: Package ID of the consumer
|
||||
library_id:
|
||||
type: string
|
||||
description: Package ID of the library
|
||||
version_spec:
|
||||
type: string
|
||||
description: Semver version spec declared by consumer
|
||||
resolved_ver:
|
||||
type: string
|
||||
description: Actual library version at dependency creation time
|
||||
ExtConnection:
|
||||
type: object
|
||||
description: "v0.38.1: Extension connection credential (secrets masked in list responses)"
|
||||
|
||||
@@ -66,6 +66,7 @@ type Stores struct {
|
||||
RateLimits RateLimitStore // v0.32.0: Distributed rate limiting
|
||||
Export ExportStore // v0.34.0: Data portability batch reads + GDPR ops
|
||||
Connections ConnectionStore // v0.38.1: Extension connection credentials
|
||||
Dependencies DependencyStore // v0.38.2: Library package dependency graph
|
||||
}
|
||||
|
||||
// =========================================
|
||||
@@ -1396,6 +1397,35 @@ type ConnectionStore interface {
|
||||
DeleteByIDAndScope(ctx context.Context, id, scope, ownerID string) (int64, error)
|
||||
}
|
||||
|
||||
// =========================================
|
||||
// DEPENDENCY STORE (v0.38.2)
|
||||
// =========================================
|
||||
|
||||
type DependencyStore interface {
|
||||
// Create records that consumerID depends on libraryID.
|
||||
Create(ctx context.Context, dep *models.ExtDependency) error
|
||||
|
||||
// Delete removes a single dependency record.
|
||||
Delete(ctx context.Context, consumerID, libraryID string) error
|
||||
|
||||
// DeleteAllForConsumer removes all dependency records for a consumer.
|
||||
// Called when uninstalling a consumer package.
|
||||
DeleteAllForConsumer(ctx context.Context, consumerID string) error
|
||||
|
||||
// ListByConsumer returns all libraries that consumerID depends on.
|
||||
ListByConsumer(ctx context.Context, consumerID string) ([]models.ExtDependency, error)
|
||||
|
||||
// ListByLibrary returns all consumers that depend on libraryID.
|
||||
ListByLibrary(ctx context.Context, libraryID string) ([]models.ExtDependency, error)
|
||||
|
||||
// ListAll returns the full dependency graph.
|
||||
ListAll(ctx context.Context) ([]models.ExtDependency, error)
|
||||
|
||||
// HasConsumers returns true if any package depends on libraryID.
|
||||
// Used for uninstall protection.
|
||||
HasConsumers(ctx context.Context, libraryID string) (bool, error)
|
||||
}
|
||||
|
||||
// =========================================
|
||||
|
||||
// ListOptions provides standard pagination/sort for list queries.
|
||||
|
||||
95
server/store/postgres/ext_dependency.go
Normal file
95
server/store/postgres/ext_dependency.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"chat-switchboard/models"
|
||||
)
|
||||
|
||||
// ── DependencyStore (v0.38.2) ────────────────────────
|
||||
|
||||
type DependencyStore struct{}
|
||||
|
||||
func NewDependencyStore() *DependencyStore { return &DependencyStore{} }
|
||||
|
||||
const depCols = `consumer_id, library_id, version_spec, resolved_ver`
|
||||
|
||||
func (s *DependencyStore) Create(ctx context.Context, dep *models.ExtDependency) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT INTO ext_dependencies (consumer_id, library_id, version_spec, resolved_ver)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (consumer_id, library_id) DO UPDATE
|
||||
SET version_spec = EXCLUDED.version_spec, resolved_ver = EXCLUDED.resolved_ver`,
|
||||
dep.ConsumerID, dep.LibraryID, dep.VersionSpec, dep.ResolvedVer,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *DependencyStore) Delete(ctx context.Context, consumerID, libraryID string) error {
|
||||
_, err := DB.ExecContext(ctx,
|
||||
`DELETE FROM ext_dependencies WHERE consumer_id = $1 AND library_id = $2`,
|
||||
consumerID, libraryID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *DependencyStore) DeleteAllForConsumer(ctx context.Context, consumerID string) error {
|
||||
_, err := DB.ExecContext(ctx,
|
||||
`DELETE FROM ext_dependencies WHERE consumer_id = $1`, consumerID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *DependencyStore) ListByConsumer(ctx context.Context, consumerID string) ([]models.ExtDependency, error) {
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
`SELECT `+depCols+` FROM ext_dependencies WHERE consumer_id = $1 ORDER BY library_id`,
|
||||
consumerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanDependencies(rows)
|
||||
}
|
||||
|
||||
func (s *DependencyStore) ListByLibrary(ctx context.Context, libraryID string) ([]models.ExtDependency, error) {
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
`SELECT `+depCols+` FROM ext_dependencies WHERE library_id = $1 ORDER BY consumer_id`,
|
||||
libraryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanDependencies(rows)
|
||||
}
|
||||
|
||||
func (s *DependencyStore) ListAll(ctx context.Context) ([]models.ExtDependency, error) {
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
`SELECT `+depCols+` FROM ext_dependencies ORDER BY consumer_id, library_id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanDependencies(rows)
|
||||
}
|
||||
|
||||
func (s *DependencyStore) HasConsumers(ctx context.Context, libraryID string) (bool, error) {
|
||||
var exists bool
|
||||
err := DB.QueryRowContext(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM ext_dependencies WHERE library_id = $1)`,
|
||||
libraryID).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
// ── Internal ───────────────────────────────────────
|
||||
|
||||
func scanDependencies(rows *sql.Rows) ([]models.ExtDependency, error) {
|
||||
var result []models.ExtDependency
|
||||
for rows.Next() {
|
||||
var d models.ExtDependency
|
||||
err := rows.Scan(&d.ConsumerID, &d.LibraryID, &d.VersionSpec, &d.ResolvedVer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, d)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
@@ -52,5 +52,6 @@ func NewStores(db *sql.DB) store.Stores {
|
||||
RateLimits: NewRateLimitStore(),
|
||||
Export: NewExportStore(),
|
||||
Connections: NewConnectionStore(),
|
||||
Dependencies: NewDependencyStore(),
|
||||
}
|
||||
}
|
||||
|
||||
93
server/store/sqlite/ext_dependency.go
Normal file
93
server/store/sqlite/ext_dependency.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"chat-switchboard/models"
|
||||
)
|
||||
|
||||
// ── DependencyStore (v0.38.2) ────────────────────────
|
||||
|
||||
type DependencyStore struct{}
|
||||
|
||||
func NewDependencyStore() *DependencyStore { return &DependencyStore{} }
|
||||
|
||||
const depCols = `consumer_id, library_id, version_spec, resolved_ver`
|
||||
|
||||
func (s *DependencyStore) Create(ctx context.Context, dep *models.ExtDependency) error {
|
||||
_, err := DB.ExecContext(ctx, `
|
||||
INSERT OR REPLACE INTO ext_dependencies (consumer_id, library_id, version_spec, resolved_ver)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
dep.ConsumerID, dep.LibraryID, dep.VersionSpec, dep.ResolvedVer,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *DependencyStore) Delete(ctx context.Context, consumerID, libraryID string) error {
|
||||
_, err := DB.ExecContext(ctx,
|
||||
`DELETE FROM ext_dependencies WHERE consumer_id = ? AND library_id = ?`,
|
||||
consumerID, libraryID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *DependencyStore) DeleteAllForConsumer(ctx context.Context, consumerID string) error {
|
||||
_, err := DB.ExecContext(ctx,
|
||||
`DELETE FROM ext_dependencies WHERE consumer_id = ?`, consumerID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *DependencyStore) ListByConsumer(ctx context.Context, consumerID string) ([]models.ExtDependency, error) {
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
`SELECT `+depCols+` FROM ext_dependencies WHERE consumer_id = ? ORDER BY library_id`,
|
||||
consumerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanDependencies(rows)
|
||||
}
|
||||
|
||||
func (s *DependencyStore) ListByLibrary(ctx context.Context, libraryID string) ([]models.ExtDependency, error) {
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
`SELECT `+depCols+` FROM ext_dependencies WHERE library_id = ? ORDER BY consumer_id`,
|
||||
libraryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanDependencies(rows)
|
||||
}
|
||||
|
||||
func (s *DependencyStore) ListAll(ctx context.Context) ([]models.ExtDependency, error) {
|
||||
rows, err := DB.QueryContext(ctx,
|
||||
`SELECT `+depCols+` FROM ext_dependencies ORDER BY consumer_id, library_id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanDependencies(rows)
|
||||
}
|
||||
|
||||
func (s *DependencyStore) HasConsumers(ctx context.Context, libraryID string) (bool, error) {
|
||||
var count int
|
||||
err := DB.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM ext_dependencies WHERE library_id = ?`,
|
||||
libraryID).Scan(&count)
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
// ── Internal ───────────────────────────────────────
|
||||
|
||||
func scanDependencies(rows *sql.Rows) ([]models.ExtDependency, error) {
|
||||
var result []models.ExtDependency
|
||||
for rows.Next() {
|
||||
var d models.ExtDependency
|
||||
err := rows.Scan(&d.ConsumerID, &d.LibraryID, &d.VersionSpec, &d.ResolvedVer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, d)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
@@ -52,5 +52,6 @@ func NewStores(db *sql.DB) store.Stores {
|
||||
RateLimits: NewRateLimitStore(),
|
||||
Export: NewExportStore(),
|
||||
Connections: NewConnectionStore(),
|
||||
Dependencies: NewDependencyStore(),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user