Changeset 0.29.0 (#195)

This commit is contained in:
2026-03-17 16:28:47 +00:00
parent 128cbb8174
commit 5d637d3a90
129 changed files with 9418 additions and 3016 deletions

136
server/sandbox/modules.go Normal file
View File

@@ -0,0 +1,136 @@
// Package sandbox — modules.go
//
// v0.29.0 CS3: Module factories for Starlark sandbox.
// Each factory returns a starlarkstruct.Module that the runner
// injects into the script namespace based on granted permissions.
package sandbox
import (
"context"
"fmt"
"go.starlark.net/starlark"
"go.starlark.net/starlarkstruct"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// ─── Secrets Module ──────────────────────────
//
// Requires permission: secrets.read
// Starlark API:
// val = secrets.get("api_key") → string or None
// all = secrets.list() → list of key names
//
// Secrets are stored in GlobalConfig under key "ext_secrets:{packageID}"
// as a JSON map: {"api_key": "sk-...", "webhook_token": "tok-..."}
// Admin sets them via PUT /admin/extensions/:id/secrets.
// BuildSecretsModule creates the "secrets" module scoped to a package.
func BuildSecretsModule(ctx context.Context, stores store.Stores, packageID string) *starlarkstruct.Module {
// Pre-load secrets for this package (single query, not per-call)
secretMap := loadExtensionSecrets(ctx, stores, packageID)
return MakeModule("secrets", starlark.StringDict{
"get": starlark.NewBuiltin("secrets.get", func(
thread *starlark.Thread, b *starlark.Builtin,
args starlark.Tuple, kwargs []starlark.Tuple,
) (starlark.Value, error) {
var key string
if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 1, &key); err != nil {
return nil, err
}
if val, ok := secretMap[key]; ok {
return starlark.String(val), nil
}
return starlark.None, nil
}),
"list": starlark.NewBuiltin("secrets.list", func(
thread *starlark.Thread, b *starlark.Builtin,
args starlark.Tuple, kwargs []starlark.Tuple,
) (starlark.Value, error) {
keys := make([]starlark.Value, 0, len(secretMap))
for k := range secretMap {
keys = append(keys, starlark.String(k))
}
return starlark.NewList(keys), nil
}),
})
}
func loadExtensionSecrets(ctx context.Context, stores store.Stores, packageID string) map[string]string {
result := make(map[string]string)
configKey := "ext_secrets:" + packageID
configVal, err := stores.GlobalConfig.Get(ctx, configKey)
if err != nil || configVal == nil {
return result
}
for k, v := range configVal {
if s, ok := v.(string); ok {
result[k] = s
}
}
return result
}
// ─── Notifications Module ────────────────────
//
// Requires permission: notifications.send
// Starlark API:
// notifications.send(user_id, title, body="", type="extension.notify")
//
// Creates an in-app notification via the notification store.
// The extension cannot send email or bypass user preferences —
// that's handled by the notification service layer.
// NotificationSender is the interface the notifications module needs.
// Matches notifications.Service.Notify without importing the package.
type NotificationSender interface {
Notify(ctx context.Context, n *models.Notification) error
}
// BuildNotificationsModule creates the "notifications" module scoped to a package.
func BuildNotificationsModule(ctx context.Context, sender NotificationSender, packageID string) *starlarkstruct.Module {
return MakeModule("notifications", starlark.StringDict{
"send": starlark.NewBuiltin("notifications.send", func(
thread *starlark.Thread, b *starlark.Builtin,
args starlark.Tuple, kwargs []starlark.Tuple,
) (starlark.Value, error) {
var userID, title string
var body string
var notifType string = "extension.notify"
if err := starlark.UnpackArgs(b.Name(), args, kwargs,
"user_id", &userID,
"title", &title,
"body?", &body,
"type?", &notifType,
); err != nil {
return nil, err
}
if userID == "" {
return nil, fmt.Errorf("notifications.send: user_id is required")
}
if title == "" {
return nil, fmt.Errorf("notifications.send: title is required")
}
n := &models.Notification{
UserID: userID,
Type: notifType,
Title: title,
Body: body,
ResourceType: "extension",
ResourceID: packageID,
}
if err := sender.Notify(ctx, n); err != nil {
return nil, fmt.Errorf("notifications.send failed: %w", err)
}
return starlark.True, nil
}),
})
}

View File

@@ -0,0 +1,171 @@
package sandbox
import (
"context"
"testing"
"go.starlark.net/starlark"
)
func TestBuildSecretsModule_Get(t *testing.T) {
mod := MakeModule("secrets", starlark.StringDict{
"get": starlark.NewBuiltin("secrets.get", func(
thread *starlark.Thread, b *starlark.Builtin,
args starlark.Tuple, kwargs []starlark.Tuple,
) (starlark.Value, error) {
var key string
if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 1, &key); err != nil {
return nil, err
}
if key == "api_key" {
return starlark.String("sk-test123"), nil
}
return starlark.None, nil
}),
})
sb := New(DefaultConfig())
res, err := sb.Exec(context.Background(), "test.star", `
val = secrets.get("api_key")
missing = secrets.get("nope")
`, map[string]starlark.Value{"secrets": mod})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
val := res.Globals["val"]
if val.(starlark.String).GoString() != "sk-test123" {
t.Fatalf("expected 'sk-test123', got %s", val.String())
}
missing := res.Globals["missing"]
if missing != starlark.None {
t.Fatalf("expected None, got %s", missing.String())
}
}
func TestBuildNotificationsModule_Send(t *testing.T) {
var sentTitle string
var sentUserID string
sendFn := starlark.NewBuiltin("notifications.send", func(
thread *starlark.Thread, b *starlark.Builtin,
args starlark.Tuple, kwargs []starlark.Tuple,
) (starlark.Value, error) {
var userID, title string
if err := starlark.UnpackArgs(b.Name(), args, kwargs,
"user_id", &userID, "title", &title); err != nil {
return nil, err
}
sentUserID = userID
sentTitle = title
return starlark.True, nil
})
mod := MakeModule("notifications", starlark.StringDict{
"send": sendFn,
})
sb := New(DefaultConfig())
_, err := sb.Exec(context.Background(), "test.star", `
result = notifications.send(user_id="u-1", title="Hello World")
`, map[string]starlark.Value{"notifications": mod})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if sentUserID != "u-1" {
t.Fatalf("expected user_id 'u-1', got %q", sentUserID)
}
if sentTitle != "Hello World" {
t.Fatalf("expected title 'Hello World', got %q", sentTitle)
}
}
func TestModuleIsolation(t *testing.T) {
// Script should not be able to access modules it wasn't given
sb := New(DefaultConfig())
_, err := sb.Exec(context.Background(), "test.star", `
val = secrets.get("key")
`, nil) // no modules injected
if err == nil {
t.Fatal("expected error when accessing undefined module")
}
}
func TestMakeModuleName(t *testing.T) {
mod := MakeModule("test_mod", starlark.StringDict{
"version": starlark.String("1.0"),
})
if mod.Name != "test_mod" {
t.Fatalf("expected 'test_mod', got %q", mod.Name)
}
}
func TestEntryPointCall(t *testing.T) {
sb := New(DefaultConfig())
// Simulate a package script with on_pre_completion entry point
res, err := sb.Exec(context.Background(), "ext.star", `
def on_pre_completion(ctx):
channel = ctx["channel_id"]
return [{"role": "system", "content": "context for " + channel}]
`, nil)
if err != nil {
t.Fatalf("exec error: %v", err)
}
fn := res.Globals["on_pre_completion"].(starlark.Callable)
ctxDict := starlark.NewDict(1)
ctxDict.SetKey(starlark.String("channel_id"), starlark.String("ch-abc"))
val, _, err := sb.Call(context.Background(), fn, starlark.Tuple{ctxDict}, nil)
if err != nil {
t.Fatalf("call error: %v", err)
}
list, ok := val.(*starlark.List)
if !ok {
t.Fatalf("expected list, got %s", val.Type())
}
if list.Len() != 1 {
t.Fatalf("expected 1 message, got %d", list.Len())
}
item := list.Index(0)
dict, ok := item.(*starlark.Dict)
if !ok {
t.Fatalf("expected dict, got %s", item.Type())
}
contentVal, _, _ := dict.Get(starlark.String("content"))
content, _ := starlark.AsString(contentVal)
if content != "context for ch-abc" {
t.Fatalf("expected 'context for ch-abc', got %q", content)
}
}
func TestEntryPointNoneReturn(t *testing.T) {
sb := New(DefaultConfig())
res, err := sb.Exec(context.Background(), "ext.star", `
def on_pre_completion(ctx):
return None
`, nil)
if err != nil {
t.Fatalf("exec error: %v", err)
}
fn := res.Globals["on_pre_completion"].(starlark.Callable)
ctxDict := starlark.NewDict(0)
val, _, err := sb.Call(context.Background(), fn, starlark.Tuple{ctxDict}, nil)
if err != nil {
t.Fatalf("call error: %v", err)
}
if val != starlark.None {
t.Fatalf("expected None, got %s", val.Type())
}
}

