Changeset 0.38.0 (#233)
Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
@@ -241,31 +241,25 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// v0.37.15: Read script.star from archive if present.
|
||||
// Keeps the manifest clean — authors write a file, installer injects
|
||||
// it as _starlark_script for the sandbox runner.
|
||||
if _, hasInline := manifest["_starlark_script"]; !hasInline {
|
||||
// v0.38.0: Entry point validation for starlark-tier packages.
|
||||
// Scripts are loaded from disk at runtime — no _starlark_script injection.
|
||||
if manifestTier, _ := manifest["tier"].(string); manifestTier == "starlark" {
|
||||
entryPoint := "script.star"
|
||||
if ep, ok := manifest["entry_point"].(string); ok && ep != "" {
|
||||
entryPoint = ep
|
||||
}
|
||||
found := false
|
||||
for _, f := range zr.File {
|
||||
name := f.Name
|
||||
base := filepath.Base(name)
|
||||
if base == "script.star" && !f.FileInfo().IsDir() {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
data, err := io.ReadAll(rc)
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
script := string(data)
|
||||
if strings.TrimSpace(script) != "" {
|
||||
manifest["_starlark_script"] = script
|
||||
log.Printf("[packages] Injected script.star (%d bytes) into manifest", len(data))
|
||||
}
|
||||
base := filepath.Base(f.Name)
|
||||
if base == entryPoint && !f.FileInfo().IsDir() {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("starlark package missing entry point %q", entryPoint)})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
@@ -487,17 +481,25 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
||||
}
|
||||
|
||||
// extractableRelPath returns the relative path for a zip entry if it
|
||||
// belongs to an extractable static directory (js/, css/, assets/).
|
||||
// belongs to an extractable directory (js/, css/, assets/, star/) or
|
||||
// is a bare .star file at the archive root.
|
||||
// Returns "" if the file should be skipped.
|
||||
func extractableRelPath(name string) string {
|
||||
staticPrefixes := []string{"js/", "css/", "assets/"}
|
||||
staticPrefixes := []string{"js/", "css/", "assets/", "star/"}
|
||||
|
||||
// Direct match (flat archive without package-id/ wrapper)
|
||||
for _, p := range staticPrefixes {
|
||||
if strings.HasPrefix(name, p) {
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
// Bare .star file at root (e.g. "script.star")
|
||||
if strings.HasSuffix(name, ".star") && !strings.Contains(name, "/") {
|
||||
return name
|
||||
}
|
||||
|
||||
// Nested inside package-id/ directory
|
||||
idx := strings.Index(name, "/")
|
||||
if idx <= 0 {
|
||||
return ""
|
||||
@@ -510,6 +512,11 @@ func extractableRelPath(name string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// Bare .star file inside package-id/ (e.g. "gitea-client/script.star")
|
||||
if strings.HasSuffix(rest, ".star") && !strings.Contains(rest, "/") {
|
||||
return rest
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
@@ -410,6 +410,10 @@ func main() {
|
||||
starlarkRunner.SetProviderResolver(handlers.NewProviderResolverAdapter(stores, keyResolver))
|
||||
// v0.29.2: db module — extension namespaced table access
|
||||
starlarkRunner.SetDB(database.DB, database.IsPostgres())
|
||||
// v0.38.0: disk-based script loading + load() support
|
||||
if cfg.StoragePath != "" {
|
||||
starlarkRunner.SetPackagesDir(cfg.StoragePath + "/packages")
|
||||
}
|
||||
|
||||
// Discover and register active Starlark pre-completion filters
|
||||
filters.DiscoverStarlarkFilters(context.Background(), filterChain, stores, starlarkRunner)
|
||||
|
||||
@@ -22,6 +22,9 @@ import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"go.starlark.net/starlark"
|
||||
|
||||
@@ -43,12 +46,13 @@ type RunContext struct {
|
||||
|
||||
// Runner executes Starlark package scripts with permission-gated modules.
|
||||
type Runner struct {
|
||||
sandbox *Sandbox
|
||||
stores store.Stores
|
||||
notifier NotificationSender // nil = notifications module unavailable
|
||||
resolver ProviderResolver // nil = provider module unavailable
|
||||
db *sql.DB // nil = db module unavailable
|
||||
dbPostgres bool // true = use $N placeholders; false = use ?
|
||||
sandbox *Sandbox
|
||||
stores store.Stores
|
||||
packagesDir string // v0.38.0: disk path for load() support
|
||||
notifier NotificationSender // nil = notifications module unavailable
|
||||
resolver ProviderResolver // nil = provider module unavailable
|
||||
db *sql.DB // nil = db module unavailable
|
||||
dbPostgres bool // true = use $N placeholders; false = use ?
|
||||
}
|
||||
|
||||
// NewRunner creates a runner with the given sandbox and dependencies.
|
||||
@@ -77,8 +81,17 @@ func (r *Runner) SetDB(db *sql.DB, isPostgres bool) {
|
||||
r.dbPostgres = isPostgres
|
||||
}
|
||||
|
||||
// ExecPackage loads a package's script from its manifest and executes it
|
||||
// with modules gated by the package's granted permissions.
|
||||
// SetPackagesDir sets the disk path where package archives are extracted.
|
||||
// Required for load() support — scripts are read from disk at runtime.
|
||||
// v0.38.0.
|
||||
func (r *Runner) SetPackagesDir(dir string) {
|
||||
r.packagesDir = dir
|
||||
}
|
||||
|
||||
// ExecPackage loads a package's script from disk (primary) or manifest
|
||||
// (legacy) and executes it with modules gated by granted permissions.
|
||||
//
|
||||
// v0.38.0: Disk-based loading + package-scoped load() support.
|
||||
//
|
||||
// The RunContext carries per-invocation state (user_id for provider
|
||||
// resolution). Pass nil if no per-invocation context is needed.
|
||||
@@ -94,10 +107,10 @@ func (r *Runner) ExecPackage(ctx context.Context, pkg *store.PackageRegistration
|
||||
return nil, fmt.Errorf("package %q is tier %s, not starlark", pkg.ID, pkg.Tier)
|
||||
}
|
||||
|
||||
// Extract script from manifest
|
||||
script, ok := pkg.Manifest["_starlark_script"].(string)
|
||||
if !ok || script == "" {
|
||||
return nil, fmt.Errorf("package %q has no _starlark_script in manifest", pkg.ID)
|
||||
// Load script from disk (primary) or manifest (legacy)
|
||||
script, err := r.loadScript(pkg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build modules based on granted permissions
|
||||
@@ -106,9 +119,105 @@ func (r *Runner) ExecPackage(ctx context.Context, pkg *store.PackageRegistration
|
||||
return nil, fmt.Errorf("failed to build modules for %q: %w", pkg.ID, err)
|
||||
}
|
||||
|
||||
// Build package-scoped load callback
|
||||
loader := r.packageLoader(pkg.ID, modules)
|
||||
|
||||
log.Printf(" 🔧 runner: exec %s (%d modules granted)", pkg.ID, len(modules))
|
||||
|
||||
return r.sandbox.Exec(ctx, pkg.ID+".star", script, modules)
|
||||
return r.sandbox.ExecWithLoader(ctx, pkg.ID+"/script.star", script, modules, loader)
|
||||
}
|
||||
|
||||
// loadScript reads the entry point script from disk (primary path)
|
||||
// or falls back to the legacy _starlark_script manifest field.
|
||||
func (r *Runner) loadScript(pkg *store.PackageRegistration) (string, error) {
|
||||
// Primary: read from disk
|
||||
if r.packagesDir != "" {
|
||||
entryPoint := "script.star"
|
||||
if ep, ok := pkg.Manifest["entry_point"].(string); ok && ep != "" {
|
||||
entryPoint = ep
|
||||
}
|
||||
path := filepath.Join(r.packagesDir, pkg.ID, entryPoint)
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil && len(data) > 0 {
|
||||
return string(data), nil
|
||||
}
|
||||
// Fall through to legacy
|
||||
}
|
||||
|
||||
// Legacy: inline in manifest
|
||||
script, ok := pkg.Manifest["_starlark_script"].(string)
|
||||
if ok && script != "" {
|
||||
return script, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("package %q: no script.star on disk and no _starlark_script in manifest", pkg.ID)
|
||||
}
|
||||
|
||||
// loadEntry tracks cache and cycle detection for the package loader.
|
||||
type loadEntry struct {
|
||||
loading bool
|
||||
globals starlark.StringDict
|
||||
}
|
||||
|
||||
// packageLoader builds a LoadFunc scoped to a package's directory.
|
||||
// Returns nil if no packages directory is configured.
|
||||
func (r *Runner) packageLoader(pkgID string, modules map[string]starlark.Value) LoadFunc {
|
||||
if r.packagesDir == "" {
|
||||
return nil // no disk = no load support
|
||||
}
|
||||
|
||||
pkgDir := filepath.Join(r.packagesDir, pkgID)
|
||||
cache := make(map[string]*loadEntry) // dedup + cycle detection
|
||||
|
||||
return func(thread *starlark.Thread, module string) (starlark.StringDict, error) {
|
||||
// Security: reject path traversal
|
||||
if strings.Contains(module, "..") || filepath.IsAbs(module) {
|
||||
return nil, fmt.Errorf("load: path traversal not allowed: %q", module)
|
||||
}
|
||||
|
||||
// Resolve to package directory
|
||||
resolved := filepath.Join(pkgDir, module)
|
||||
if !strings.HasPrefix(filepath.Clean(resolved), filepath.Clean(pkgDir)) {
|
||||
return nil, fmt.Errorf("load: path escapes package directory: %q", module)
|
||||
}
|
||||
|
||||
// Must be a .star file
|
||||
if !strings.HasSuffix(resolved, ".star") {
|
||||
return nil, fmt.Errorf("load: only .star files can be loaded: %q", module)
|
||||
}
|
||||
|
||||
// Cache / cycle detection
|
||||
if entry, ok := cache[module]; ok {
|
||||
if entry.loading {
|
||||
return nil, fmt.Errorf("load: circular dependency: %q", module)
|
||||
}
|
||||
return entry.globals, nil
|
||||
}
|
||||
|
||||
entry := &loadEntry{loading: true}
|
||||
cache[module] = entry
|
||||
|
||||
// Read and execute
|
||||
data, err := os.ReadFile(resolved)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load: %q not found in package %q", module, pkgID)
|
||||
}
|
||||
|
||||
// Build predeclared with same modules as entry point
|
||||
predeclared := make(starlark.StringDict, len(modules)+1)
|
||||
for k, v := range modules {
|
||||
predeclared[k] = v
|
||||
}
|
||||
|
||||
globals, err := starlark.ExecFile(thread, module, string(data), predeclared)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load: error in %q: %w", module, err)
|
||||
}
|
||||
|
||||
entry.globals = globals
|
||||
entry.loading = false
|
||||
return globals, nil
|
||||
}
|
||||
}
|
||||
|
||||
// CallEntryPoint executes a package script and calls a named function.
|
||||
|
||||
@@ -79,18 +79,28 @@ func New(cfg Config) *Sandbox {
|
||||
return &Sandbox{config: cfg}
|
||||
}
|
||||
|
||||
// LoadFunc resolves load("path") calls to Starlark source.
|
||||
// Returns the module's globals. The sandbox caches results per
|
||||
// thread (Starlark handles this via thread.Load dedup).
|
||||
type LoadFunc func(thread *starlark.Thread, module string) (starlark.StringDict, error)
|
||||
|
||||
// Exec executes a Starlark script within the sandbox.
|
||||
// load() is disabled — all dependencies must be provided via modules.
|
||||
// For load() support, use ExecWithLoader.
|
||||
func (s *Sandbox) Exec(ctx context.Context, filename, source string, modules map[string]starlark.Value) (*Result, error) {
|
||||
return s.ExecWithLoader(ctx, filename, source, modules, nil)
|
||||
}
|
||||
|
||||
// ExecWithLoader executes a Starlark script within the sandbox with
|
||||
// an optional load callback. If loader is nil, load() returns an error.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: context for timeout/cancellation
|
||||
// - filename: used in error messages and stack traces
|
||||
// - source: Starlark source code
|
||||
// - modules: predeclared modules injected into the script's namespace
|
||||
// (e.g., {"secrets": secretsModule, "notifications": notifModule})
|
||||
//
|
||||
// The script cannot use load() — all dependencies must be provided
|
||||
// via the modules parameter.
|
||||
func (s *Sandbox) Exec(ctx context.Context, filename, source string, modules map[string]starlark.Value) (*Result, error) {
|
||||
// - loader: resolves load() calls; nil = load() disabled
|
||||
func (s *Sandbox) ExecWithLoader(ctx context.Context, filename, source string, modules map[string]starlark.Value, loader LoadFunc) (*Result, error) {
|
||||
var output strings.Builder
|
||||
var outputMu sync.Mutex
|
||||
|
||||
@@ -101,6 +111,14 @@ func (s *Sandbox) Exec(ctx context.Context, filename, source string, modules map
|
||||
// json.encode / json.decode — universal, no permissions required (pure computation)
|
||||
predeclared["json"] = starlarkjson.Module
|
||||
|
||||
// Default: load() disabled. If loader provided, delegate to it.
|
||||
loadFn := func(_ *starlark.Thread, module string) (starlark.StringDict, error) {
|
||||
return nil, fmt.Errorf("load() is disabled in sandbox (requested %q)", module)
|
||||
}
|
||||
if loader != nil {
|
||||
loadFn = loader
|
||||
}
|
||||
|
||||
thread := &starlark.Thread{
|
||||
Name: s.config.Name,
|
||||
Print: func(_ *starlark.Thread, msg string) {
|
||||
@@ -109,9 +127,7 @@ func (s *Sandbox) Exec(ctx context.Context, filename, source string, modules map
|
||||
output.WriteString(msg)
|
||||
output.WriteByte('\n')
|
||||
},
|
||||
Load: func(_ *starlark.Thread, module string) (starlark.StringDict, error) {
|
||||
return nil, fmt.Errorf("load() is disabled in sandbox (requested %q)", module)
|
||||
},
|
||||
Load: loadFn,
|
||||
}
|
||||
|
||||
if s.config.MaxSteps > 0 {
|
||||
|
||||
@@ -8674,6 +8674,13 @@ paths:
|
||||
- Admin
|
||||
- Extensions
|
||||
summary: Install package from upload
|
||||
description: |
|
||||
Upload a .pkg/.surface/.zip archive containing manifest.json and
|
||||
optional assets. For starlark-tier packages, the archive must
|
||||
include `script.star` (or the manifest's `entry_point` value).
|
||||
Starlark submodules in `star/` are extracted alongside static
|
||||
assets (js/, css/, assets/). The manifest `entry_point` field
|
||||
overrides the default entry script name (v0.38.0).
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
|
||||
Reference in New Issue
Block a user