This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/sandbox/runner.go
Jeffrey Smith 8957e99610 Feat v0.7.12 batch.exec concurrent execution primitive
Add batch.exec(callables, timeout=10) — a general-purpose concurrent
execution primitive that runs arbitrary Starlark callables in parallel,
each in its own thread with independent step budget. Enables extensions
to fan out library function calls (frozen exports from lib.require)
without decomposing them back into raw http.post parameters.

New permission: batch.exec. Max 8 callables, timeout 1-30s.
Nesting prohibited via atomic flag. 12 new tests, all pass with -race.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 23:52:01 +00:00

420 lines
13 KiB
Go

// Package sandbox — runner.go
//
// the module set based on granted permissions, and executes it.
//
// network_access config from package manifest.
//
// vault for provider key decryption, and provider.complete module.
//
// at startup. db.read grants query/view/list_tables; db.write adds
// insert/update/delete. If both are granted, db.write wins (superset).
//
// 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
import (
"context"
"database/sql"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync/atomic"
"time"
"go.starlark.net/starlark"
starlarkjson "go.starlark.net/lib/json"
"armature/events"
"armature/metrics"
"armature/models"
"armature/store"
)
// sandboxStats tracks cumulative execution counters for the admin metrics endpoint.
var sandboxStats struct {
execCount atomic.Int64
errorCount atomic.Int64
totalDurationNs atomic.Int64
}
// SandboxStats returns cumulative execution counters.
func SandboxStats() (execCount, errorCount uint64, avgDurationMs float64) {
exec := sandboxStats.execCount.Load()
errs := sandboxStats.errorCount.Load()
totalNs := sandboxStats.totalDurationNs.Load()
if exec > 0 {
avgDurationMs = float64(totalNs) / float64(exec) / 1e6
}
return uint64(exec), uint64(errs), avgDurationMs
}
// RunContext carries per-invocation state that modules need but which
// varies per caller (API route vs filter vs task). Nil is safe — modules
// that need RunContext fields gracefully degrade.
type RunContext struct {
// UserID is the acting user for provider resolution (BYOK chain).
UserID string
// TeamID is the team context for settings cascade resolution.
// When set, team-scoped package settings are included in the cascade.
TeamID string
}
// Runner executes Starlark package scripts with permission-gated modules.
type Runner struct {
sandbox *Sandbox
stores store.Stores
packagesDir string
notifier NotificationSender // nil = notifications module unavailable
connResolver ConnectionResolver // nil = connections module unavailable
db *sql.DB // nil = db module unavailable
dbPostgres bool // true = use $N placeholders; false = use ?
allowPrivateIPs bool
bus *events.Bus
}
// NewRunner creates a runner with the given sandbox and dependencies.
func NewRunner(sb *Sandbox, stores store.Stores) *Runner {
return &Runner{
sandbox: sb,
stores: stores,
}
}
// SetNotifier attaches the notification sender for the notifications module.
func (r *Runner) SetNotifier(n NotificationSender) {
r.notifier = n
}
// SetConnectionResolver attaches the connection resolver for the connections module.
func (r *Runner) SetConnectionResolver(cr ConnectionResolver) {
r.connResolver = cr
}
// SetDB attaches the raw database connection for the db module.
// isPostgres controls whether $N or ? placeholders are used.
// Call this at startup after database.Connect().
func (r *Runner) SetDB(db *sql.DB, isPostgres bool) {
r.db = db
r.dbPostgres = isPostgres
}
// SetPackagesDir sets the disk path where package archives are extracted.
// Required for load() support — scripts are read from disk at runtime.
func (r *Runner) SetPackagesDir(dir string) {
r.packagesDir = dir
}
// SetBus attaches the event bus for the realtime module.
func (r *Runner) SetBus(bus *events.Bus) {
r.bus = bus
}
// SetAllowPrivateIPs disables the SSRF check that blocks connections to
// private/loopback IPs. For self-hosted environments where extensions
// reach internal services. Controlled by EXT_ALLOW_PRIVATE_IPS env var.
func (r *Runner) SetAllowPrivateIPs(allow bool) {
r.allowPrivateIPs = allow
}
// ExecPackage loads a package's script from disk (primary) or manifest
// (legacy) and executes it with modules gated by granted permissions.
//
//
// The RunContext carries per-invocation state (user_id for provider
// resolution). Pass nil if no per-invocation context is needed.
//
// Returns the script's globals (which may contain callable entry points
// like on_pre_completion, on_run, etc.) and any captured print output.
func (r *Runner) ExecPackage(ctx context.Context, pkg *store.PackageRegistration, rc *RunContext) (*Result, error) {
// Only active starlark packages can execute
if pkg.Status != models.PackageStatusActive {
return nil, fmt.Errorf("package %q is %s, not active", pkg.ID, pkg.Status)
}
if pkg.Tier != models.ExtTierStarlark {
return nil, fmt.Errorf("package %q is tier %s, not starlark", pkg.ID, pkg.Tier)
}
// Load script from disk (primary) or manifest (legacy)
script, err := r.loadScript(pkg)
if err != nil {
return nil, err
}
lc := newLibContext()
// Build modules based on granted permissions
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)
}
// Build package-scoped load callback
loader := r.packageLoader(pkg.ID, modules)
log.Printf(" 🔧 runner: exec %s (%d modules granted)", pkg.ID, len(modules))
start := time.Now()
result, err := r.sandbox.ExecWithLoader(ctx, pkg.ID+"/script.star", script, modules, loader)
duration := time.Since(start)
sandboxStats.execCount.Add(1)
sandboxStats.totalDurationNs.Add(int64(duration))
status := "success"
if err != nil {
sandboxStats.errorCount.Add(1)
status = "error"
}
metrics.SandboxExecutionsTotal.WithLabelValues("exec", status).Inc()
return result, err
}
// 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)+2)
for k, v := range modules {
predeclared[k] = v
}
// json is always available (added by ExecWithLoader for entry point;
// sub-modules need it too)
predeclared["json"] = starlarkjson.Module
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.
// This is the standard pattern for event-driven extensions:
// 1. Exec the script (defines functions)
// 2. Find the named entry point in globals
// 3. Call it with the provided arguments
//
// The RunContext carries per-invocation state. Pass nil if not needed.
//
// Returns the function's return value and captured output.
func (r *Runner) CallEntryPoint(ctx context.Context, pkg *store.PackageRegistration, entryPoint string, args starlark.Tuple, kwargs []starlark.Tuple, rc *RunContext) (starlark.Value, string, error) {
result, err := r.ExecPackage(ctx, pkg, rc)
if err != nil {
return nil, "", err
}
fn, ok := result.Globals[entryPoint]
if !ok {
return nil, result.Output, fmt.Errorf("package %q has no %s function", pkg.ID, entryPoint)
}
callable, ok := fn.(starlark.Callable)
if !ok {
return nil, result.Output, fmt.Errorf("package %q: %s is not callable", pkg.ID, entryPoint)
}
start := time.Now()
val, callOutput, callErr := r.sandbox.Call(ctx, callable, args, kwargs)
duration := time.Since(start)
sandboxStats.execCount.Add(1)
sandboxStats.totalDurationNs.Add(int64(duration))
status := "success"
if callErr != nil {
sandboxStats.errorCount.Add(1)
status = "error"
}
metrics.SandboxExecutionsTotal.WithLabelValues(entryPoint, status).Inc()
return val, result.Output + callOutput, callErr
}
// 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.
// The libContext enables lib.load() caching and cycle detection.
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
}
granted, err := r.stores.ExtPermissions.GrantedForPackage(ctx, packageID)
if err != nil {
return nil, err
}
modules := make(map[string]starlark.Value)
// Track db permission level: 0=none, 1=read, 2=write
dbLevel := 0
hasBatchExec := false
for _, perm := range granted {
switch perm {
case models.ExtPermSecretsRead:
modules["secrets"] = BuildSecretsModule(ctx, r.stores, packageID)
case models.ExtPermNotificationsSend:
if r.notifier != nil {
modules["notifications"] = BuildNotificationsModule(ctx, r.notifier, packageID)
}
case models.ExtPermAPIHTTP:
httpCfg := ParseNetworkAccess(manifest)
httpCfg.AllowPrivateIPs = r.allowPrivateIPs
modules["http"] = BuildHTTPModule(ctx, httpCfg)
case models.ExtPermDBRead:
if dbLevel < 1 {
dbLevel = 1
}
case models.ExtPermDBWrite:
dbLevel = 2
case models.ExtPermWorkflowAccess:
modules["workflow"] = BuildWorkflowModule(ctx, r.stores)
case models.ExtPermConnectionsRead:
if r.connResolver != nil && rc != nil && rc.UserID != "" {
modules["connections"] = BuildConnectionsModule(ctx, r.connResolver, rc.UserID)
}
case models.ExtPermRealtimePublish:
if r.bus != nil {
modules["realtime"] = BuildRealtimeModule(ctx, r.bus, packageID)
}
case models.ExtPermBatchExec:
hasBatchExec = true
}
}
// Wire db module at the highest granted level.
if dbLevel > 0 && r.db != nil {
modules["db"] = BuildDBModule(ctx, DBModuleConfig{
PackageID: packageID,
CanWrite: dbLevel == 2,
DB: r.db,
IsPostgres: r.dbPostgres,
})
}
// A package reads its own admin + team + user settings.
userID := ""
teamID := ""
if rc != nil {
userID = rc.UserID
teamID = rc.TeamID
}
modules["settings"] = BuildSettingsModule(ctx, r.stores, packageID, userID, teamID)
// Always available — read-only check against kernel permission data
modules["permissions"] = BuildPermissionsModule(ctx, r.stores)
// Allows any starlark package to load declared library dependencies.
if lc != nil {
modules["lib"] = BuildLibModule(ctx, r, packageID, rc, lc)
}
// Concurrent execution primitive — wired after all other modules
// so the runner reference can construct per-branch module sets if
// needed in future (Option B).
if hasBatchExec {
modules["batch"] = BuildBatchModule(ctx, r, packageID, manifest, rc, lc)
}
return modules, nil
}