131
server/sandbox/runner.go Normal file
View File

@@ -0,0 +1,131 @@
// Package sandbox — runner.go
//
// v0.29.0 CS3: Runner loads a package's Starlark script, assembles
// the module set based on granted permissions, and executes it.
//
// The runner is the bridge between the package/permission system
// and the sandboxed Starlark interpreter.
package sandbox
import (
"context"
"fmt"
"log"
"go.starlark.net/starlark"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// Runner executes Starlark package scripts with permission-gated modules.
type Runner struct {
sandbox *Sandbox
stores store.Stores
notifier NotificationSender // nil = notifications module unavailable
}
// 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
}
// ExecPackage loads a package's script from its manifest and executes it
// with modules gated by the package's granted permissions.
//
// 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) (*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)
}
// 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)
}
// Build modules based on granted permissions
modules, err := r.buildModules(ctx, pkg.ID)
if err != nil {
return nil, fmt.Errorf("failed to build modules for %q: %w", pkg.ID, err)
}
log.Printf(" 🔧 runner: exec %s (%d modules granted)", pkg.ID, len(modules))
return r.sandbox.Exec(ctx, pkg.ID+".star", script, modules)
}
// 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
//
// 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) (starlark.Value, string, error) {
result, err := r.ExecPackage(ctx, pkg)
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)
}
val, callOutput, err := r.sandbox.Call(ctx, callable, args, kwargs)
return val, result.Output + callOutput, err
}
// buildModules assembles the module map based on granted permissions.
func (r *Runner) buildModules(ctx context.Context, packageID string) (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)
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)
}
// Future permissions (CS4+):
// case models.ExtPermDBRead, models.ExtPermDBWrite:
// modules["db"] = BuildDBModule(...)
// case models.ExtPermAPIHTTP:
// modules["http"] = BuildHTTPModule(...)
}
}
return modules, nil
}

229
server/sandbox/sandbox.go Normal file
View File

@@ -0,0 +1,229 @@
// Package sandbox — sandboxed Starlark interpreter.
//
// v0.29.0 CS1: Provides execution with timeout, step limits,
// captured output, and injectable predeclared modules.
//
// The sandbox is intentionally restrictive:
// - No file I/O
// - No network access
// - No os/env access
// - No load() statements (modules injected via predeclared)
// - Step limit prevents infinite loops
// - Context-based timeout prevents runaway wall-clock
//
// Extensions interact with the platform through injected Go builtins
// (e.g., secrets.get, notifications.send) — never through Starlark's
// own I/O. This is the eval loop; modules are added in CS3.
package sandbox
import (
"context"
"fmt"
"strings"
"sync"
"go.starlark.net/starlark"
"go.starlark.net/starlarkstruct"
)
// ─── Configuration ───────────────────────────
// Config controls sandbox resource limits.
type Config struct {
// MaxSteps limits total bytecode operations. 0 = unlimited.
// Default: 1_000_000 (~1M ops, covers most scripts in <100ms).
MaxSteps uint64
// Name identifies this sandbox in logs and error messages.
Name string
}
// DefaultConfig returns production defaults.
func DefaultConfig() Config {
return Config{
MaxSteps: 1_000_000,
Name: "extension",
}
}
// ─── Result ──────────────────────────────────
// Result holds the output of a sandbox execution.
type Result struct {
// Globals contains the top-level names defined by the script.
Globals map[string]starlark.Value
// Output captures all print() calls made during execution.
Output string
// Steps is the number of bytecode operations executed.
Steps uint64
}
// ─── Sandbox ─────────────────────────────────
// Sandbox executes Starlark scripts with resource limits and
// injected modules. Safe for concurrent use — each Exec call
// gets its own thread.
type Sandbox struct {
config Config
}
// New creates a sandbox with the given configuration.
// Set MaxSteps to 0 for unlimited (not recommended in production).
func New(cfg Config) *Sandbox {
if cfg.Name == "" {
cfg.Name = "extension"
}
return &Sandbox{config: cfg}
}
// Exec executes a Starlark script within the sandbox.
//
// 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) {
var output strings.Builder
var outputMu sync.Mutex
predeclared := make(starlark.StringDict, len(modules))
for k, v := range modules {
predeclared[k] = v
}
thread := &starlark.Thread{
Name: s.config.Name,
Print: func(_ *starlark.Thread, msg string) {
outputMu.Lock()
defer outputMu.Unlock()
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)
},
}
if s.config.MaxSteps > 0 {
thread.SetMaxExecutionSteps(s.config.MaxSteps)
}
// Wire context cancellation into the thread.
done := make(chan struct{})
defer close(done)
go func() {
select {
case <-ctx.Done():
thread.Cancel(ctx.Err().Error())
case <-done:
}
}()
globals, err := starlark.ExecFile(thread, filename, source, predeclared)
if err != nil {
return nil, wrapError(err)
}
result := &Result{
Globals: make(map[string]starlark.Value, len(globals)),
Output: output.String(),
Steps: thread.ExecutionSteps(),
}
for k, v := range globals {
result.Globals[k] = v
}
return result, nil
}
// Call invokes a Starlark function within the sandbox.
// Used by the task executor and filter runner to call specific
// entry points (e.g., "on_pre_completion", "on_run").
func (s *Sandbox) Call(ctx context.Context, fn starlark.Callable, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, string, error) {
var output strings.Builder
var outputMu sync.Mutex
thread := &starlark.Thread{
Name: s.config.Name + "/call",
Print: func(_ *starlark.Thread, msg string) {
outputMu.Lock()
defer outputMu.Unlock()
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)
},
}
if s.config.MaxSteps > 0 {
thread.SetMaxExecutionSteps(s.config.MaxSteps)
}
done := make(chan struct{})
defer close(done)
go func() {
select {
case <-ctx.Done():
thread.Cancel(ctx.Err().Error())
case <-done:
}
}()
val, err := starlark.Call(thread, fn, args, kwargs)
if err != nil {
return nil, output.String(), wrapError(err)
}
return val, output.String(), nil
}
// ─── Module Helper ───────────────────────────
// MakeModule creates a starlarkstruct.Module with the given name and
// members. Standard way to expose Go functions to Starlark.
//
// Usage:
//
// mod := sandbox.MakeModule("secrets", starlark.StringDict{
// "get": starlark.NewBuiltin("secrets.get", secretsGet),
// })
func MakeModule(name string, members starlark.StringDict) *starlarkstruct.Module {
return &starlarkstruct.Module{
Name: name,
Members: members,
}
}
// ─── Error Wrapping ──────────────────────────
// SandboxError wraps a Starlark execution error with context.
type SandboxError struct {
Err error
Backtrace string
}
func (e *SandboxError) Error() string {
if e.Backtrace != "" {
return fmt.Sprintf("%s\n%s", e.Err.Error(), e.Backtrace)
}
return e.Err.Error()
}
func (e *SandboxError) Unwrap() error { return e.Err }
func wrapError(err error) error {
if err == nil {
return nil
}
se := &SandboxError{Err: err}
if evalErr, ok := err.(*starlark.EvalError); ok {
se.Backtrace = evalErr.Backtrace()
}
return se
}

View File

@@ -0,0 +1,306 @@
package sandbox
import (
"context"
"strings"
"testing"
"time"
"go.starlark.net/starlark"
)
func TestExecBasic(t *testing.T) {
sb := New(DefaultConfig())
res, err := sb.Exec(context.Background(), "test.star", `
x = 1 + 2
name = "hello"
`, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if v, ok := res.Globals["x"]; !ok {
t.Fatal("missing global 'x'")
} else if v.String() != "3" {
t.Fatalf("expected x=3, got %s", v.String())
}
if v, ok := res.Globals["name"]; !ok {
t.Fatal("missing global 'name'")
} else if v.(starlark.String).GoString() != "hello" {
t.Fatalf("expected name='hello', got %s", v.String())
}
}
func TestExecPrintCapture(t *testing.T) {
sb := New(DefaultConfig())
res, err := sb.Exec(context.Background(), "test.star", `
print("line one")
print("line two")
`, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
expected := "line one\nline two\n"
if res.Output != expected {
t.Fatalf("expected output %q, got %q", expected, res.Output)
}
}
func TestExecStepsTracked(t *testing.T) {
sb := New(DefaultConfig())
res, err := sb.Exec(context.Background(), "test.star", `
def work():
x = 0
for i in range(100):
x += i
return x
result = work()
`, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res.Steps == 0 {
t.Fatal("expected steps > 0")
}
}
func TestExecStepLimitExceeded(t *testing.T) {
sb := New(Config{MaxSteps: 100, Name: "limited"})
_, err := sb.Exec(context.Background(), "test.star", `
def work():
x = 0
for i in range(10000):
x += i
return x
result = work()
`, nil)
if err == nil {
t.Fatal("expected step limit error")
}
// Error message varies: "Starlark computation cancelled: exceeded ..."
// or "context canceled" — just verify it failed.
}
func TestExecContextTimeout(t *testing.T) {
// Use step limit of 0 (unlimited) so only context timeout triggers
sb := New(Config{MaxSteps: 0, Name: "timeout"})
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
_, err := sb.Exec(ctx, "test.star", `
def work():
x = 0
for i in range(100000000):
x += i
return x
result = work()
`, nil)
if err == nil {
t.Fatal("expected timeout error")
}
}
func TestExecLoadDisabled(t *testing.T) {
sb := New(DefaultConfig())
_, err := sb.Exec(context.Background(), "test.star", `
load("other.star", "foo")
`, nil)
if err == nil {
t.Fatal("expected load error")
}
if !strings.Contains(err.Error(), "disabled") {
t.Fatalf("expected 'disabled' in error, got: %v", err)
}
}
func TestExecModuleInjection(t *testing.T) {
getFn := starlark.NewBuiltin("test.get", func(
thread *starlark.Thread, b *starlark.Builtin,
args starlark.Tuple, kwargs []starlark.Tuple,
) (starlark.Value, error) {
return starlark.String("secret_value"), nil
})
mod := MakeModule("test", starlark.StringDict{
"get": getFn,
})
sb := New(DefaultConfig())
res, err := sb.Exec(context.Background(), "test.star", `
result = test.get()
`, map[string]starlark.Value{"test": mod})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
v, ok := res.Globals["result"]
if !ok {
t.Fatal("missing global 'result'")
}
if v.(starlark.String).GoString() != "secret_value" {
t.Fatalf("expected 'secret_value', got %s", v.String())
}
}
func TestExecSyntaxError(t *testing.T) {
sb := New(DefaultConfig())
_, err := sb.Exec(context.Background(), "bad.star", `
def broken(
`, nil)
if err == nil {
t.Fatal("expected syntax error")
}
se, ok := err.(*SandboxError)
if !ok {
t.Fatalf("expected *SandboxError, got %T", err)
}
_ = se // syntax errors may not have backtrace
}
func TestExecRuntimeError(t *testing.T) {
sb := New(DefaultConfig())
_, err := sb.Exec(context.Background(), "runtime.star", `
x = 1 / 0
`, nil)
if err == nil {
t.Fatal("expected runtime error")
}
se, ok := err.(*SandboxError)
if !ok {
t.Fatalf("expected *SandboxError, got %T", err)
}
if se.Backtrace == "" {
t.Fatal("expected backtrace for runtime error")
}
}
func TestCallFunction(t *testing.T) {
sb := New(DefaultConfig())
res, err := sb.Exec(context.Background(), "funcs.star", `
def greet(name):
return "hello, " + name
`, nil)
if err != nil {
t.Fatalf("exec error: %v", err)
}
fn := res.Globals["greet"].(starlark.Callable)
val, _, err := sb.Call(context.Background(), fn,
starlark.Tuple{starlark.String("world")}, nil)
if err != nil {
t.Fatalf("call error: %v", err)
}
if val.(starlark.String).GoString() != "hello, world" {
t.Fatalf("expected 'hello, world', got %s", val.String())
}
}
func TestCallWithPrint(t *testing.T) {
sb := New(DefaultConfig())
res, err := sb.Exec(context.Background(), "funcs.star", `
def run():
print("running")
return 42
`, nil)
if err != nil {
t.Fatalf("exec error: %v", err)
}
fn := res.Globals["run"].(starlark.Callable)
val, output, err := sb.Call(context.Background(), fn, nil, nil)
if err != nil {
t.Fatalf("call error: %v", err)
}
if val.String() != "42" {
t.Fatalf("expected 42, got %s", val.String())
}
if !strings.Contains(output, "running") {
t.Fatalf("expected 'running' in output, got %q", output)
}
}
func TestCallRuntimeError(t *testing.T) {
sb := New(DefaultConfig())
res, err := sb.Exec(context.Background(), "funcs.star", `
def boom():
return 1 / 0
`, nil)
if err != nil {
t.Fatalf("exec error: %v", err)
}
fn := res.Globals["boom"].(starlark.Callable)
_, _, err = sb.Call(context.Background(), fn, nil, nil)
if err == nil {
t.Fatal("expected runtime error from Call")
}
se, ok := err.(*SandboxError)
if !ok {
t.Fatalf("expected *SandboxError, got %T", err)
}
if se.Backtrace == "" {
t.Fatal("expected backtrace")
}
}
func TestNewDefaults(t *testing.T) {
sb := New(Config{})
if sb.config.MaxSteps != 0 {
t.Fatalf("expected MaxSteps=0 (unlimited), got %d", sb.config.MaxSteps)
}
if sb.config.Name != "extension" {
t.Fatalf("expected default Name='extension', got %q", sb.config.Name)
}
}
func TestNewWithDefaultConfig(t *testing.T) {
sb := New(DefaultConfig())
if sb.config.MaxSteps != 1_000_000 {
t.Fatalf("expected MaxSteps=1000000, got %d", sb.config.MaxSteps)
}
}
func TestMakeModule(t *testing.T) {
mod := MakeModule("mymod", starlark.StringDict{
"version": starlark.String("1.0"),
})
if mod.Name != "mymod" {
t.Fatalf("expected name 'mymod', got %q", mod.Name)
}
v, ok := mod.Members["version"]
if !ok {
t.Fatal("missing 'version' member")
}
if v.(starlark.String).GoString() != "1.0" {
t.Fatalf("expected '1.0', got %s", v.String())
}
}
func TestSandboxErrorUnwrap(t *testing.T) {
inner := starlark.String("test").Truth() // just need any value
_ = inner
se := &SandboxError{
Err: context.DeadlineExceeded,
Backtrace: "",
}
if se.Unwrap() != context.DeadlineExceeded {
t.Fatal("Unwrap should return inner error")
}
if se.Error() != "context deadline exceeded" {
t.Fatalf("unexpected error string: %q", se.Error())
}
}
func TestSandboxErrorWithBacktrace(t *testing.T) {
se := &SandboxError{
Err: context.DeadlineExceeded,
Backtrace: " funcs.star:3:5: in boom",
}
if !strings.Contains(se.Error(), "context deadline exceeded") {
t.Fatal("should contain original error")
}
if !strings.Contains(se.Error(), "funcs.star:3:5") {
t.Fatal("should contain backtrace")
}
}