Changeset 0.29.1 (#196)
This commit is contained in:
62
CHANGELOG.md
62
CHANGELOG.md
@@ -1,5 +1,67 @@
|
||||
# Changelog
|
||||
|
||||
## [0.29.1] — 2026-03-17
|
||||
|
||||
### Summary
|
||||
|
||||
API extensions. Starlark packages serve custom JSON endpoints via
|
||||
`api_routes` manifest key, with SSRF-safe outbound HTTP, LLM provider
|
||||
access via BYOK chain, capability-based routing, and config-file
|
||||
provider types. Five changesets (CS0–CS4).
|
||||
|
||||
### New
|
||||
|
||||
- **Extension API routes** (`/s/:slug/api/*path`) — Starlark packages
|
||||
declare routes in `manifest.api_routes` and implement `on_request(req)`
|
||||
entry point. Supports exact and prefix path matching, wildcard methods,
|
||||
and boolean shorthand. JWT-authenticated.
|
||||
- **HTTP outbound module** (`http`) — `http.get/post/put/delete/request`
|
||||
builtins with SSRF protection (private/loopback IP blocking via
|
||||
`net.Dialer.Control`), manifest-based allowlist/blocklist
|
||||
(`network_access.allow/block`), redirect chain validation, 1 MB
|
||||
response cap, 10 s timeout. Requires `api.http` permission.
|
||||
- **Provider module** (`provider`) — `provider.complete(messages, model?,
|
||||
max_tokens?, temperature?)` makes synchronous LLM calls via BYOK chain.
|
||||
`requires_provider` manifest key with optional `provider_config_id` pin
|
||||
and `model` default. Requires `provider.complete` permission.
|
||||
- **`RunContext`** — per-invocation state (user_id, channel_id) passed
|
||||
through `CallEntryPoint` for user-scoped modules. All callers updated:
|
||||
API routes pass JWT user, task executor passes owner, filters pass nil.
|
||||
- **`ProviderResolver` interface** — decouples sandbox from handlers
|
||||
package (avoids circular dependency). `ProviderResolverAdapter` in
|
||||
handlers bridges to `ResolveProviderConfig` + provider registry.
|
||||
- **`capability_match` routing policy** — filters candidates by model
|
||||
capabilities (`required_caps: ["tool_calling", "vision"]`) with
|
||||
optional `prefer_cheapest` sort by output price. Forward-compatible
|
||||
with unknown capability names.
|
||||
- **Config-file provider types** — `PROVIDER_TYPES_FILE` env var points
|
||||
to JSON array of custom provider type definitions. Registers
|
||||
OpenAI-compatible endpoints (Ollama, LiteLLM, vLLM) at startup
|
||||
without code deploy. Built-in IDs are protected from override.
|
||||
- **`provider.complete` permission** — new extension permission constant
|
||||
for LLM completion access from Starlark scripts.
|
||||
|
||||
### Changed
|
||||
|
||||
- `Runner.ExecPackage` and `Runner.CallEntryPoint` signatures gain
|
||||
`*RunContext` parameter. All callers updated (breaking internal API,
|
||||
no external impact).
|
||||
- `Runner.buildModules` receives manifest for module-specific config
|
||||
(http reads `network_access`, provider reads `requires_provider`).
|
||||
- Routing policy admin handler validation switch includes
|
||||
`capability_match`.
|
||||
- `PolicyConfig` gains `RequiredCaps` and `PreferCheapest` fields.
|
||||
- Routing `Context` gains `Capabilities` map for capability-match
|
||||
evaluation.
|
||||
|
||||
### Deferred
|
||||
|
||||
- **Server-side tool execution in completion handler** → v0.29.2.
|
||||
`provider.complete()` covers bare completions; full tool loop
|
||||
(extensions defining tools that execute server-side during chat
|
||||
completions) requires tool registry integration with the sandbox,
|
||||
which aligns with the DB extensions scope.
|
||||
|
||||
## [0.29.0] — 2026-03-17
|
||||
|
||||
### Summary
|
||||
|
||||
@@ -254,6 +254,21 @@ Permissions are granted via groups. The `Everyone` group
|
||||
permissions to all authenticated users. It is system-seeded
|
||||
and editable by admins.
|
||||
|
||||
## Extension Permissions (v0.29.0+)
|
||||
|
||||
7 extension permission constants, `domain.action` convention.
|
||||
Declared in package manifests, granted by admin review.
|
||||
|
||||
| Permission | Module | Since |
|
||||
|-----------|--------|-------|
|
||||
| `secrets.read` | `secrets` | v0.29.0 |
|
||||
| `notifications.send` | `notifications` | v0.29.0 |
|
||||
| `filters.pre_completion` | — | v0.29.0 |
|
||||
| `api.http` | `http` | v0.29.0 |
|
||||
| `provider.complete` | `provider` | v0.29.1 |
|
||||
| `db.read` | `db` | future |
|
||||
| `db.write` | `db` | future |
|
||||
|
||||
## Policies
|
||||
|
||||
| Key | Default | Description |
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
Plugin system with three tiers: Browser JS (client-side), Starlark
|
||||
sandbox (server-side, v0.29.0), Sidecar containers (server-side, future).
|
||||
Currently only Browser tier is implemented.
|
||||
|
||||
### User Extensions
|
||||
|
||||
@@ -26,6 +25,74 @@ GET /extensions/tools → {"data": [...tool schema objects]}
|
||||
- `GET /extensions/tools` returns raw tool schema JSON from all enabled
|
||||
browser extensions' `manifest.tools[]` arrays.
|
||||
|
||||
### Extension API Routes (v0.29.1)
|
||||
|
||||
Starlark packages serve custom JSON endpoints. Mounted at
|
||||
`/s/:slug/api/*path` with JWT authentication.
|
||||
|
||||
**Auth:** Authenticated user (JWT — returns 401, not redirect)
|
||||
|
||||
```
|
||||
ANY /s/:slug/api/*path → Starlark on_request(req) response
|
||||
```
|
||||
|
||||
**Route:** `slug` is the package ID. `path` is matched against the
|
||||
manifest's `api_routes` declaration.
|
||||
|
||||
**Manifest `api_routes` field:**
|
||||
|
||||
```json
|
||||
{
|
||||
"api_routes": [
|
||||
{"method": "GET", "path": "/status"},
|
||||
{"method": "POST", "path": "/webhook"},
|
||||
{"method": "*", "path": "/proxy/*"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `method`: exact match (case-insensitive), or `"*"` for any method
|
||||
- `path`: exact match, or trailing `"*"` for prefix match
|
||||
- Boolean `true` shorthand: all routes forwarded
|
||||
- Missing or `false`: no routes served
|
||||
|
||||
**Request dict passed to `on_request(req)`:**
|
||||
|
||||
```python
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/webhook",
|
||||
"headers": {"content-type": "application/json", ...},
|
||||
"query": {"page": "1", ...},
|
||||
"body": "{...}",
|
||||
"user_id": "uuid"
|
||||
}
|
||||
```
|
||||
|
||||
**Expected return dict:**
|
||||
|
||||
```python
|
||||
{"status": 200, "headers": {"X-Custom": "val"}, "body": "{...}"}
|
||||
```
|
||||
|
||||
- `status`: int (default 200). Return `None` for 204 No Content.
|
||||
- `headers`: dict (optional). Content-Type auto-detected if not set.
|
||||
- `body`: string (default "").
|
||||
|
||||
**Validation pipeline:**
|
||||
1. Package exists and is enabled
|
||||
2. Status is `active` (not `pending_review` or `suspended`)
|
||||
3. Tier is `starlark`
|
||||
4. `api.http` permission is granted
|
||||
5. Method+path matches `api_routes` in manifest
|
||||
|
||||
**Errors:**
|
||||
- `401` — no/invalid JWT
|
||||
- `403` — package suspended or missing `api.http` permission
|
||||
- `404` — package not found, disabled, or route not declared
|
||||
- `400` — package is not starlark tier
|
||||
- `500` — Starlark execution error
|
||||
|
||||
### Admin Extension Management
|
||||
|
||||
**Auth:** Admin role
|
||||
@@ -64,6 +131,46 @@ Returns the inline `_script` field from the extension's manifest.
|
||||
The `*path` segment is accepted but currently ignored (all requests
|
||||
return the same script). Disabled extensions return 404.
|
||||
|
||||
### Extension Permissions (v0.29.0+)
|
||||
|
||||
Starlark packages declare capabilities in `manifest.permissions`.
|
||||
Admin must grant each before the package activates.
|
||||
|
||||
| Permission | Module | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `secrets.read` | `secrets` | Read extension secrets via GlobalConfig |
|
||||
| `notifications.send` | `notifications` | Send in-app notifications |
|
||||
| `filters.pre_completion` | — | Register pre-completion filter |
|
||||
| `api.http` | `http` | Outbound HTTP requests (v0.29.0: module, v0.29.1: also required for API routes) |
|
||||
| `provider.complete` | `provider` | LLM completion calls via BYOK chain (v0.29.1) |
|
||||
| `db.read` | `db` | Read extension tables (future) |
|
||||
| `db.write` | `db` | Write extension tables (future) |
|
||||
|
||||
### Starlark Modules (v0.29.0+)
|
||||
|
||||
Modules injected into the script namespace based on granted permissions:
|
||||
|
||||
**`secrets`** (requires `secrets.read`):
|
||||
- `secrets.get(key)` → string or None
|
||||
- `secrets.list()` → list of key names
|
||||
|
||||
**`notifications`** (requires `notifications.send`):
|
||||
- `notifications.send(user_id, title, body?, type?)` → True
|
||||
|
||||
**`http`** (requires `api.http`, v0.29.1):
|
||||
- `http.get(url, headers?)` → `{"status": int, "headers": dict, "body": string}`
|
||||
- `http.post(url, body?, headers?)` → response dict
|
||||
- `http.put(url, body?, headers?)` → response dict
|
||||
- `http.delete(url, headers?)` → response dict
|
||||
- `http.request(method, url, body?, headers?)` → response dict
|
||||
- SSRF protection: private/loopback/link-local IPs blocked after DNS
|
||||
- Manifest `network_access`: `{"allow": [...]}` or `{"block": [...]}`
|
||||
|
||||
**`provider`** (requires `provider.complete`, v0.29.1):
|
||||
- `provider.complete(messages, model?, max_tokens?, temperature?)` → response dict
|
||||
- Response: `{"content", "model", "finish_reason", "input_tokens", "output_tokens"}`
|
||||
- Provider resolved via BYOK chain; pinnable via `requires_provider.provider_config_id`
|
||||
|
||||
### Builtin Seeding
|
||||
|
||||
On startup, `SeedBuiltinExtensions()` scans `extensions/builtin/`
|
||||
|
||||
@@ -31,7 +31,7 @@ v0.9.x–v0.28.7 Foundation through Platform Polish ✅
|
||||
Extension Track Operations Track
|
||||
│ │
|
||||
v0.29.0 Starlark Sandbox ✅ v0.32.0 Multi-Replica HA
|
||||
v0.29.1 API Extensions v0.33.0 Observability
|
||||
v0.29.1 API Extensions ✅ v0.33.0 Observability
|
||||
v0.29.2 DB Extensions v0.34.0 Data Portability
|
||||
v0.29.3 Workflow Forms │
|
||||
v0.30.0 Package Lifecycle │
|
||||
@@ -171,19 +171,22 @@ Depends on: v0.28.8.
|
||||
- [x] ICD runner: `packaging` test tier — 18 tests covering
|
||||
permission lifecycle + secrets CRUD (CS5)
|
||||
|
||||
### v0.29.1 — API Extensions
|
||||
### v0.29.1 — API Extensions ✅
|
||||
|
||||
Starlark route handlers. Surfaces serve custom JSON endpoints.
|
||||
|
||||
Depends on: v0.29.0.
|
||||
|
||||
- [ ] `api_routes` manifest key, mounted at `/s/{id}/api/...`
|
||||
- [ ] Starlark request/response primitives
|
||||
- [ ] `http` outbound module with allowlist/blocklist
|
||||
- [ ] `requires_provider` manifest key (provider resolution via BYOK chain)
|
||||
- [ ] Server-side tool execution in completion handler
|
||||
- [ ] `capability_match` routing policy (cheapest model with required caps)
|
||||
- [ ] Config-file provider types (YAML/JSON, no code deploy)
|
||||
- [x] `api_routes` manifest key, mounted at `/s/{id}/api/...`
|
||||
- [x] Starlark request/response primitives
|
||||
- [x] `http` outbound module with allowlist/blocklist
|
||||
- [x] `requires_provider` manifest key (provider resolution via BYOK chain)
|
||||
- [x] `capability_match` routing policy (cheapest model with required caps)
|
||||
- [x] Config-file provider types (JSON, no code deploy)
|
||||
|
||||
**Deferred to v0.29.2:**
|
||||
- Server-side tool execution in completion handler (requires tool
|
||||
registry integration with sandbox; aligns with DB extensions scope)
|
||||
|
||||
### v0.29.2 — DB Extensions
|
||||
|
||||
@@ -195,6 +198,7 @@ Depends on: v0.29.1.
|
||||
- [ ] `db` Starlark module: `query()`, `exec()` scoped to extension tables
|
||||
- [ ] Views as read contract over platform tables (column allowlist)
|
||||
- [ ] Schema creation on install, drop on uninstall
|
||||
- [ ] Server-side tool execution in completion handler (deferred from v0.29.1)
|
||||
|
||||
### v0.29.3 — Workflow Forms
|
||||
|
||||
|
||||
@@ -68,6 +68,11 @@ type Config struct {
|
||||
// provider is automatically deactivated. Default 3. Set to 0 to disable.
|
||||
ProviderAutoDisableThreshold int
|
||||
|
||||
// Config-file provider types (v0.29.1)
|
||||
// PROVIDER_TYPES_FILE: path to JSON file defining custom provider types
|
||||
// (e.g., Ollama, LiteLLM, vLLM). Empty = no custom types.
|
||||
ProviderTypesFile string
|
||||
|
||||
// SESSION_EXPIRY_DAYS: anonymous sessions older than this with no messages
|
||||
// are cleaned up by the background session sweeper. Default 30. Set to 0
|
||||
// to disable cleanup.
|
||||
@@ -140,6 +145,8 @@ func Load() *Config {
|
||||
|
||||
ProviderAutoDisableThreshold: getEnvInt("PROVIDER_AUTO_DISABLE_THRESHOLD", 3),
|
||||
|
||||
ProviderTypesFile: getEnv("PROVIDER_TYPES_FILE", ""),
|
||||
|
||||
SessionExpiryDays: getEnvInt("SESSION_EXPIRY_DAYS", 30),
|
||||
|
||||
WorkflowStaleHours: getEnvInt("WORKFLOW_STALE_HOURS", 72),
|
||||
|
||||
@@ -56,7 +56,7 @@ func (f *StarlarkFilter) Execute(ctx context.Context, cc *CompletionContext) (*C
|
||||
ctxDict.SetKey(starlark.String("last_user_message"), starlark.String(cc.LastUserMessage))
|
||||
|
||||
val, output, err := f.runner.CallEntryPoint(ctx, f.pkg, "on_pre_completion",
|
||||
starlark.Tuple{ctxDict}, nil)
|
||||
starlark.Tuple{ctxDict}, nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ext:%s: %w", f.pkg.ID, err)
|
||||
}
|
||||
|
||||
344
server/handlers/ext_api.go
Normal file
344
server/handlers/ext_api.go
Normal file
@@ -0,0 +1,344 @@
|
||||
// Package handlers — ext_api.go
|
||||
//
|
||||
// v0.29.1 CS1: Extension API routes. Starlark packages serve custom
|
||||
// JSON endpoints via the existing Gin router. Mounted at
|
||||
// /s/:slug/api/*path with JWT auth.
|
||||
//
|
||||
// Manifest format:
|
||||
//
|
||||
// {
|
||||
// "api_routes": [
|
||||
// {"method": "GET", "path": "/status"},
|
||||
// {"method": "POST", "path": "/webhook"},
|
||||
// {"method": "*", "path": "/proxy/*"}
|
||||
// ]
|
||||
// }
|
||||
//
|
||||
// Route matching:
|
||||
// - method: exact match (case-insensitive), or "*" for any method
|
||||
// - path: exact match, or trailing "*" for prefix match
|
||||
// - If api_routes is boolean true, all routes are forwarded
|
||||
//
|
||||
// Starlark contract:
|
||||
//
|
||||
// def on_request(req):
|
||||
// # req = {"method": "GET", "path": "/status", "headers": {...},
|
||||
// # "query": {...}, "body": "", "user_id": "..."}
|
||||
// return {"status": 200, "body": '{"ok": true}'}
|
||||
//
|
||||
// Response dict fields:
|
||||
// - status: int (default 200)
|
||||
// - headers: dict (optional, string→string)
|
||||
// - body: string (default "")
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.starlark.net/starlark"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/sandbox"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// ExtAPIHandler serves extension API routes via Starlark.
|
||||
type ExtAPIHandler struct {
|
||||
stores store.Stores
|
||||
runner *sandbox.Runner
|
||||
}
|
||||
|
||||
// NewExtAPIHandler creates the extension API handler.
|
||||
func NewExtAPIHandler(stores store.Stores, runner *sandbox.Runner) *ExtAPIHandler {
|
||||
return &ExtAPIHandler{
|
||||
stores: stores,
|
||||
runner: runner,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle processes an incoming request to /s/:slug/api/*path.
|
||||
// It loads the target package, validates it, matches the route against
|
||||
// the manifest's api_routes, and dispatches to the Starlark on_request
|
||||
// entry point.
|
||||
func (h *ExtAPIHandler) Handle(c *gin.Context) {
|
||||
slug := c.Param("slug")
|
||||
rawPath := c.Param("path") // includes leading "/"
|
||||
|
||||
if slug == "" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "extension not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Gin *path captures include leading slash; normalize
|
||||
if rawPath == "" {
|
||||
rawPath = "/"
|
||||
}
|
||||
|
||||
// ── 1. Load package ────────────────────────
|
||||
pkg, err := h.stores.Packages.Get(c.Request.Context(), slug)
|
||||
if err != nil || pkg == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "extension not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// ── 2. Validate package state ──────────────
|
||||
if !pkg.Enabled {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "extension is disabled"})
|
||||
return
|
||||
}
|
||||
if pkg.Status != models.PackageStatusActive {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "extension is " + pkg.Status})
|
||||
return
|
||||
}
|
||||
if pkg.Tier != models.ExtTierStarlark {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "extension is not a starlark package"})
|
||||
return
|
||||
}
|
||||
|
||||
// ── 3. Check api.http permission ───────────
|
||||
if h.stores.ExtPermissions == nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "permission system unavailable"})
|
||||
return
|
||||
}
|
||||
granted, err := h.stores.ExtPermissions.GrantedForPackage(c.Request.Context(), pkg.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "permission check failed"})
|
||||
return
|
||||
}
|
||||
if !hasPermission(granted, models.ExtPermAPIHTTP) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "extension does not have api.http permission"})
|
||||
return
|
||||
}
|
||||
|
||||
// ── 4. Match route against manifest ────────
|
||||
if !matchAPIRoute(pkg.Manifest, c.Request.Method, rawPath) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "route not declared in extension manifest"})
|
||||
return
|
||||
}
|
||||
|
||||
// ── 5. Build request dict ──────────────────
|
||||
reqDict, err := buildRequestDict(c, rawPath)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read request: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// ── 6. Call on_request entry point ─────────
|
||||
rc := &sandbox.RunContext{UserID: getUserID(c)}
|
||||
val, output, err := h.runner.CallEntryPoint(
|
||||
c.Request.Context(), pkg, "on_request",
|
||||
starlark.Tuple{reqDict}, nil, rc,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ ext_api %s: on_request error: %v", pkg.ID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "extension error: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if output != "" {
|
||||
log.Printf(" 🔧 ext_api %s print: %s", pkg.ID, output)
|
||||
}
|
||||
|
||||
// ── 7. Parse and write response ────────────
|
||||
writeStarlarkResponse(c, val)
|
||||
}
|
||||
|
||||
// ─── Route Matching ─────────────────────────
|
||||
|
||||
// matchAPIRoute checks whether the incoming method+path is declared in
|
||||
// the package manifest's api_routes field.
|
||||
//
|
||||
// Formats accepted:
|
||||
// - boolean true: all routes match
|
||||
// - []any of route dicts: {"method": "GET", "path": "/status"}
|
||||
// method "*" matches any method; path trailing "*" matches prefix
|
||||
func matchAPIRoute(manifest map[string]any, method, path string) bool {
|
||||
raw, ok := manifest["api_routes"]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
// Boolean shorthand: true = all routes
|
||||
if b, ok := raw.(bool); ok {
|
||||
return b
|
||||
}
|
||||
|
||||
// Array of route declarations
|
||||
routes, ok := raw.([]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
method = strings.ToUpper(method)
|
||||
|
||||
for _, entry := range routes {
|
||||
route, ok := entry.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Match method
|
||||
rm, _ := route["method"].(string)
|
||||
rm = strings.ToUpper(rm)
|
||||
if rm != "*" && rm != method {
|
||||
continue
|
||||
}
|
||||
|
||||
// Match path
|
||||
rp, _ := route["path"].(string)
|
||||
if rp == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasSuffix(rp, "*") {
|
||||
// Prefix match: "/proxy/*" matches "/proxy/anything"
|
||||
prefix := strings.TrimSuffix(rp, "*")
|
||||
if strings.HasPrefix(path, prefix) {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
// Exact match
|
||||
if path == rp {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// ─── Request Building ───────────────────────
|
||||
|
||||
// buildRequestDict creates a Starlark dict from the Gin context:
|
||||
//
|
||||
// {
|
||||
// "method": "GET",
|
||||
// "path": "/status",
|
||||
// "headers": {"content-type": "application/json", ...},
|
||||
// "query": {"page": "1", ...},
|
||||
// "body": "...",
|
||||
// "user_id": "uuid",
|
||||
// }
|
||||
func buildRequestDict(c *gin.Context, path string) (*starlark.Dict, error) {
|
||||
// Read body (capped at 1 MB for safety)
|
||||
body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading body: %w", err)
|
||||
}
|
||||
|
||||
// Headers dict (lowercase keys, first value)
|
||||
hdrs := starlark.NewDict(len(c.Request.Header))
|
||||
for k, vals := range c.Request.Header {
|
||||
if len(vals) > 0 {
|
||||
_ = hdrs.SetKey(starlark.String(strings.ToLower(k)), starlark.String(vals[0]))
|
||||
}
|
||||
}
|
||||
|
||||
// Query dict (first value per key)
|
||||
query := starlark.NewDict(len(c.Request.URL.Query()))
|
||||
for k, vals := range c.Request.URL.Query() {
|
||||
if len(vals) > 0 {
|
||||
_ = query.SetKey(starlark.String(k), starlark.String(vals[0]))
|
||||
}
|
||||
}
|
||||
|
||||
d := starlark.NewDict(6)
|
||||
_ = d.SetKey(starlark.String("method"), starlark.String(c.Request.Method))
|
||||
_ = d.SetKey(starlark.String("path"), starlark.String(path))
|
||||
_ = d.SetKey(starlark.String("headers"), hdrs)
|
||||
_ = d.SetKey(starlark.String("query"), query)
|
||||
_ = d.SetKey(starlark.String("body"), starlark.String(string(body)))
|
||||
_ = d.SetKey(starlark.String("user_id"), starlark.String(getUserID(c)))
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// ─── Response Writing ───────────────────────
|
||||
|
||||
// writeStarlarkResponse parses the Starlark return value and writes
|
||||
// the HTTP response. Expected: dict with "status", "headers", "body".
|
||||
// Missing fields use defaults (200, no extra headers, empty body).
|
||||
func writeStarlarkResponse(c *gin.Context, val starlark.Value) {
|
||||
if val == nil || val == starlark.None {
|
||||
c.Status(http.StatusNoContent)
|
||||
c.Writer.WriteHeaderNow()
|
||||
return
|
||||
}
|
||||
|
||||
dict, ok := val.(*starlark.Dict)
|
||||
if !ok {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("on_request must return a dict, got %s", val.Type()),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Status code
|
||||
status := http.StatusOK
|
||||
if sv, found, _ := dict.Get(starlark.String("status")); found {
|
||||
if i, err := starlark.AsInt32(sv); err == nil {
|
||||
status = i
|
||||
}
|
||||
}
|
||||
|
||||
// Response headers
|
||||
if hv, found, _ := dict.Get(starlark.String("headers")); found {
|
||||
if hd, ok := hv.(*starlark.Dict); ok {
|
||||
for _, item := range hd.Items() {
|
||||
k, ok1 := starlark.AsString(item[0])
|
||||
v, ok2 := starlark.AsString(item[1])
|
||||
if ok1 && ok2 {
|
||||
c.Header(k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Body
|
||||
body := ""
|
||||
if bv, found, _ := dict.Get(starlark.String("body")); found {
|
||||
body, _ = starlark.AsString(bv)
|
||||
}
|
||||
|
||||
// Auto-detect content type if not set explicitly
|
||||
if c.Writer.Header().Get("Content-Type") == "" {
|
||||
if len(body) > 0 && (body[0] == '{' || body[0] == '[') {
|
||||
c.Header("Content-Type", "application/json")
|
||||
} else {
|
||||
c.Header("Content-Type", "text/plain; charset=utf-8")
|
||||
}
|
||||
}
|
||||
|
||||
c.String(status, body)
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────
|
||||
|
||||
func hasPermission(granted []string, perm string) bool {
|
||||
for _, p := range granted {
|
||||
if p == perm {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasAPIRoutes checks whether a package manifest declares API routes.
|
||||
// Used by startup discovery and admin UI to identify API-capable packages.
|
||||
func HasAPIRoutes(manifest map[string]any) bool {
|
||||
raw, ok := manifest["api_routes"]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if b, ok := raw.(bool); ok {
|
||||
return b
|
||||
}
|
||||
if routes, ok := raw.([]any); ok {
|
||||
return len(routes) > 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
386
server/handlers/ext_api_test.go
Normal file
386
server/handlers/ext_api_test.go
Normal file
@@ -0,0 +1,386 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.starlark.net/starlark"
|
||||
)
|
||||
|
||||
// ─── matchAPIRoute ──────────────────────────
|
||||
|
||||
func TestMatchAPIRoute_BooleanTrue(t *testing.T) {
|
||||
m := map[string]any{"api_routes": true}
|
||||
if !matchAPIRoute(m, "GET", "/anything") {
|
||||
t.Error("boolean true should match any route")
|
||||
}
|
||||
if !matchAPIRoute(m, "POST", "/foo/bar") {
|
||||
t.Error("boolean true should match any route")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchAPIRoute_BooleanFalse(t *testing.T) {
|
||||
m := map[string]any{"api_routes": false}
|
||||
if matchAPIRoute(m, "GET", "/anything") {
|
||||
t.Error("boolean false should not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchAPIRoute_Missing(t *testing.T) {
|
||||
m := map[string]any{}
|
||||
if matchAPIRoute(m, "GET", "/anything") {
|
||||
t.Error("missing api_routes should not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchAPIRoute_ExactMatch(t *testing.T) {
|
||||
m := map[string]any{
|
||||
"api_routes": []any{
|
||||
map[string]any{"method": "GET", "path": "/status"},
|
||||
map[string]any{"method": "POST", "path": "/webhook"},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
method, path string
|
||||
want bool
|
||||
}{
|
||||
{"GET", "/status", true},
|
||||
{"POST", "/webhook", true},
|
||||
{"GET", "/webhook", false}, // wrong method
|
||||
{"POST", "/status", false}, // wrong method
|
||||
{"GET", "/unknown", false}, // undeclared path
|
||||
{"GET", "/status/", false}, // trailing slash mismatch
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := matchAPIRoute(m, tt.method, tt.path)
|
||||
if got != tt.want {
|
||||
t.Errorf("matchAPIRoute(%s %s) = %v, want %v", tt.method, tt.path, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchAPIRoute_WildcardMethod(t *testing.T) {
|
||||
m := map[string]any{
|
||||
"api_routes": []any{
|
||||
map[string]any{"method": "*", "path": "/anything"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, method := range []string{"GET", "POST", "PUT", "DELETE", "PATCH"} {
|
||||
if !matchAPIRoute(m, method, "/anything") {
|
||||
t.Errorf("wildcard method should match %s", method)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchAPIRoute_PrefixMatch(t *testing.T) {
|
||||
m := map[string]any{
|
||||
"api_routes": []any{
|
||||
map[string]any{"method": "*", "path": "/proxy/*"},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{"/proxy/", true},
|
||||
{"/proxy/foo", true},
|
||||
{"/proxy/foo/bar/baz", true},
|
||||
{"/prox", false}, // not the prefix
|
||||
{"/other", false}, // different path
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := matchAPIRoute(m, "GET", tt.path)
|
||||
if got != tt.want {
|
||||
t.Errorf("matchAPIRoute(GET %s) = %v, want %v", tt.path, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchAPIRoute_CaseInsensitiveMethod(t *testing.T) {
|
||||
m := map[string]any{
|
||||
"api_routes": []any{
|
||||
map[string]any{"method": "get", "path": "/status"},
|
||||
},
|
||||
}
|
||||
if !matchAPIRoute(m, "GET", "/status") {
|
||||
t.Error("method matching should be case-insensitive")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchAPIRoute_EmptyArray(t *testing.T) {
|
||||
m := map[string]any{"api_routes": []any{}}
|
||||
if matchAPIRoute(m, "GET", "/anything") {
|
||||
t.Error("empty array should not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchAPIRoute_MalformedEntries(t *testing.T) {
|
||||
m := map[string]any{
|
||||
"api_routes": []any{
|
||||
"not a map",
|
||||
42,
|
||||
map[string]any{"method": "GET"}, // missing path
|
||||
map[string]any{"method": "GET", "path": "/ok"},
|
||||
},
|
||||
}
|
||||
// Malformed entries skipped, valid one matches
|
||||
if !matchAPIRoute(m, "GET", "/ok") {
|
||||
t.Error("should skip malformed entries and match valid one")
|
||||
}
|
||||
if matchAPIRoute(m, "GET", "/not-declared") {
|
||||
t.Error("undeclared route should not match")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── HasAPIRoutes ───────────────────────────
|
||||
|
||||
func TestHasAPIRoutes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
manifest map[string]any
|
||||
want bool
|
||||
}{
|
||||
{"empty manifest", map[string]any{}, false},
|
||||
{"boolean true", map[string]any{"api_routes": true}, true},
|
||||
{"boolean false", map[string]any{"api_routes": false}, false},
|
||||
{"non-empty array", map[string]any{"api_routes": []any{
|
||||
map[string]any{"method": "GET", "path": "/x"},
|
||||
}}, true},
|
||||
{"empty array", map[string]any{"api_routes": []any{}}, false},
|
||||
{"wrong type", map[string]any{"api_routes": "yes"}, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := HasAPIRoutes(tt.manifest)
|
||||
if got != tt.want {
|
||||
t.Errorf("HasAPIRoutes() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ─── writeStarlarkResponse ──────────────────
|
||||
|
||||
func TestWriteStarlarkResponse_FullDict(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
hdrs := starlark.NewDict(1)
|
||||
_ = hdrs.SetKey(starlark.String("X-Custom"), starlark.String("hello"))
|
||||
|
||||
resp := starlark.NewDict(3)
|
||||
_ = resp.SetKey(starlark.String("status"), starlark.MakeInt(201))
|
||||
_ = resp.SetKey(starlark.String("headers"), hdrs)
|
||||
_ = resp.SetKey(starlark.String("body"), starlark.String(`{"created":true}`))
|
||||
|
||||
writeStarlarkResponse(c, resp)
|
||||
|
||||
if w.Code != 201 {
|
||||
t.Errorf("status = %d, want 201", w.Code)
|
||||
}
|
||||
if w.Header().Get("X-Custom") != "hello" {
|
||||
t.Errorf("X-Custom = %q, want %q", w.Header().Get("X-Custom"), "hello")
|
||||
}
|
||||
if w.Header().Get("Content-Type") != "application/json" {
|
||||
t.Errorf("Content-Type = %q, want application/json", w.Header().Get("Content-Type"))
|
||||
}
|
||||
if w.Body.String() != `{"created":true}` {
|
||||
t.Errorf("body = %q", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStarlarkResponse_DefaultStatus(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
resp := starlark.NewDict(1)
|
||||
_ = resp.SetKey(starlark.String("body"), starlark.String("ok"))
|
||||
|
||||
writeStarlarkResponse(c, resp)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Errorf("status = %d, want 200 (default)", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStarlarkResponse_None(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
writeStarlarkResponse(c, starlark.None)
|
||||
|
||||
if w.Code != 204 {
|
||||
t.Errorf("status = %d, want 204 for None", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStarlarkResponse_Nil(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
writeStarlarkResponse(c, nil)
|
||||
|
||||
if w.Code != 204 {
|
||||
t.Errorf("status = %d, want 204 for nil", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStarlarkResponse_NotDict(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
writeStarlarkResponse(c, starlark.String("oops"))
|
||||
|
||||
if w.Code != 500 {
|
||||
t.Errorf("status = %d, want 500 for non-dict", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "must return a dict") {
|
||||
t.Errorf("body = %q, expected error about dict", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStarlarkResponse_PlainText(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
resp := starlark.NewDict(1)
|
||||
_ = resp.SetKey(starlark.String("body"), starlark.String("plain text"))
|
||||
|
||||
writeStarlarkResponse(c, resp)
|
||||
|
||||
if w.Header().Get("Content-Type") != "text/plain; charset=utf-8" {
|
||||
t.Errorf("Content-Type = %q, want text/plain", w.Header().Get("Content-Type"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStarlarkResponse_ExplicitContentType(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
hdrs := starlark.NewDict(1)
|
||||
_ = hdrs.SetKey(starlark.String("Content-Type"), starlark.String("text/xml"))
|
||||
|
||||
resp := starlark.NewDict(2)
|
||||
_ = resp.SetKey(starlark.String("headers"), hdrs)
|
||||
_ = resp.SetKey(starlark.String("body"), starlark.String(`<ok/>`))
|
||||
|
||||
writeStarlarkResponse(c, resp)
|
||||
|
||||
if w.Header().Get("Content-Type") != "text/xml" {
|
||||
t.Errorf("Content-Type = %q, want text/xml (explicit)", w.Header().Get("Content-Type"))
|
||||
}
|
||||
}
|
||||
|
||||
// ─── buildRequestDict ───────────────────────
|
||||
|
||||
func TestBuildRequestDict(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
c.Request = httptest.NewRequest("POST", "/s/my-ext/api/data?page=2&limit=10", strings.NewReader(`{"input":"test"}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Request.Header.Set("X-Token", "abc")
|
||||
c.Set("user_id", "user-uuid-123")
|
||||
|
||||
d, err := buildRequestDict(c, "/data")
|
||||
if err != nil {
|
||||
t.Fatalf("buildRequestDict: %v", err)
|
||||
}
|
||||
|
||||
// Method
|
||||
mv, _, _ := d.Get(starlark.String("method"))
|
||||
if s, _ := starlark.AsString(mv); s != "POST" {
|
||||
t.Errorf("method = %q, want POST", s)
|
||||
}
|
||||
|
||||
// Path
|
||||
pv, _, _ := d.Get(starlark.String("path"))
|
||||
if s, _ := starlark.AsString(pv); s != "/data" {
|
||||
t.Errorf("path = %q, want /data", s)
|
||||
}
|
||||
|
||||
// Body
|
||||
bv, _, _ := d.Get(starlark.String("body"))
|
||||
if s, _ := starlark.AsString(bv); s != `{"input":"test"}` {
|
||||
t.Errorf("body = %q", s)
|
||||
}
|
||||
|
||||
// User ID
|
||||
uv, _, _ := d.Get(starlark.String("user_id"))
|
||||
if s, _ := starlark.AsString(uv); s != "user-uuid-123" {
|
||||
t.Errorf("user_id = %q", s)
|
||||
}
|
||||
|
||||
// Headers
|
||||
hv, _, _ := d.Get(starlark.String("headers"))
|
||||
hd := hv.(*starlark.Dict)
|
||||
ctv, found, _ := hd.Get(starlark.String("content-type"))
|
||||
if !found {
|
||||
t.Fatal("missing content-type header")
|
||||
}
|
||||
if s, _ := starlark.AsString(ctv); s != "application/json" {
|
||||
t.Errorf("content-type = %q", s)
|
||||
}
|
||||
|
||||
// Query
|
||||
qv, _, _ := d.Get(starlark.String("query"))
|
||||
qd := qv.(*starlark.Dict)
|
||||
pageV, found, _ := qd.Get(starlark.String("page"))
|
||||
if !found {
|
||||
t.Fatal("missing page query param")
|
||||
}
|
||||
if s, _ := starlark.AsString(pageV); s != "2" {
|
||||
t.Errorf("query page = %q, want 2", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRequestDict_EmptyBody(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
c.Request = httptest.NewRequest("GET", "/s/ext/api/health", nil)
|
||||
|
||||
d, err := buildRequestDict(c, "/health")
|
||||
if err != nil {
|
||||
t.Fatalf("buildRequestDict: %v", err)
|
||||
}
|
||||
|
||||
bv, _, _ := d.Get(starlark.String("body"))
|
||||
if s, _ := starlark.AsString(bv); s != "" {
|
||||
t.Errorf("body should be empty for GET, got %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── hasPermission ──────────────────────────
|
||||
|
||||
func TestHasPermission(t *testing.T) {
|
||||
granted := []string{"secrets.read", "api.http", "notifications.send"}
|
||||
|
||||
if !hasPermission(granted, "api.http") {
|
||||
t.Error("should find api.http")
|
||||
}
|
||||
if hasPermission(granted, "db.read") {
|
||||
t.Error("should not find db.read")
|
||||
}
|
||||
if hasPermission(nil, "api.http") {
|
||||
t.Error("nil slice should return false")
|
||||
}
|
||||
if hasPermission([]string{}, "api.http") {
|
||||
t.Error("empty slice should return false")
|
||||
}
|
||||
}
|
||||
51
server/handlers/provider_resolver_adapter.go
Normal file
51
server/handlers/provider_resolver_adapter.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// Package handlers — provider_resolver_adapter.go
|
||||
//
|
||||
// v0.29.1 CS2: Adapter that satisfies sandbox.ProviderResolver using
|
||||
// ResolveProviderConfig + providers.Get. Avoids circular dependency
|
||||
// (sandbox → handlers → sandbox) by having handlers implement the
|
||||
// sandbox-defined interface.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/sandbox"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// ProviderResolverAdapter implements sandbox.ProviderResolver using
|
||||
// the existing ResolveProviderConfig function and provider registry.
|
||||
type ProviderResolverAdapter struct {
|
||||
stores store.Stores
|
||||
vault *crypto.KeyResolver
|
||||
}
|
||||
|
||||
// NewProviderResolverAdapter creates an adapter for Starlark provider resolution.
|
||||
func NewProviderResolverAdapter(stores store.Stores, vault *crypto.KeyResolver) *ProviderResolverAdapter {
|
||||
return &ProviderResolverAdapter{stores: stores, vault: vault}
|
||||
}
|
||||
|
||||
// Resolve satisfies sandbox.ProviderResolver. It resolves the provider
|
||||
// config via the BYOK chain and returns the provider instance + config.
|
||||
func (a *ProviderResolverAdapter) Resolve(ctx context.Context, userID, channelID, providerConfigID, model string) (*sandbox.ProviderResolution, error) {
|
||||
res, err := ResolveProviderConfig(a.stores, a.vault, userID, channelID, providerConfigID, model)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
provider, err := providers.Get(res.ProviderID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("provider unavailable: %w", err)
|
||||
}
|
||||
|
||||
return &sandbox.ProviderResolution{
|
||||
Provider: provider,
|
||||
Config: res.Config,
|
||||
ProviderID: res.ProviderID,
|
||||
Model: res.Model,
|
||||
ConfigID: res.ConfigID,
|
||||
}, nil
|
||||
}
|
||||
@@ -78,7 +78,8 @@ func (h *RoutingAdminHandler) CreatePolicy(c *gin.Context) {
|
||||
// Validate policy type
|
||||
switch routing.PolicyType(p.Type) {
|
||||
case routing.PolicyProviderPrefer, routing.PolicyTeamRoute,
|
||||
routing.PolicyCostLimit, routing.PolicyModelAlias:
|
||||
routing.PolicyCostLimit, routing.PolicyModelAlias,
|
||||
routing.PolicyCapabilityMatch:
|
||||
// valid
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid policy_type: " + p.Type})
|
||||
|
||||
@@ -61,6 +61,16 @@ func main() {
|
||||
// Register LLM providers
|
||||
providers.Init()
|
||||
|
||||
// v0.29.1: Config-file provider types (Ollama, LiteLLM, vLLM, etc.)
|
||||
if cfg.ProviderTypesFile != "" {
|
||||
n, err := providers.LoadCustomTypes(cfg.ProviderTypesFile)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Failed to load custom provider types from %s: %v", cfg.ProviderTypesFile, err)
|
||||
} else if n > 0 {
|
||||
log.Printf(" 📦 Loaded %d custom provider type(s) from %s", n, cfg.ProviderTypesFile)
|
||||
}
|
||||
}
|
||||
|
||||
var stores store.Stores
|
||||
uekCache := crypto.NewUEKCache()
|
||||
var keyResolver *crypto.KeyResolver
|
||||
@@ -369,6 +379,8 @@ func main() {
|
||||
sandbox.New(sandbox.DefaultConfig()),
|
||||
stores,
|
||||
)
|
||||
// v0.29.1: provider module adapter — bridges sandbox.ProviderResolver to handlers.ResolveProviderConfig
|
||||
starlarkRunner.SetProviderResolver(handlers.NewProviderResolverAdapter(stores, keyResolver))
|
||||
|
||||
// Discover and register active Starlark pre-completion filters
|
||||
filters.DiscoverStarlarkFilters(context.Background(), filterChain, stores, starlarkRunner)
|
||||
@@ -1236,6 +1248,15 @@ func main() {
|
||||
Session: middleware.AuthOrSession(cfg, stores, userCache),
|
||||
})
|
||||
|
||||
// v0.29.1: Extension API routes — Starlark packages serve JSON endpoints.
|
||||
// Mounted at /s/:slug/api/* with JWT auth (returns 401, not redirect).
|
||||
{
|
||||
extAPIH := handlers.NewExtAPIHandler(stores, starlarkRunner)
|
||||
extAPI := base.Group("/s/:slug/api")
|
||||
extAPI.Use(middleware.Auth(cfg, stores.Users, userCache))
|
||||
extAPI.Any("/*path", extAPIH.Handle)
|
||||
}
|
||||
|
||||
// Workflow API — session participants can send messages + trigger completions
|
||||
wfAPI := base.Group("/api/v1/w")
|
||||
wfAPI.Use(middleware.AuthOrSession(cfg, stores, userCache))
|
||||
|
||||
@@ -32,6 +32,7 @@ const (
|
||||
ExtPermDBRead = "db.read"
|
||||
ExtPermDBWrite = "db.write"
|
||||
ExtPermAPIHTTP = "api.http"
|
||||
ExtPermProviderComplete = "provider.complete" // v0.29.1: LLM completion calls
|
||||
)
|
||||
|
||||
// ValidExtensionPermissions is the set of recognized permission keys.
|
||||
@@ -42,6 +43,7 @@ var ValidExtensionPermissions = map[string]bool{
|
||||
ExtPermDBRead: true,
|
||||
ExtPermDBWrite: true,
|
||||
ExtPermAPIHTTP: true,
|
||||
ExtPermProviderComplete: true,
|
||||
}
|
||||
|
||||
// ── Extension Permission Model ───────────────
|
||||
|
||||
132
server/providers/custom.go
Normal file
132
server/providers/custom.go
Normal file
@@ -0,0 +1,132 @@
|
||||
// Package providers — custom.go
|
||||
//
|
||||
// v0.29.1 CS4: Config-file provider types. Registers OpenAI-compatible
|
||||
// provider types from a JSON file at startup. No code deploy needed to
|
||||
// add Ollama, LiteLLM, vLLM, or any other OpenAI-compatible endpoint.
|
||||
//
|
||||
// File format (array of objects):
|
||||
//
|
||||
// [
|
||||
// {
|
||||
// "id": "ollama",
|
||||
// "name": "Ollama",
|
||||
// "description": "Local Ollama instance",
|
||||
// "default_endpoint": "http://localhost:11434/v1",
|
||||
// "api": "openai"
|
||||
// },
|
||||
// {
|
||||
// "id": "litellm",
|
||||
// "name": "LiteLLM Proxy",
|
||||
// "description": "LiteLLM unified proxy",
|
||||
// "default_endpoint": "http://litellm:4000/v1",
|
||||
// "api": "openai"
|
||||
// }
|
||||
// ]
|
||||
//
|
||||
// Fields:
|
||||
// - id: Unique provider type ID (used in provider_configs.provider column)
|
||||
// - name: Human-readable label for admin UI
|
||||
// - description: Help text shown in provider creation form
|
||||
// - default_endpoint: Pre-filled endpoint URL for new configs
|
||||
// - api: Wire protocol — "openai" (required, only supported value for now)
|
||||
//
|
||||
// The api field determines which Provider implementation handles
|
||||
// requests. Currently only "openai" is supported (covers Ollama,
|
||||
// LiteLLM, vLLM, LocalAI, and any other OpenAI-compatible API).
|
||||
// Future: "anthropic" for Anthropic-compatible proxies.
|
||||
//
|
||||
// Built-in provider IDs (openai, anthropic, venice, openrouter) cannot
|
||||
// be overridden by config-file entries — they are silently skipped.
|
||||
package providers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
)
|
||||
|
||||
// CustomProviderType represents one entry in the provider types JSON file.
|
||||
type CustomProviderType struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
DefaultEndpoint string `json:"default_endpoint"`
|
||||
API string `json:"api"` // "openai" (required)
|
||||
}
|
||||
|
||||
// builtinIDs is the set of provider IDs registered by Init().
|
||||
// Config-file entries with these IDs are silently skipped.
|
||||
var builtinIDs = map[string]bool{
|
||||
"openai": true,
|
||||
"anthropic": true,
|
||||
"venice": true,
|
||||
"openrouter": true,
|
||||
}
|
||||
|
||||
// LoadCustomTypes reads a JSON file of provider type definitions and
|
||||
// registers each as a new provider type. Only OpenAI-compatible APIs
|
||||
// are currently supported (api="openai").
|
||||
//
|
||||
// Returns the number of providers registered. Errors are non-fatal —
|
||||
// individual invalid entries are logged and skipped.
|
||||
//
|
||||
// Call after Init() so that built-in types are already registered
|
||||
// and can be detected for skip logic.
|
||||
func LoadCustomTypes(path string) (int, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("reading provider types file: %w", err)
|
||||
}
|
||||
|
||||
var entries []CustomProviderType
|
||||
if err := json.Unmarshal(data, &entries); err != nil {
|
||||
return 0, fmt.Errorf("parsing provider types file: %w", err)
|
||||
}
|
||||
|
||||
registered := 0
|
||||
for _, entry := range entries {
|
||||
// Validate required fields
|
||||
if entry.ID == "" {
|
||||
log.Printf("⚠️ custom provider: skipping entry with empty id")
|
||||
continue
|
||||
}
|
||||
if entry.Name == "" {
|
||||
log.Printf("⚠️ custom provider %q: skipping — name is required", entry.ID)
|
||||
continue
|
||||
}
|
||||
if entry.API == "" {
|
||||
log.Printf("⚠️ custom provider %q: skipping — api is required", entry.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip built-in provider IDs
|
||||
if builtinIDs[entry.ID] {
|
||||
log.Printf("⚠️ custom provider %q: skipping — conflicts with built-in provider", entry.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
// Resolve API implementation
|
||||
var impl Provider
|
||||
switch entry.API {
|
||||
case "openai":
|
||||
impl = &OpenAIProvider{}
|
||||
default:
|
||||
log.Printf("⚠️ custom provider %q: unsupported api %q (only \"openai\" supported)", entry.ID, entry.API)
|
||||
continue
|
||||
}
|
||||
|
||||
RegisterType(ProviderTypeMeta{
|
||||
ID: entry.ID,
|
||||
Name: entry.Name,
|
||||
Description: entry.Description,
|
||||
DefaultEndpoint: entry.DefaultEndpoint,
|
||||
ProfileSchema: GetProfileSchema(entry.API), // inherit schema from API type
|
||||
}, impl)
|
||||
|
||||
registered++
|
||||
log.Printf(" 📦 Custom provider type: %s (%s) → %s", entry.ID, entry.Name, entry.DefaultEndpoint)
|
||||
}
|
||||
|
||||
return registered, nil
|
||||
}
|
||||
161
server/providers/custom_test.go
Normal file
161
server/providers/custom_test.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeTempJSON(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "provider_types.json")
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("failed to write temp file: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestLoadCustomTypes_Valid(t *testing.T) {
|
||||
path := writeTempJSON(t, `[
|
||||
{
|
||||
"id": "test-ollama",
|
||||
"name": "Test Ollama",
|
||||
"description": "Local test",
|
||||
"default_endpoint": "http://localhost:11434/v1",
|
||||
"api": "openai"
|
||||
},
|
||||
{
|
||||
"id": "test-litellm",
|
||||
"name": "Test LiteLLM",
|
||||
"description": "LiteLLM proxy",
|
||||
"default_endpoint": "http://litellm:4000/v1",
|
||||
"api": "openai"
|
||||
}
|
||||
]`)
|
||||
|
||||
n, err := LoadCustomTypes(path)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if n != 2 {
|
||||
t.Errorf("registered = %d, want 2", n)
|
||||
}
|
||||
|
||||
// Verify they're in the registry
|
||||
p, err := Get("test-ollama")
|
||||
if err != nil {
|
||||
t.Errorf("test-ollama not registered: %v", err)
|
||||
}
|
||||
if p == nil {
|
||||
t.Error("test-ollama provider is nil")
|
||||
}
|
||||
|
||||
// Verify type metadata
|
||||
types := ListTypes()
|
||||
found := false
|
||||
for _, m := range types {
|
||||
if m.ID == "test-litellm" {
|
||||
found = true
|
||||
if m.DefaultEndpoint != "http://litellm:4000/v1" {
|
||||
t.Errorf("endpoint = %q", m.DefaultEndpoint)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("test-litellm not in ListTypes()")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCustomTypes_SkipsBuiltins(t *testing.T) {
|
||||
path := writeTempJSON(t, `[
|
||||
{
|
||||
"id": "openai",
|
||||
"name": "Override OpenAI",
|
||||
"description": "Should be skipped",
|
||||
"default_endpoint": "http://evil.com",
|
||||
"api": "openai"
|
||||
},
|
||||
{
|
||||
"id": "test-custom-ok",
|
||||
"name": "Valid Custom",
|
||||
"description": "Should register",
|
||||
"default_endpoint": "http://ok.com",
|
||||
"api": "openai"
|
||||
}
|
||||
]`)
|
||||
|
||||
n, err := LoadCustomTypes(path)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("registered = %d, want 1 (openai skipped)", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCustomTypes_SkipsInvalid(t *testing.T) {
|
||||
path := writeTempJSON(t, `[
|
||||
{"id": "", "name": "No ID", "api": "openai"},
|
||||
{"id": "test-no-name", "name": "", "api": "openai"},
|
||||
{"id": "test-no-api", "name": "No API", "api": ""},
|
||||
{"id": "test-bad-api", "name": "Bad API", "api": "grpc", "default_endpoint": "http://x"},
|
||||
{"id": "test-valid-entry", "name": "Valid", "description": "ok", "default_endpoint": "http://ok", "api": "openai"}
|
||||
]`)
|
||||
|
||||
n, err := LoadCustomTypes(path)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("registered = %d, want 1 (4 invalid skipped)", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCustomTypes_EmptyArray(t *testing.T) {
|
||||
path := writeTempJSON(t, `[]`)
|
||||
|
||||
n, err := LoadCustomTypes(path)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("registered = %d, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCustomTypes_InvalidJSON(t *testing.T) {
|
||||
path := writeTempJSON(t, `not json`)
|
||||
|
||||
_, err := LoadCustomTypes(path)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCustomTypes_FileNotFound(t *testing.T) {
|
||||
_, err := LoadCustomTypes("/nonexistent/path/providers.json")
|
||||
if err == nil {
|
||||
t.Error("expected error for missing file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCustomTypes_NoDefaultEndpoint(t *testing.T) {
|
||||
// default_endpoint is optional — should still register
|
||||
path := writeTempJSON(t, `[
|
||||
{
|
||||
"id": "test-no-endpoint",
|
||||
"name": "No Endpoint",
|
||||
"description": "User must fill in endpoint",
|
||||
"api": "openai"
|
||||
}
|
||||
]`)
|
||||
|
||||
n, err := LoadCustomTypes(path)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("registered = %d, want 1", n)
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,8 @@ func (e *Evaluator) Evaluate(ctx *Context, policies []Policy, candidates []Candi
|
||||
result, matched = applyCostLimit(result, p.Config, ctx)
|
||||
case PolicyModelAlias:
|
||||
result, matched = applyModelAlias(result, p.Config, ctx)
|
||||
case PolicyCapabilityMatch:
|
||||
result, matched = applyCapabilityMatch(result, p.Config, ctx)
|
||||
}
|
||||
|
||||
if matched {
|
||||
@@ -218,6 +220,102 @@ func applyModelAlias(candidates []Candidate, cfg PolicyConfig, ctx *Context) ([]
|
||||
return candidates, true
|
||||
}
|
||||
|
||||
// applyCapabilityMatch filters candidates to those whose model has all
|
||||
// required capabilities. Optionally sorts remaining candidates by cheapest
|
||||
// output price when PreferCheapest is set.
|
||||
//
|
||||
// Required capabilities map to ModelCapabilities boolean fields:
|
||||
// "tool_calling", "vision", "thinking", "reasoning",
|
||||
// "code_optimized", "web_search", "streaming"
|
||||
func applyCapabilityMatch(candidates []Candidate, cfg PolicyConfig, ctx *Context) ([]Candidate, bool) {
|
||||
if len(cfg.RequiredCaps) == 0 {
|
||||
return candidates, false
|
||||
}
|
||||
if ctx.Capabilities == nil {
|
||||
return candidates, false
|
||||
}
|
||||
|
||||
filtered := make([]Candidate, 0, len(candidates))
|
||||
for _, c := range candidates {
|
||||
key := c.ConfigID + ":" + c.Model
|
||||
caps, ok := ctx.Capabilities[key]
|
||||
if !ok {
|
||||
continue // no capability data — skip (conservative)
|
||||
}
|
||||
if hasAllCaps(caps, cfg.RequiredCaps) {
|
||||
c.Reason = "capability match"
|
||||
filtered = append(filtered, c)
|
||||
}
|
||||
}
|
||||
|
||||
if len(filtered) == 0 {
|
||||
// No candidates match — fall back to original set rather than
|
||||
// returning empty (better to try than fail immediately).
|
||||
return candidates, false
|
||||
}
|
||||
|
||||
// Sort by cheapest output price if requested
|
||||
if cfg.PreferCheapest && ctx.Pricing != nil {
|
||||
sort.SliceStable(filtered, func(i, j int) bool {
|
||||
ki := filtered[i].ConfigID + ":" + filtered[i].Model
|
||||
kj := filtered[j].ConfigID + ":" + filtered[j].Model
|
||||
pi, oki := ctx.Pricing[ki]
|
||||
pj, okj := ctx.Pricing[kj]
|
||||
if !oki || pi == nil {
|
||||
return false // unknown price sorts last
|
||||
}
|
||||
if !okj || pj == nil {
|
||||
return true
|
||||
}
|
||||
return pi.OutputPerM < pj.OutputPerM
|
||||
})
|
||||
}
|
||||
|
||||
return filtered, true
|
||||
}
|
||||
|
||||
// hasAllCaps checks whether a ModelCapabilities struct has all requested
|
||||
// capability flags set. Unrecognized capability names are ignored.
|
||||
func hasAllCaps(caps *models.ModelCapabilities, required []string) bool {
|
||||
if caps == nil {
|
||||
return false
|
||||
}
|
||||
for _, req := range required {
|
||||
switch req {
|
||||
case "tool_calling":
|
||||
if !caps.ToolCalling {
|
||||
return false
|
||||
}
|
||||
case "vision":
|
||||
if !caps.Vision {
|
||||
return false
|
||||
}
|
||||
case "thinking":
|
||||
if !caps.Thinking {
|
||||
return false
|
||||
}
|
||||
case "reasoning":
|
||||
if !caps.Reasoning {
|
||||
return false
|
||||
}
|
||||
case "code_optimized":
|
||||
if !caps.CodeOptimized {
|
||||
return false
|
||||
}
|
||||
case "web_search":
|
||||
if !caps.WebSearch {
|
||||
return false
|
||||
}
|
||||
case "streaming":
|
||||
if !caps.Streaming {
|
||||
return false
|
||||
}
|
||||
// Unrecognized caps are ignored (forward-compatible)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ── Health Sorting ──────────────────────────
|
||||
|
||||
var statusOrder = map[models.ProviderStatus]int{
|
||||
|
||||
@@ -292,3 +292,207 @@ func TestEvaluate_InactivePolicy(t *testing.T) {
|
||||
t.Fatalf("inactive policy should not filter, got %d candidates", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
// ── capability_match tests ──────────────────
|
||||
|
||||
func capContext() *Context {
|
||||
return &Context{
|
||||
UserID: "u1", Model: "any",
|
||||
HealthStatus: map[string]models.ProviderStatus{},
|
||||
Capabilities: map[string]*models.ModelCapabilities{
|
||||
"cfg-openai:gpt-4o": {ToolCalling: true, Vision: true, Streaming: true},
|
||||
"cfg-anthropic:claude-sonnet-4-20250514": {ToolCalling: true, Vision: true, Thinking: true, Streaming: true},
|
||||
"cfg-venice:llama-3.1-405b": {ToolCalling: false, Vision: false, Streaming: true},
|
||||
},
|
||||
Pricing: map[string]*models.ModelPricing{
|
||||
"cfg-openai:gpt-4o": {OutputPerM: 15.0},
|
||||
"cfg-anthropic:claude-sonnet-4-20250514": {OutputPerM: 3.0},
|
||||
"cfg-venice:llama-3.1-405b": {OutputPerM: 0.5},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_CapabilityMatch_ToolCalling(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
ctx := capContext()
|
||||
policies := []Policy{
|
||||
{
|
||||
ID: "p1", Name: "Needs tool calling", Priority: 1,
|
||||
Type: PolicyCapabilityMatch, IsActive: true, Scope: "global",
|
||||
Config: PolicyConfig{RequiredCaps: []string{"tool_calling"}},
|
||||
},
|
||||
}
|
||||
|
||||
result, decision := e.Evaluate(ctx, policies, baseCandidates())
|
||||
|
||||
// Venice (llama) has no tool_calling — should be filtered out
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 candidates with tool_calling, got %d", len(result))
|
||||
}
|
||||
for _, c := range result {
|
||||
if c.ConfigID == "cfg-venice" {
|
||||
t.Error("venice should be filtered (no tool_calling)")
|
||||
}
|
||||
}
|
||||
if decision.PolicyID != "p1" {
|
||||
t.Errorf("expected policy p1, got %s", decision.PolicyID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_CapabilityMatch_MultipleCaps(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
ctx := capContext()
|
||||
policies := []Policy{
|
||||
{
|
||||
ID: "p1", Name: "Needs thinking + tool_calling", Priority: 1,
|
||||
Type: PolicyCapabilityMatch, IsActive: true, Scope: "global",
|
||||
Config: PolicyConfig{RequiredCaps: []string{"thinking", "tool_calling"}},
|
||||
},
|
||||
}
|
||||
|
||||
result, _ := e.Evaluate(ctx, policies, baseCandidates())
|
||||
|
||||
// Only anthropic has both thinking AND tool_calling
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 candidate with thinking+tool_calling, got %d", len(result))
|
||||
}
|
||||
if result[0].ConfigID != "cfg-anthropic" {
|
||||
t.Errorf("expected anthropic, got %s", result[0].ConfigID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_CapabilityMatch_PreferCheapest(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
ctx := capContext()
|
||||
policies := []Policy{
|
||||
{
|
||||
ID: "p1", Name: "Tool calling, cheapest first", Priority: 1,
|
||||
Type: PolicyCapabilityMatch, IsActive: true, Scope: "global",
|
||||
Config: PolicyConfig{
|
||||
RequiredCaps: []string{"tool_calling"},
|
||||
PreferCheapest: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, _ := e.Evaluate(ctx, policies, baseCandidates())
|
||||
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 candidates, got %d", len(result))
|
||||
}
|
||||
// Anthropic ($3/M) should be before OpenAI ($15/M)
|
||||
if result[0].ConfigID != "cfg-anthropic" {
|
||||
t.Errorf("expected cheapest (anthropic) first, got %s", result[0].ConfigID)
|
||||
}
|
||||
if result[1].ConfigID != "cfg-openai" {
|
||||
t.Errorf("expected openai second, got %s", result[1].ConfigID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_CapabilityMatch_NoneMatch_FallsBack(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
ctx := capContext()
|
||||
policies := []Policy{
|
||||
{
|
||||
ID: "p1", Name: "Needs web_search", Priority: 1,
|
||||
Type: PolicyCapabilityMatch, IsActive: true, Scope: "global",
|
||||
Config: PolicyConfig{RequiredCaps: []string{"web_search"}},
|
||||
},
|
||||
}
|
||||
|
||||
result, _ := e.Evaluate(ctx, policies, baseCandidates())
|
||||
|
||||
// None of the candidates have web_search — should fall back to all 3
|
||||
if len(result) != 3 {
|
||||
t.Fatalf("expected 3 candidates (fallback), got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_CapabilityMatch_StreamingOnly(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
ctx := capContext()
|
||||
policies := []Policy{
|
||||
{
|
||||
ID: "p1", Name: "Streaming only", Priority: 1,
|
||||
Type: PolicyCapabilityMatch, IsActive: true, Scope: "global",
|
||||
Config: PolicyConfig{RequiredCaps: []string{"streaming"}},
|
||||
},
|
||||
}
|
||||
|
||||
result, _ := e.Evaluate(ctx, policies, baseCandidates())
|
||||
|
||||
// All three have streaming — all should pass
|
||||
if len(result) != 3 {
|
||||
t.Fatalf("expected 3 candidates (all have streaming), got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_CapabilityMatch_NoCapsData(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
ctx := &Context{
|
||||
UserID: "u1", Model: "any",
|
||||
HealthStatus: map[string]models.ProviderStatus{},
|
||||
Capabilities: nil, // no capability data
|
||||
}
|
||||
policies := []Policy{
|
||||
{
|
||||
ID: "p1", Priority: 1,
|
||||
Type: PolicyCapabilityMatch, IsActive: true, Scope: "global",
|
||||
Config: PolicyConfig{RequiredCaps: []string{"tool_calling"}},
|
||||
},
|
||||
}
|
||||
|
||||
result, _ := e.Evaluate(ctx, policies, baseCandidates())
|
||||
|
||||
// No caps data — policy should not match, all candidates kept
|
||||
if len(result) != 3 {
|
||||
t.Fatalf("expected 3 candidates (no caps data), got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_CapabilityMatch_EmptyRequiredCaps(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
ctx := capContext()
|
||||
policies := []Policy{
|
||||
{
|
||||
ID: "p1", Priority: 1,
|
||||
Type: PolicyCapabilityMatch, IsActive: true, Scope: "global",
|
||||
Config: PolicyConfig{RequiredCaps: []string{}},
|
||||
},
|
||||
}
|
||||
|
||||
result, _ := e.Evaluate(ctx, policies, baseCandidates())
|
||||
|
||||
// Empty required caps — no filtering
|
||||
if len(result) != 3 {
|
||||
t.Fatalf("expected 3 candidates (empty caps), got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasAllCaps(t *testing.T) {
|
||||
caps := &models.ModelCapabilities{
|
||||
ToolCalling: true, Vision: true, Streaming: true,
|
||||
}
|
||||
|
||||
if !hasAllCaps(caps, []string{"tool_calling"}) {
|
||||
t.Error("should match tool_calling")
|
||||
}
|
||||
if !hasAllCaps(caps, []string{"tool_calling", "vision"}) {
|
||||
t.Error("should match tool_calling+vision")
|
||||
}
|
||||
if hasAllCaps(caps, []string{"thinking"}) {
|
||||
t.Error("should not match thinking")
|
||||
}
|
||||
if !hasAllCaps(caps, []string{"unknown_future_cap"}) {
|
||||
t.Error("unknown caps should be ignored (forward compat)")
|
||||
}
|
||||
if hasAllCaps(nil, []string{"tool_calling"}) {
|
||||
t.Error("nil caps should return false")
|
||||
}
|
||||
if !hasAllCaps(caps, nil) {
|
||||
t.Error("nil required should return true")
|
||||
}
|
||||
if !hasAllCaps(caps, []string{}) {
|
||||
t.Error("empty required should return true")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,9 @@ const (
|
||||
PolicyCostLimit PolicyType = "cost_limit"
|
||||
// PolicyModelAlias maps a model alias to a specific provider+model pair.
|
||||
PolicyModelAlias PolicyType = "model_alias"
|
||||
// PolicyCapabilityMatch filters to models with required capabilities,
|
||||
// optionally sorted by cheapest output price. v0.29.1.
|
||||
PolicyCapabilityMatch PolicyType = "capability_match"
|
||||
)
|
||||
|
||||
// Policy is one routing rule stored in the routing_policies table.
|
||||
@@ -57,6 +60,10 @@ type PolicyConfig struct {
|
||||
AliasTo string `json:"alias_to,omitempty"` // model ID
|
||||
AliasProvider string `json:"alias_provider,omitempty"` // config ID
|
||||
|
||||
// capability_match: filter candidates by model capabilities
|
||||
RequiredCaps []string `json:"required_caps,omitempty"` // e.g. ["tool_calling", "vision"]
|
||||
PreferCheapest bool `json:"prefer_cheapest,omitempty"` // sort by output_price_per_m ascending
|
||||
|
||||
// Shared: health requirements
|
||||
SkipDown bool `json:"skip_down,omitempty"` // skip providers with status "down"
|
||||
PreferHealthy bool `json:"prefer_healthy,omitempty"` // rank healthy > degraded
|
||||
@@ -79,6 +86,10 @@ type Context struct {
|
||||
// Pricing for candidate models (keyed by "configID:modelID").
|
||||
// Populated lazily from the pricing store.
|
||||
Pricing map[string]*models.ModelPricing
|
||||
|
||||
// Capabilities for candidate models (keyed by "configID:modelID").
|
||||
// Populated lazily from the catalog store. Used by capability_match.
|
||||
Capabilities map[string]*models.ModelCapabilities
|
||||
}
|
||||
|
||||
// ── Candidates ─────────────────────────────
|
||||
|
||||
448
server/sandbox/http_module.go
Normal file
448
server/sandbox/http_module.go
Normal file
@@ -0,0 +1,448 @@
|
||||
// Package sandbox — http_module.go
|
||||
//
|
||||
// v0.29.1 CS0: HTTP outbound module for Starlark extensions.
|
||||
// Requires permission: api.http
|
||||
//
|
||||
// Starlark API:
|
||||
//
|
||||
// resp = http.get(url, headers={})
|
||||
// resp = http.post(url, body="", headers={})
|
||||
// resp = http.put(url, body="", headers={})
|
||||
// resp = http.delete(url, headers={})
|
||||
// resp = http.request(method, url, body="", headers={})
|
||||
//
|
||||
// # resp is a dict: {"status": 200, "headers": {...}, "body": "..."}
|
||||
//
|
||||
// Network access rules from manifest:
|
||||
//
|
||||
// {
|
||||
// "network_access": {
|
||||
// "allow": ["api.example.com"], // allowlist mode: ONLY these domains
|
||||
// "block": ["evil.com"] // blocklist: additive to built-in blocks
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Security:
|
||||
// - Private/loopback IPs blocked (SSRF protection)
|
||||
// - DNS resolution checked before connect
|
||||
// - Response body capped at 1 MB
|
||||
// - 10 s default timeout (inherits context)
|
||||
// - Max 10 redirects, each checked for SSRF
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"go.starlark.net/starlark"
|
||||
"go.starlark.net/starlarkstruct"
|
||||
)
|
||||
|
||||
// ─── Configuration ──────────────────────────
|
||||
|
||||
const (
|
||||
httpDefaultTimeout = 10 * time.Second
|
||||
httpMaxResponseBody = 1 << 20 // 1 MB
|
||||
httpMaxRedirects = 10
|
||||
)
|
||||
|
||||
// HTTPModuleConfig controls the HTTP module's network access policy.
|
||||
type HTTPModuleConfig struct {
|
||||
// AllowDomains: if non-empty, only these exact domains are reachable.
|
||||
// When set, BlockDomains is ignored (allowlist takes precedence).
|
||||
AllowDomains []string
|
||||
|
||||
// BlockDomains: domains to block in addition to built-in SSRF rules.
|
||||
// Ignored when AllowDomains is non-empty.
|
||||
BlockDomains []string
|
||||
|
||||
// Timeout overrides the default 10 s request timeout.
|
||||
// Zero means use default.
|
||||
Timeout time.Duration
|
||||
|
||||
// MaxBodyBytes overrides the default 1 MB response body limit.
|
||||
// Zero means use default.
|
||||
MaxBodyBytes int64
|
||||
}
|
||||
|
||||
func (c HTTPModuleConfig) timeout() time.Duration {
|
||||
if c.Timeout > 0 {
|
||||
return c.Timeout
|
||||
}
|
||||
return httpDefaultTimeout
|
||||
}
|
||||
|
||||
func (c HTTPModuleConfig) maxBody() int64 {
|
||||
if c.MaxBodyBytes > 0 {
|
||||
return c.MaxBodyBytes
|
||||
}
|
||||
return httpMaxResponseBody
|
||||
}
|
||||
|
||||
// ParseNetworkAccess extracts HTTPModuleConfig from a package manifest.
|
||||
// Missing or malformed fields are silently ignored (no access = default policy).
|
||||
func ParseNetworkAccess(manifest map[string]any) HTTPModuleConfig {
|
||||
cfg := HTTPModuleConfig{}
|
||||
|
||||
na, ok := manifest["network_access"]
|
||||
if !ok {
|
||||
return cfg
|
||||
}
|
||||
naMap, ok := na.(map[string]any)
|
||||
if !ok {
|
||||
return cfg
|
||||
}
|
||||
|
||||
if allow, ok := naMap["allow"]; ok {
|
||||
cfg.AllowDomains = toStringSlice(allow)
|
||||
}
|
||||
if block, ok := naMap["block"]; ok {
|
||||
cfg.BlockDomains = toStringSlice(block)
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
func toStringSlice(v any) []string {
|
||||
switch arr := v.(type) {
|
||||
case []any:
|
||||
out := make([]string, 0, len(arr))
|
||||
for _, item := range arr {
|
||||
if s, ok := item.(string); ok && s != "" {
|
||||
out = append(out, strings.ToLower(s))
|
||||
}
|
||||
}
|
||||
return out
|
||||
case []string:
|
||||
out := make([]string, 0, len(arr))
|
||||
for _, s := range arr {
|
||||
if s != "" {
|
||||
out = append(out, strings.ToLower(s))
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Module Builder ─────────────────────────
|
||||
|
||||
// BuildHTTPModule creates the "http" Starlark module with the given
|
||||
// network access policy. Each call to a builtin creates a fresh
|
||||
// request bound to the parent context.
|
||||
func BuildHTTPModule(ctx context.Context, cfg HTTPModuleConfig) *starlarkstruct.Module {
|
||||
ac := &accessControl{cfg: cfg}
|
||||
|
||||
doRequest := func(method, rawURL, body string, headers map[string]string) (starlark.Value, error) {
|
||||
return executeHTTPRequest(ctx, ac, method, rawURL, body, headers, cfg.timeout(), cfg.maxBody())
|
||||
}
|
||||
|
||||
return MakeModule("http", starlark.StringDict{
|
||||
"request": starlark.NewBuiltin("http.request", func(
|
||||
thread *starlark.Thread, b *starlark.Builtin,
|
||||
args starlark.Tuple, kwargs []starlark.Tuple,
|
||||
) (starlark.Value, error) {
|
||||
var method, rawURL string
|
||||
var body string
|
||||
var hdrs *starlark.Dict
|
||||
if err := starlark.UnpackArgs(b.Name(), args, kwargs,
|
||||
"method", &method,
|
||||
"url", &rawURL,
|
||||
"body?", &body,
|
||||
"headers?", &hdrs,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return doRequest(strings.ToUpper(method), rawURL, body, dictToStringMap(hdrs))
|
||||
}),
|
||||
|
||||
"get": starlark.NewBuiltin("http.get", func(
|
||||
thread *starlark.Thread, b *starlark.Builtin,
|
||||
args starlark.Tuple, kwargs []starlark.Tuple,
|
||||
) (starlark.Value, error) {
|
||||
var rawURL string
|
||||
var hdrs *starlark.Dict
|
||||
if err := starlark.UnpackArgs(b.Name(), args, kwargs,
|
||||
"url", &rawURL,
|
||||
"headers?", &hdrs,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return doRequest("GET", rawURL, "", dictToStringMap(hdrs))
|
||||
}),
|
||||
|
||||
"post": starlark.NewBuiltin("http.post", func(
|
||||
thread *starlark.Thread, b *starlark.Builtin,
|
||||
args starlark.Tuple, kwargs []starlark.Tuple,
|
||||
) (starlark.Value, error) {
|
||||
var rawURL, body string
|
||||
var hdrs *starlark.Dict
|
||||
if err := starlark.UnpackArgs(b.Name(), args, kwargs,
|
||||
"url", &rawURL,
|
||||
"body?", &body,
|
||||
"headers?", &hdrs,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return doRequest("POST", rawURL, body, dictToStringMap(hdrs))
|
||||
}),
|
||||
|
||||
"put": starlark.NewBuiltin("http.put", func(
|
||||
thread *starlark.Thread, b *starlark.Builtin,
|
||||
args starlark.Tuple, kwargs []starlark.Tuple,
|
||||
) (starlark.Value, error) {
|
||||
var rawURL, body string
|
||||
var hdrs *starlark.Dict
|
||||
if err := starlark.UnpackArgs(b.Name(), args, kwargs,
|
||||
"url", &rawURL,
|
||||
"body?", &body,
|
||||
"headers?", &hdrs,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return doRequest("PUT", rawURL, body, dictToStringMap(hdrs))
|
||||
}),
|
||||
|
||||
"delete": starlark.NewBuiltin("http.delete", func(
|
||||
thread *starlark.Thread, b *starlark.Builtin,
|
||||
args starlark.Tuple, kwargs []starlark.Tuple,
|
||||
) (starlark.Value, error) {
|
||||
var rawURL string
|
||||
var hdrs *starlark.Dict
|
||||
if err := starlark.UnpackArgs(b.Name(), args, kwargs,
|
||||
"url", &rawURL,
|
||||
"headers?", &hdrs,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return doRequest("DELETE", rawURL, "", dictToStringMap(hdrs))
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Request Execution ──────────────────────
|
||||
|
||||
func executeHTTPRequest(
|
||||
ctx context.Context,
|
||||
ac *accessControl,
|
||||
method, rawURL, body string,
|
||||
headers map[string]string,
|
||||
timeout time.Duration,
|
||||
maxBody int64,
|
||||
) (starlark.Value, error) {
|
||||
// Validate URL
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("http.%s: invalid URL: %w", strings.ToLower(method), err)
|
||||
}
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return nil, fmt.Errorf("http.%s: only http/https schemes allowed, got %q", strings.ToLower(method), parsed.Scheme)
|
||||
}
|
||||
|
||||
// Check domain against access control (pre-DNS)
|
||||
host := parsed.Hostname()
|
||||
if err := ac.checkDomain(host); err != nil {
|
||||
return nil, fmt.Errorf("http.%s: %w", strings.ToLower(method), err)
|
||||
}
|
||||
|
||||
// Build request
|
||||
reqCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
var bodyReader io.Reader
|
||||
if body != "" {
|
||||
bodyReader = strings.NewReader(body)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(reqCtx, method, rawURL, bodyReader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("http.%s: %w", strings.ToLower(method), err)
|
||||
}
|
||||
|
||||
// Set headers (user-provided override defaults)
|
||||
req.Header.Set("User-Agent", "ChatSwitchboard-Extension/1.0")
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
// SSRF-safe client with custom dialer
|
||||
client := ac.client()
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("http.%s: request failed: %w", strings.ToLower(method), err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read body with size limit
|
||||
limited := io.LimitReader(resp.Body, maxBody+1)
|
||||
respBody, err := io.ReadAll(limited)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("http.%s: reading response: %w", strings.ToLower(method), err)
|
||||
}
|
||||
if int64(len(respBody)) > maxBody {
|
||||
respBody = respBody[:maxBody]
|
||||
// Truncated — still return what we have, don't error
|
||||
}
|
||||
|
||||
return buildResponseDict(resp.StatusCode, resp.Header, string(respBody))
|
||||
}
|
||||
|
||||
// buildResponseDict converts an HTTP response into a Starlark dict:
|
||||
//
|
||||
// {"status": int, "headers": dict, "body": string}
|
||||
func buildResponseDict(status int, h http.Header, body string) (starlark.Value, error) {
|
||||
// Build headers dict (lowercase keys, first value only for simplicity)
|
||||
hdrDict := starlark.NewDict(len(h))
|
||||
for k, vals := range h {
|
||||
if len(vals) > 0 {
|
||||
_ = hdrDict.SetKey(starlark.String(strings.ToLower(k)), starlark.String(vals[0]))
|
||||
}
|
||||
}
|
||||
|
||||
resp := starlark.NewDict(3)
|
||||
_ = resp.SetKey(starlark.String("status"), starlark.MakeInt(status))
|
||||
_ = resp.SetKey(starlark.String("headers"), hdrDict)
|
||||
_ = resp.SetKey(starlark.String("body"), starlark.String(body))
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// ─── Access Control ─────────────────────────
|
||||
|
||||
type accessControl struct {
|
||||
cfg HTTPModuleConfig
|
||||
}
|
||||
|
||||
// checkDomain validates a hostname against the access policy.
|
||||
func (ac *accessControl) checkDomain(host string) error {
|
||||
host = strings.ToLower(host)
|
||||
|
||||
// Allowlist mode: only listed domains are permitted
|
||||
if len(ac.cfg.AllowDomains) > 0 {
|
||||
for _, d := range ac.cfg.AllowDomains {
|
||||
if host == d {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("domain %q not in network_access allow list", host)
|
||||
}
|
||||
|
||||
// Blocklist mode: check against explicit blocks
|
||||
for _, d := range ac.cfg.BlockDomains {
|
||||
if host == d {
|
||||
return fmt.Errorf("domain %q is blocked by network_access policy", host)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// client builds an SSRF-safe http.Client with IP validation on connect.
|
||||
func (ac *accessControl) client() *http.Client {
|
||||
dialer := &net.Dialer{
|
||||
Timeout: 5 * time.Second,
|
||||
Control: ssrfControl,
|
||||
}
|
||||
|
||||
transport := &http.Transport{
|
||||
DialContext: dialer.DialContext,
|
||||
TLSHandshakeTimeout: 5 * time.Second,
|
||||
MaxIdleConns: 1,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
DisableKeepAlives: true, // extension requests are one-shot
|
||||
}
|
||||
|
||||
return &http.Client{
|
||||
Transport: transport,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= httpMaxRedirects {
|
||||
return fmt.Errorf("too many redirects (max %d)", httpMaxRedirects)
|
||||
}
|
||||
// Re-check domain on redirect to prevent SSRF via redirect
|
||||
host := req.URL.Hostname()
|
||||
if err := ac.checkDomain(host); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ssrfControl is a net.Dialer.Control function that rejects connections
|
||||
// to private, loopback, and link-local addresses. Called after DNS
|
||||
// resolution, before the TCP handshake — prevents DNS rebinding attacks.
|
||||
func ssrfControl(network, address string, _ syscall.RawConn) error {
|
||||
host, _, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ssrf: invalid address %q: %w", address, err)
|
||||
}
|
||||
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil {
|
||||
return fmt.Errorf("ssrf: could not parse IP %q", host)
|
||||
}
|
||||
|
||||
if !isPublicIP(ip) {
|
||||
return fmt.Errorf("ssrf: connection to private/reserved address %s blocked", ip)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// isPublicIP returns true if the IP is a routable public address.
|
||||
// Blocks all RFC1918, loopback, link-local, multicast, and unspecified.
|
||||
func isPublicIP(ip net.IP) bool {
|
||||
// Unspecified (0.0.0.0, ::)
|
||||
if ip.IsUnspecified() {
|
||||
return false
|
||||
}
|
||||
// Loopback (127.0.0.0/8, ::1)
|
||||
if ip.IsLoopback() {
|
||||
return false
|
||||
}
|
||||
// Link-local unicast (169.254.0.0/16, fe80::/10)
|
||||
if ip.IsLinkLocalUnicast() {
|
||||
return false
|
||||
}
|
||||
// Link-local multicast
|
||||
if ip.IsLinkLocalMulticast() {
|
||||
return false
|
||||
}
|
||||
// Multicast
|
||||
if ip.IsMulticast() {
|
||||
return false
|
||||
}
|
||||
// Private (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7)
|
||||
if ip.IsPrivate() {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// ─── Starlark Dict Helpers ──────────────────
|
||||
|
||||
// dictToStringMap converts a Starlark dict to a Go map[string]string.
|
||||
// Non-string keys or values are silently skipped.
|
||||
func dictToStringMap(d *starlark.Dict) map[string]string {
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
m := make(map[string]string, d.Len())
|
||||
for _, item := range d.Items() {
|
||||
k, ok1 := starlark.AsString(item[0])
|
||||
v, ok2 := starlark.AsString(item[1])
|
||||
if ok1 && ok2 {
|
||||
m[k] = v
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
510
server/sandbox/http_module_test.go
Normal file
510
server/sandbox/http_module_test.go
Normal file
@@ -0,0 +1,510 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.starlark.net/starlark"
|
||||
)
|
||||
|
||||
// ─── isPublicIP ─────────────────────────────
|
||||
|
||||
func TestIsPublicIP(t *testing.T) {
|
||||
tests := []struct {
|
||||
ip string
|
||||
public bool
|
||||
}{
|
||||
// Public
|
||||
{"8.8.8.8", true},
|
||||
{"1.1.1.1", true},
|
||||
{"93.184.216.34", true},
|
||||
{"2607:f8b0:4004:800::200e", true}, // google IPv6
|
||||
|
||||
// Loopback
|
||||
{"127.0.0.1", false},
|
||||
{"127.0.0.2", false},
|
||||
{"::1", false},
|
||||
|
||||
// Private RFC1918
|
||||
{"10.0.0.1", false},
|
||||
{"10.255.255.255", false},
|
||||
{"172.16.0.1", false},
|
||||
{"172.31.255.255", false},
|
||||
{"192.168.0.1", false},
|
||||
{"192.168.1.100", false},
|
||||
|
||||
// Link-local
|
||||
{"169.254.0.1", false},
|
||||
{"169.254.169.254", false}, // AWS metadata
|
||||
{"fe80::1", false},
|
||||
|
||||
// Unspecified
|
||||
{"0.0.0.0", false},
|
||||
{"::", false},
|
||||
|
||||
// Multicast
|
||||
{"224.0.0.1", false},
|
||||
{"ff02::1", false},
|
||||
|
||||
// Private IPv6
|
||||
{"fc00::1", false},
|
||||
{"fd00::1", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
ip := net.ParseIP(tt.ip)
|
||||
if ip == nil {
|
||||
t.Fatalf("failed to parse test IP %q", tt.ip)
|
||||
}
|
||||
got := isPublicIP(ip)
|
||||
if got != tt.public {
|
||||
t.Errorf("isPublicIP(%s) = %v, want %v", tt.ip, got, tt.public)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── ParseNetworkAccess ─────────────────────
|
||||
|
||||
func TestParseNetworkAccess(t *testing.T) {
|
||||
t.Run("empty manifest", func(t *testing.T) {
|
||||
cfg := ParseNetworkAccess(map[string]any{})
|
||||
if len(cfg.AllowDomains) != 0 || len(cfg.BlockDomains) != 0 {
|
||||
t.Errorf("expected empty config, got allow=%v block=%v", cfg.AllowDomains, cfg.BlockDomains)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("allow list", func(t *testing.T) {
|
||||
cfg := ParseNetworkAccess(map[string]any{
|
||||
"network_access": map[string]any{
|
||||
"allow": []any{"API.Example.COM", "hooks.slack.com"},
|
||||
},
|
||||
})
|
||||
if len(cfg.AllowDomains) != 2 {
|
||||
t.Fatalf("expected 2 allow domains, got %d", len(cfg.AllowDomains))
|
||||
}
|
||||
if cfg.AllowDomains[0] != "api.example.com" {
|
||||
t.Errorf("allow[0] = %q, want %q", cfg.AllowDomains[0], "api.example.com")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("block list", func(t *testing.T) {
|
||||
cfg := ParseNetworkAccess(map[string]any{
|
||||
"network_access": map[string]any{
|
||||
"block": []any{"evil.com"},
|
||||
},
|
||||
})
|
||||
if len(cfg.BlockDomains) != 1 || cfg.BlockDomains[0] != "evil.com" {
|
||||
t.Errorf("expected block=[evil.com], got %v", cfg.BlockDomains)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("malformed network_access ignored", func(t *testing.T) {
|
||||
cfg := ParseNetworkAccess(map[string]any{
|
||||
"network_access": "not a map",
|
||||
})
|
||||
if len(cfg.AllowDomains) != 0 {
|
||||
t.Errorf("expected empty allow, got %v", cfg.AllowDomains)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("string slice variant", func(t *testing.T) {
|
||||
cfg := ParseNetworkAccess(map[string]any{
|
||||
"network_access": map[string]any{
|
||||
"allow": []string{"a.com", "b.com"},
|
||||
},
|
||||
})
|
||||
if len(cfg.AllowDomains) != 2 {
|
||||
t.Errorf("expected 2 allow domains, got %d", len(cfg.AllowDomains))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty strings filtered", func(t *testing.T) {
|
||||
cfg := ParseNetworkAccess(map[string]any{
|
||||
"network_access": map[string]any{
|
||||
"allow": []any{"", "ok.com", ""},
|
||||
},
|
||||
})
|
||||
if len(cfg.AllowDomains) != 1 || cfg.AllowDomains[0] != "ok.com" {
|
||||
t.Errorf("expected [ok.com], got %v", cfg.AllowDomains)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Access Control ─────────────────────────
|
||||
|
||||
func TestAccessControl_Allowlist(t *testing.T) {
|
||||
ac := &accessControl{cfg: HTTPModuleConfig{
|
||||
AllowDomains: []string{"api.example.com", "hooks.slack.com"},
|
||||
}}
|
||||
|
||||
if err := ac.checkDomain("api.example.com"); err != nil {
|
||||
t.Errorf("allowed domain rejected: %v", err)
|
||||
}
|
||||
if err := ac.checkDomain("hooks.slack.com"); err != nil {
|
||||
t.Errorf("allowed domain rejected: %v", err)
|
||||
}
|
||||
if err := ac.checkDomain("evil.com"); err == nil {
|
||||
t.Error("unlisted domain should be rejected in allowlist mode")
|
||||
}
|
||||
if err := ac.checkDomain("API.EXAMPLE.COM"); err != nil {
|
||||
t.Errorf("case-insensitive match should pass: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessControl_Blocklist(t *testing.T) {
|
||||
ac := &accessControl{cfg: HTTPModuleConfig{
|
||||
BlockDomains: []string{"evil.com", "malware.org"},
|
||||
}}
|
||||
|
||||
if err := ac.checkDomain("api.example.com"); err != nil {
|
||||
t.Errorf("unblocked domain rejected: %v", err)
|
||||
}
|
||||
if err := ac.checkDomain("evil.com"); err == nil {
|
||||
t.Error("blocked domain should be rejected")
|
||||
}
|
||||
if err := ac.checkDomain("malware.org"); err == nil {
|
||||
t.Error("blocked domain should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessControl_NoPolicy(t *testing.T) {
|
||||
ac := &accessControl{cfg: HTTPModuleConfig{}}
|
||||
|
||||
if err := ac.checkDomain("anything.com"); err != nil {
|
||||
t.Errorf("no-policy mode should allow all public domains: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── dictToStringMap ────────────────────────
|
||||
|
||||
func TestDictToStringMap(t *testing.T) {
|
||||
t.Run("nil dict", func(t *testing.T) {
|
||||
m := dictToStringMap(nil)
|
||||
if m != nil {
|
||||
t.Errorf("nil input should return nil, got %v", m)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid dict", func(t *testing.T) {
|
||||
d := starlark.NewDict(2)
|
||||
_ = d.SetKey(starlark.String("Content-Type"), starlark.String("application/json"))
|
||||
_ = d.SetKey(starlark.String("X-Token"), starlark.String("abc"))
|
||||
m := dictToStringMap(d)
|
||||
if m["Content-Type"] != "application/json" {
|
||||
t.Errorf("Content-Type = %q", m["Content-Type"])
|
||||
}
|
||||
if m["X-Token"] != "abc" {
|
||||
t.Errorf("X-Token = %q", m["X-Token"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-string values skipped", func(t *testing.T) {
|
||||
d := starlark.NewDict(2)
|
||||
_ = d.SetKey(starlark.String("ok"), starlark.String("yes"))
|
||||
_ = d.SetKey(starlark.String("bad"), starlark.MakeInt(42))
|
||||
m := dictToStringMap(d)
|
||||
if len(m) != 1 {
|
||||
t.Errorf("expected 1 entry (non-string skipped), got %d", len(m))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─── buildResponseDict ──────────────────────
|
||||
|
||||
func TestBuildResponseDict(t *testing.T) {
|
||||
h := http.Header{}
|
||||
h.Set("Content-Type", "application/json")
|
||||
h.Set("X-Request-Id", "abc-123")
|
||||
|
||||
val, err := buildResponseDict(200, h, `{"ok":true}`)
|
||||
if err != nil {
|
||||
t.Fatalf("buildResponseDict: %v", err)
|
||||
}
|
||||
|
||||
d, ok := val.(*starlark.Dict)
|
||||
if !ok {
|
||||
t.Fatalf("expected *starlark.Dict, got %T", val)
|
||||
}
|
||||
|
||||
// Status
|
||||
statusVal, found, _ := d.Get(starlark.String("status"))
|
||||
if !found {
|
||||
t.Fatal("missing 'status' key")
|
||||
}
|
||||
statusInt, _ := starlark.AsInt32(statusVal)
|
||||
if statusInt != 200 {
|
||||
t.Errorf("status = %d, want 200", statusInt)
|
||||
}
|
||||
|
||||
// Body
|
||||
bodyVal, found, _ := d.Get(starlark.String("body"))
|
||||
if !found {
|
||||
t.Fatal("missing 'body' key")
|
||||
}
|
||||
bodyStr, _ := starlark.AsString(bodyVal)
|
||||
if bodyStr != `{"ok":true}` {
|
||||
t.Errorf("body = %q", bodyStr)
|
||||
}
|
||||
|
||||
// Headers
|
||||
hdrsVal, found, _ := d.Get(starlark.String("headers"))
|
||||
if !found {
|
||||
t.Fatal("missing 'headers' key")
|
||||
}
|
||||
hdrs, ok := hdrsVal.(*starlark.Dict)
|
||||
if !ok {
|
||||
t.Fatalf("headers is %T, want *Dict", hdrsVal)
|
||||
}
|
||||
ctVal, found, _ := hdrs.Get(starlark.String("content-type"))
|
||||
if !found {
|
||||
t.Fatal("missing content-type header")
|
||||
}
|
||||
ct, _ := starlark.AsString(ctVal)
|
||||
if ct != "application/json" {
|
||||
t.Errorf("content-type = %q", ct)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Integration: HTTP GET against httptest ──
|
||||
|
||||
func TestHTTPModule_GET(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
t.Errorf("expected GET, got %s", r.Method)
|
||||
}
|
||||
if r.Header.Get("X-Custom") != "hello" {
|
||||
t.Errorf("missing custom header")
|
||||
}
|
||||
w.Header().Set("X-Test", "passed")
|
||||
w.WriteHeader(200)
|
||||
fmt.Fprint(w, `{"result":"ok"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// Extract host for allowlist
|
||||
host := strings.TrimPrefix(srv.URL, "http://")
|
||||
hostOnly := strings.Split(host, ":")[0]
|
||||
_ = hostOnly // httptest uses 127.0.0.1 which is blocked by SSRF
|
||||
|
||||
// For testing, we need to use the module without SSRF checks
|
||||
// since httptest binds to 127.0.0.1. Test the module builder
|
||||
// API by calling executeHTTPRequest with a permissive client.
|
||||
// SSRF checks are tested separately via isPublicIP tests.
|
||||
resp, err := executeHTTPRequest(
|
||||
context.Background(),
|
||||
&accessControl{cfg: HTTPModuleConfig{}},
|
||||
"GET", srv.URL, "",
|
||||
map[string]string{"X-Custom": "hello"},
|
||||
5*time.Second,
|
||||
httpMaxResponseBody,
|
||||
)
|
||||
// This will fail with SSRF block because httptest is 127.0.0.1.
|
||||
// That's the correct behavior — verify we get the SSRF error.
|
||||
if err == nil {
|
||||
// If it somehow succeeded (e.g., test env allows loopback), check response
|
||||
d := resp.(*starlark.Dict)
|
||||
statusVal, _, _ := d.Get(starlark.String("status"))
|
||||
statusInt, _ := starlark.AsInt32(statusVal)
|
||||
if statusInt != 200 {
|
||||
t.Errorf("status = %d, want 200", statusInt)
|
||||
}
|
||||
} else if !strings.Contains(err.Error(), "ssrf") && !strings.Contains(err.Error(), "private") {
|
||||
t.Errorf("expected SSRF error for loopback, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPModule_SSRFBlocked verifies that private IPs are blocked
|
||||
// even when disguised as valid URLs.
|
||||
func TestHTTPModule_SSRFBlocked(t *testing.T) {
|
||||
targets := []string{
|
||||
"http://127.0.0.1/",
|
||||
"http://10.0.0.1/",
|
||||
"http://192.168.1.1/",
|
||||
"http://169.254.169.254/latest/meta-data/", // AWS metadata
|
||||
"http://[::1]/",
|
||||
}
|
||||
|
||||
ac := &accessControl{cfg: HTTPModuleConfig{}}
|
||||
|
||||
for _, target := range targets {
|
||||
_, err := executeHTTPRequest(
|
||||
context.Background(), ac,
|
||||
"GET", target, "", nil,
|
||||
2*time.Second, httpMaxResponseBody,
|
||||
)
|
||||
if err == nil {
|
||||
t.Errorf("expected SSRF block for %s", target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPModule_SchemeValidation verifies only http/https are allowed.
|
||||
func TestHTTPModule_SchemeValidation(t *testing.T) {
|
||||
ac := &accessControl{cfg: HTTPModuleConfig{}}
|
||||
|
||||
schemes := []string{"ftp://example.com", "file:///etc/passwd", "gopher://evil.com"}
|
||||
for _, u := range schemes {
|
||||
_, err := executeHTTPRequest(
|
||||
context.Background(), ac,
|
||||
"GET", u, "", nil,
|
||||
2*time.Second, httpMaxResponseBody,
|
||||
)
|
||||
if err == nil {
|
||||
t.Errorf("expected scheme rejection for %s", u)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "only http/https") {
|
||||
t.Errorf("wrong error for %s: %v", u, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPModule_DomainBlock verifies blocklist enforcement at request time.
|
||||
func TestHTTPModule_DomainBlock(t *testing.T) {
|
||||
ac := &accessControl{cfg: HTTPModuleConfig{
|
||||
BlockDomains: []string{"evil.com"},
|
||||
}}
|
||||
|
||||
_, err := executeHTTPRequest(
|
||||
context.Background(), ac,
|
||||
"GET", "https://evil.com/api", "", nil,
|
||||
2*time.Second, httpMaxResponseBody,
|
||||
)
|
||||
if err == nil {
|
||||
t.Error("expected block for evil.com")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "blocked") {
|
||||
t.Errorf("wrong error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPModule_AllowlistEnforcement verifies allowlist-only mode.
|
||||
func TestHTTPModule_AllowlistEnforcement(t *testing.T) {
|
||||
ac := &accessControl{cfg: HTTPModuleConfig{
|
||||
AllowDomains: []string{"api.good.com"},
|
||||
}}
|
||||
|
||||
_, err := executeHTTPRequest(
|
||||
context.Background(), ac,
|
||||
"GET", "https://api.bad.com/data", "", nil,
|
||||
2*time.Second, httpMaxResponseBody,
|
||||
)
|
||||
if err == nil {
|
||||
t.Error("expected rejection for unlisted domain")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not in network_access allow list") {
|
||||
t.Errorf("wrong error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPModule_Timeout verifies context-based timeout.
|
||||
func TestHTTPModule_Timeout(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(5 * time.Second)
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ac := &accessControl{cfg: HTTPModuleConfig{}}
|
||||
_, err := executeHTTPRequest(
|
||||
context.Background(), ac,
|
||||
"GET", srv.URL, "", nil,
|
||||
100*time.Millisecond, httpMaxResponseBody,
|
||||
)
|
||||
// Will either fail with SSRF (127.0.0.1) or timeout — both are correct.
|
||||
if err == nil {
|
||||
t.Error("expected error (SSRF or timeout)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPModule_Starlark_Integration runs the http module through the
|
||||
// Starlark interpreter to verify the builtins are properly wired.
|
||||
// Starlark has no try/except — errors from builtins propagate to Go.
|
||||
func TestHTTPModule_Starlark_Integration(t *testing.T) {
|
||||
mod := BuildHTTPModule(context.Background(), HTTPModuleConfig{
|
||||
AllowDomains: []string{"only-this.example.com"},
|
||||
})
|
||||
|
||||
sb := New(DefaultConfig())
|
||||
modules := map[string]starlark.Value{"http": mod}
|
||||
|
||||
// Call http.get against a domain NOT in the allowlist.
|
||||
// The builtin should return an error that propagates to Go.
|
||||
script := `
|
||||
def test():
|
||||
return http.get(url="https://blocked.example.com/api")
|
||||
|
||||
result = test()
|
||||
`
|
||||
_, err := sb.Exec(context.Background(), "test.star", script, modules)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for blocked domain, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not in network_access allow list") {
|
||||
t.Errorf("expected allowlist error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPModule_Starlark_POST verifies post binding accepts body/headers kwargs.
|
||||
// Starlark has no try/except — we verify binding by checking the error is
|
||||
// a network/DNS error (correct kwargs accepted) not a Starlark type error.
|
||||
func TestHTTPModule_Starlark_POST(t *testing.T) {
|
||||
mod := BuildHTTPModule(context.Background(), HTTPModuleConfig{
|
||||
AllowDomains: []string{"only-this.example.com"},
|
||||
})
|
||||
|
||||
sb := New(DefaultConfig())
|
||||
modules := map[string]starlark.Value{"http": mod}
|
||||
|
||||
// Call http.post with body and headers kwargs against an allowed domain.
|
||||
// Will fail at DNS/connection (domain doesn't exist), but the binding
|
||||
// itself must parse kwargs without error.
|
||||
script := `
|
||||
def test():
|
||||
return http.post(url="https://only-this.example.com/api", body='{"k":"v"}', headers={"Content-Type": "application/json"})
|
||||
|
||||
result = test()
|
||||
`
|
||||
_, err := sb.Exec(context.Background(), "test.star", script, modules)
|
||||
if err == nil {
|
||||
t.Fatal("expected network error (domain doesn't resolve), got nil")
|
||||
}
|
||||
// Should be a network error (DNS lookup failure), NOT a Starlark
|
||||
// argument-parsing error like "unexpected keyword argument".
|
||||
errStr := err.Error()
|
||||
if strings.Contains(errStr, "unexpected keyword") || strings.Contains(errStr, "missing argument") {
|
||||
t.Errorf("binding signature broken: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPModule_Starlark_Request verifies the generic request binding.
|
||||
func TestHTTPModule_Starlark_Request(t *testing.T) {
|
||||
mod := BuildHTTPModule(context.Background(), HTTPModuleConfig{
|
||||
AllowDomains: []string{"nope.invalid"},
|
||||
})
|
||||
|
||||
sb := New(DefaultConfig())
|
||||
modules := map[string]starlark.Value{"http": mod}
|
||||
|
||||
// Call http.request with method, url, body kwargs.
|
||||
// Will fail at DNS, but binding must accept kwargs correctly.
|
||||
script := `
|
||||
def test():
|
||||
return http.request(method="PATCH", url="https://nope.invalid/x", body="data")
|
||||
|
||||
result = test()
|
||||
`
|
||||
_, err := sb.Exec(context.Background(), "test.star", script, modules)
|
||||
if err == nil {
|
||||
t.Fatal("expected network error, got nil")
|
||||
}
|
||||
errStr := err.Error()
|
||||
if strings.Contains(errStr, "unexpected keyword") || strings.Contains(errStr, "missing argument") {
|
||||
t.Errorf("binding signature broken: %v", err)
|
||||
}
|
||||
}
|
||||
241
server/sandbox/provider_module.go
Normal file
241
server/sandbox/provider_module.go
Normal file
@@ -0,0 +1,241 @@
|
||||
// Package sandbox — provider_module.go
|
||||
//
|
||||
// v0.29.1 CS2: Provider module for Starlark extensions.
|
||||
// Requires permission: provider.complete
|
||||
//
|
||||
// Starlark API:
|
||||
//
|
||||
// resp = provider.complete(
|
||||
// messages=[{"role": "user", "content": "Hello"}],
|
||||
// model="claude-3-haiku", # optional — uses default from BYOK chain
|
||||
// max_tokens=1024, # optional — default 4096
|
||||
// temperature=0.7, # optional — provider default
|
||||
// )
|
||||
//
|
||||
// # resp = {
|
||||
// # "content": "Hi there!",
|
||||
// # "model": "claude-3-haiku-20240307",
|
||||
// # "finish_reason": "stop",
|
||||
// # "input_tokens": 10,
|
||||
// # "output_tokens": 15,
|
||||
// # }
|
||||
//
|
||||
// Provider resolution uses the existing BYOK chain via the
|
||||
// ProviderResolver interface (implemented by handlers package).
|
||||
// This avoids a circular dependency (sandbox → handlers → sandbox).
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"go.starlark.net/starlark"
|
||||
"go.starlark.net/starlarkstruct"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
)
|
||||
|
||||
// ─── Provider Resolution Interface ──────────
|
||||
|
||||
// ProviderResolution holds the resolved provider and config.
|
||||
// Mirrors handlers.ProviderResolution without importing it.
|
||||
type ProviderResolution struct {
|
||||
Provider providers.Provider
|
||||
Config providers.ProviderConfig
|
||||
ProviderID string
|
||||
Model string
|
||||
ConfigID string
|
||||
}
|
||||
|
||||
// ProviderResolver resolves a provider configuration from the BYOK chain.
|
||||
// Implemented by handlers via a thin adapter (set on Runner at startup).
|
||||
type ProviderResolver interface {
|
||||
Resolve(ctx context.Context, userID, channelID, providerConfigID, model string) (*ProviderResolution, error)
|
||||
}
|
||||
|
||||
// ─── Configuration ──────────────────────────
|
||||
|
||||
const providerDefaultMaxTokens = 4096
|
||||
|
||||
// ProviderModuleConfig holds per-package provider settings parsed from
|
||||
// the manifest's requires_provider field.
|
||||
type ProviderModuleConfig struct {
|
||||
// ProviderConfigID pins the extension to a specific provider config.
|
||||
// Empty string means use the BYOK resolution chain.
|
||||
ProviderConfigID string
|
||||
|
||||
// DefaultModel is the model to use when the script doesn't specify one.
|
||||
DefaultModel string
|
||||
}
|
||||
|
||||
// ParseRequiresProvider extracts ProviderModuleConfig from a manifest.
|
||||
//
|
||||
// Accepted formats:
|
||||
//
|
||||
// true → empty config (BYOK default)
|
||||
// {"model": "claude-3-haiku"} → default model
|
||||
// {"provider_config_id": "uuid", ...} → pinned provider
|
||||
func ParseRequiresProvider(manifest map[string]any) (ProviderModuleConfig, bool) {
|
||||
raw, ok := manifest["requires_provider"]
|
||||
if !ok {
|
||||
return ProviderModuleConfig{}, false
|
||||
}
|
||||
|
||||
// Boolean shorthand
|
||||
if b, ok := raw.(bool); ok {
|
||||
return ProviderModuleConfig{}, b
|
||||
}
|
||||
|
||||
// Object form
|
||||
m, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return ProviderModuleConfig{}, false
|
||||
}
|
||||
|
||||
cfg := ProviderModuleConfig{}
|
||||
if v, ok := m["provider_config_id"].(string); ok {
|
||||
cfg.ProviderConfigID = v
|
||||
}
|
||||
if v, ok := m["model"].(string); ok {
|
||||
cfg.DefaultModel = v
|
||||
}
|
||||
|
||||
return cfg, true
|
||||
}
|
||||
|
||||
// ─── Module Builder ─────────────────────────
|
||||
|
||||
// BuildProviderModule creates the "provider" Starlark module. Each call
|
||||
// to provider.complete() resolves a provider via the BYOK chain and
|
||||
// makes a synchronous (non-streaming) LLM completion call.
|
||||
func BuildProviderModule(
|
||||
ctx context.Context,
|
||||
resolver ProviderResolver,
|
||||
userID string,
|
||||
manifestCfg ProviderModuleConfig,
|
||||
) *starlarkstruct.Module {
|
||||
return MakeModule("provider", starlark.StringDict{
|
||||
"complete": starlark.NewBuiltin("provider.complete", func(
|
||||
thread *starlark.Thread, b *starlark.Builtin,
|
||||
args starlark.Tuple, kwargs []starlark.Tuple,
|
||||
) (starlark.Value, error) {
|
||||
var messagesList *starlark.List
|
||||
var model string
|
||||
var maxTokens int = providerDefaultMaxTokens
|
||||
var temperature starlark.Value = starlark.None
|
||||
|
||||
if err := starlark.UnpackArgs(b.Name(), args, kwargs,
|
||||
"messages", &messagesList,
|
||||
"model?", &model,
|
||||
"max_tokens?", &maxTokens,
|
||||
"temperature?", &temperature,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Apply default model from manifest
|
||||
if model == "" {
|
||||
model = manifestCfg.DefaultModel
|
||||
}
|
||||
|
||||
// Convert Starlark messages to provider messages
|
||||
msgs, err := starlarkToMessages(messagesList)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("provider.complete: %w", err)
|
||||
}
|
||||
if len(msgs) == 0 {
|
||||
return nil, fmt.Errorf("provider.complete: messages list is empty")
|
||||
}
|
||||
|
||||
// Resolve provider via BYOK chain
|
||||
res, err := resolver.Resolve(ctx, userID, "",
|
||||
manifestCfg.ProviderConfigID, model)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("provider.complete: %w", err)
|
||||
}
|
||||
|
||||
// Build request
|
||||
req := providers.CompletionRequest{
|
||||
Model: res.Model,
|
||||
Messages: msgs,
|
||||
MaxTokens: maxTokens,
|
||||
Stream: false,
|
||||
}
|
||||
|
||||
// Set temperature if provided
|
||||
if temperature != starlark.None {
|
||||
if f, ok := starlark.AsFloat(temperature); ok {
|
||||
req.Temperature = &f
|
||||
}
|
||||
}
|
||||
|
||||
// Make synchronous completion call
|
||||
resp, err := res.Provider.ChatCompletion(ctx, res.Config, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("provider.complete: LLM call failed: %w", err)
|
||||
}
|
||||
|
||||
return completionToStarlark(resp)
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Message Conversion ─────────────────────
|
||||
|
||||
// starlarkToMessages converts a Starlark list of dicts to provider Messages.
|
||||
// Each dict must have "role" (string) and "content" (string).
|
||||
func starlarkToMessages(list *starlark.List) ([]providers.Message, error) {
|
||||
if list == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
msgs := make([]providers.Message, 0, list.Len())
|
||||
iter := list.Iterate()
|
||||
defer iter.Done()
|
||||
|
||||
var item starlark.Value
|
||||
for iter.Next(&item) {
|
||||
dict, ok := item.(*starlark.Dict)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected dict in messages list, got %s", item.Type())
|
||||
}
|
||||
|
||||
roleVal, found, _ := dict.Get(starlark.String("role"))
|
||||
if !found {
|
||||
return nil, fmt.Errorf("message dict missing 'role' key")
|
||||
}
|
||||
role, ok := starlark.AsString(roleVal)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("message 'role' must be a string")
|
||||
}
|
||||
|
||||
contentVal, found, _ := dict.Get(starlark.String("content"))
|
||||
if !found {
|
||||
return nil, fmt.Errorf("message dict missing 'content' key")
|
||||
}
|
||||
content, ok := starlark.AsString(contentVal)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("message 'content' must be a string")
|
||||
}
|
||||
|
||||
msgs = append(msgs, providers.Message{
|
||||
Role: role,
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
|
||||
return msgs, nil
|
||||
}
|
||||
|
||||
// ─── Response Conversion ────────────────────
|
||||
|
||||
// completionToStarlark converts a provider CompletionResponse to a Starlark dict.
|
||||
func completionToStarlark(resp *providers.CompletionResponse) (starlark.Value, error) {
|
||||
d := starlark.NewDict(5)
|
||||
_ = d.SetKey(starlark.String("content"), starlark.String(resp.Content))
|
||||
_ = d.SetKey(starlark.String("model"), starlark.String(resp.Model))
|
||||
_ = d.SetKey(starlark.String("finish_reason"), starlark.String(resp.FinishReason))
|
||||
_ = d.SetKey(starlark.String("input_tokens"), starlark.MakeInt(resp.InputTokens))
|
||||
_ = d.SetKey(starlark.String("output_tokens"), starlark.MakeInt(resp.OutputTokens))
|
||||
return d, nil
|
||||
}
|
||||
241
server/sandbox/provider_module_test.go
Normal file
241
server/sandbox/provider_module_test.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go.starlark.net/starlark"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
)
|
||||
|
||||
// ─── ParseRequiresProvider ──────────────────
|
||||
|
||||
func TestParseRequiresProvider_Missing(t *testing.T) {
|
||||
_, ok := ParseRequiresProvider(map[string]any{})
|
||||
if ok {
|
||||
t.Error("missing requires_provider should return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRequiresProvider_BoolTrue(t *testing.T) {
|
||||
cfg, ok := ParseRequiresProvider(map[string]any{"requires_provider": true})
|
||||
if !ok {
|
||||
t.Fatal("boolean true should return ok=true")
|
||||
}
|
||||
if cfg.ProviderConfigID != "" || cfg.DefaultModel != "" {
|
||||
t.Errorf("boolean true should produce empty config, got %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRequiresProvider_BoolFalse(t *testing.T) {
|
||||
_, ok := ParseRequiresProvider(map[string]any{"requires_provider": false})
|
||||
if ok {
|
||||
t.Error("boolean false should return ok=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRequiresProvider_ObjectFull(t *testing.T) {
|
||||
cfg, ok := ParseRequiresProvider(map[string]any{
|
||||
"requires_provider": map[string]any{
|
||||
"provider_config_id": "cfg-uuid-123",
|
||||
"model": "claude-3-haiku",
|
||||
},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("object form should return ok=true")
|
||||
}
|
||||
if cfg.ProviderConfigID != "cfg-uuid-123" {
|
||||
t.Errorf("provider_config_id = %q", cfg.ProviderConfigID)
|
||||
}
|
||||
if cfg.DefaultModel != "claude-3-haiku" {
|
||||
t.Errorf("model = %q", cfg.DefaultModel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRequiresProvider_ObjectPartial(t *testing.T) {
|
||||
cfg, ok := ParseRequiresProvider(map[string]any{
|
||||
"requires_provider": map[string]any{
|
||||
"model": "gpt-4o-mini",
|
||||
},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("should return ok=true")
|
||||
}
|
||||
if cfg.ProviderConfigID != "" {
|
||||
t.Errorf("provider_config_id should be empty, got %q", cfg.ProviderConfigID)
|
||||
}
|
||||
if cfg.DefaultModel != "gpt-4o-mini" {
|
||||
t.Errorf("model = %q", cfg.DefaultModel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRequiresProvider_InvalidType(t *testing.T) {
|
||||
_, ok := ParseRequiresProvider(map[string]any{"requires_provider": "yes"})
|
||||
if ok {
|
||||
t.Error("string value should return false")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── starlarkToMessages ─────────────────────
|
||||
|
||||
func TestStarlarkToMessages_Valid(t *testing.T) {
|
||||
list := starlark.NewList(nil)
|
||||
|
||||
msg1 := starlark.NewDict(2)
|
||||
_ = msg1.SetKey(starlark.String("role"), starlark.String("system"))
|
||||
_ = msg1.SetKey(starlark.String("content"), starlark.String("You are helpful."))
|
||||
list.Append(msg1)
|
||||
|
||||
msg2 := starlark.NewDict(2)
|
||||
_ = msg2.SetKey(starlark.String("role"), starlark.String("user"))
|
||||
_ = msg2.SetKey(starlark.String("content"), starlark.String("Hello"))
|
||||
list.Append(msg2)
|
||||
|
||||
msgs, err := starlarkToMessages(list)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(msgs) != 2 {
|
||||
t.Fatalf("expected 2 messages, got %d", len(msgs))
|
||||
}
|
||||
if msgs[0].Role != "system" || msgs[0].Content != "You are helpful." {
|
||||
t.Errorf("msg[0] = %+v", msgs[0])
|
||||
}
|
||||
if msgs[1].Role != "user" || msgs[1].Content != "Hello" {
|
||||
t.Errorf("msg[1] = %+v", msgs[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarlarkToMessages_Empty(t *testing.T) {
|
||||
list := starlark.NewList(nil)
|
||||
msgs, err := starlarkToMessages(list)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(msgs) != 0 {
|
||||
t.Errorf("expected 0 messages, got %d", len(msgs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarlarkToMessages_Nil(t *testing.T) {
|
||||
msgs, err := starlarkToMessages(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if msgs != nil {
|
||||
t.Errorf("expected nil, got %v", msgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarlarkToMessages_MissingRole(t *testing.T) {
|
||||
list := starlark.NewList(nil)
|
||||
msg := starlark.NewDict(1)
|
||||
_ = msg.SetKey(starlark.String("content"), starlark.String("hello"))
|
||||
list.Append(msg)
|
||||
|
||||
_, err := starlarkToMessages(list)
|
||||
if err == nil {
|
||||
t.Error("expected error for missing role")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarlarkToMessages_MissingContent(t *testing.T) {
|
||||
list := starlark.NewList(nil)
|
||||
msg := starlark.NewDict(1)
|
||||
_ = msg.SetKey(starlark.String("role"), starlark.String("user"))
|
||||
list.Append(msg)
|
||||
|
||||
_, err := starlarkToMessages(list)
|
||||
if err == nil {
|
||||
t.Error("expected error for missing content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarlarkToMessages_NonDictItem(t *testing.T) {
|
||||
list := starlark.NewList([]starlark.Value{starlark.String("not a dict")})
|
||||
|
||||
_, err := starlarkToMessages(list)
|
||||
if err == nil {
|
||||
t.Error("expected error for non-dict item")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarlarkToMessages_NonStringRole(t *testing.T) {
|
||||
list := starlark.NewList(nil)
|
||||
msg := starlark.NewDict(2)
|
||||
_ = msg.SetKey(starlark.String("role"), starlark.MakeInt(42))
|
||||
_ = msg.SetKey(starlark.String("content"), starlark.String("hello"))
|
||||
list.Append(msg)
|
||||
|
||||
_, err := starlarkToMessages(list)
|
||||
if err == nil {
|
||||
t.Error("expected error for non-string role")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── completionToStarlark ───────────────────
|
||||
|
||||
func TestCompletionToStarlark(t *testing.T) {
|
||||
resp := &providers.CompletionResponse{
|
||||
Content: "Hello! How can I help?",
|
||||
Model: "claude-3-haiku-20240307",
|
||||
FinishReason: "stop",
|
||||
InputTokens: 15,
|
||||
OutputTokens: 8,
|
||||
}
|
||||
|
||||
val, err := completionToStarlark(resp)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
d, ok := val.(*starlark.Dict)
|
||||
if !ok {
|
||||
t.Fatalf("expected dict, got %T", val)
|
||||
}
|
||||
|
||||
// content
|
||||
cv, found, _ := d.Get(starlark.String("content"))
|
||||
if !found {
|
||||
t.Fatal("missing 'content'")
|
||||
}
|
||||
if s, _ := starlark.AsString(cv); s != "Hello! How can I help?" {
|
||||
t.Errorf("content = %q", s)
|
||||
}
|
||||
|
||||
// model
|
||||
mv, found, _ := d.Get(starlark.String("model"))
|
||||
if !found {
|
||||
t.Fatal("missing 'model'")
|
||||
}
|
||||
if s, _ := starlark.AsString(mv); s != "claude-3-haiku-20240307" {
|
||||
t.Errorf("model = %q", s)
|
||||
}
|
||||
|
||||
// finish_reason
|
||||
fv, found, _ := d.Get(starlark.String("finish_reason"))
|
||||
if !found {
|
||||
t.Fatal("missing 'finish_reason'")
|
||||
}
|
||||
if s, _ := starlark.AsString(fv); s != "stop" {
|
||||
t.Errorf("finish_reason = %q", s)
|
||||
}
|
||||
|
||||
// input_tokens
|
||||
iv, found, _ := d.Get(starlark.String("input_tokens"))
|
||||
if !found {
|
||||
t.Fatal("missing 'input_tokens'")
|
||||
}
|
||||
if i, err := starlark.AsInt32(iv); err != nil || i != 15 {
|
||||
t.Errorf("input_tokens = %v (err=%v)", i, err)
|
||||
}
|
||||
|
||||
// output_tokens
|
||||
ov, found, _ := d.Get(starlark.String("output_tokens"))
|
||||
if !found {
|
||||
t.Fatal("missing 'output_tokens'")
|
||||
}
|
||||
if i, err := starlark.AsInt32(ov); err != nil || i != 8 {
|
||||
t.Errorf("output_tokens = %v (err=%v)", i, err)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,12 @@
|
||||
// v0.29.0 CS3: Runner loads a package's Starlark script, assembles
|
||||
// the module set based on granted permissions, and executes it.
|
||||
//
|
||||
// v0.29.1 CS0: Wires api.http permission to BuildHTTPModule with
|
||||
// network_access config from package manifest.
|
||||
//
|
||||
// v0.29.1 CS2: Adds RunContext for per-invocation state (user_id),
|
||||
// vault for provider key decryption, and provider.complete module.
|
||||
//
|
||||
// The runner is the bridge between the package/permission system
|
||||
// and the sandboxed Starlark interpreter.
|
||||
package sandbox
|
||||
@@ -18,11 +24,24 @@ import (
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// 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
|
||||
|
||||
// ChannelID is the channel context, if any. Used for provider
|
||||
// resolution when a channel has a pinned provider config.
|
||||
ChannelID string
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// NewRunner creates a runner with the given sandbox and dependencies.
|
||||
@@ -38,12 +57,20 @@ func (r *Runner) SetNotifier(n NotificationSender) {
|
||||
r.notifier = n
|
||||
}
|
||||
|
||||
// SetProviderResolver attaches the provider resolver for the provider.complete module.
|
||||
func (r *Runner) SetProviderResolver(pr ProviderResolver) {
|
||||
r.resolver = pr
|
||||
}
|
||||
|
||||
// ExecPackage loads a package's script from its manifest and executes it
|
||||
// with modules gated by the package's 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) (*Result, error) {
|
||||
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)
|
||||
@@ -59,7 +86,7 @@ func (r *Runner) ExecPackage(ctx context.Context, pkg *store.PackageRegistration
|
||||
}
|
||||
|
||||
// Build modules based on granted permissions
|
||||
modules, err := r.buildModules(ctx, pkg.ID)
|
||||
modules, err := r.buildModules(ctx, pkg.ID, pkg.Manifest, rc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to build modules for %q: %w", pkg.ID, err)
|
||||
}
|
||||
@@ -75,9 +102,11 @@ func (r *Runner) ExecPackage(ctx context.Context, pkg *store.PackageRegistration
|
||||
// 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) (starlark.Value, string, error) {
|
||||
result, err := r.ExecPackage(ctx, pkg)
|
||||
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
|
||||
}
|
||||
@@ -97,7 +126,10 @@ func (r *Runner) CallEntryPoint(ctx context.Context, pkg *store.PackageRegistrat
|
||||
}
|
||||
|
||||
// buildModules assembles the module map based on granted permissions.
|
||||
func (r *Runner) buildModules(ctx context.Context, packageID string) (map[string]starlark.Value, error) {
|
||||
// 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) {
|
||||
if r.stores.ExtPermissions == nil {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -119,11 +151,21 @@ func (r *Runner) buildModules(ctx context.Context, packageID string) (map[string
|
||||
modules["notifications"] = BuildNotificationsModule(ctx, r.notifier, packageID)
|
||||
}
|
||||
|
||||
// Future permissions (CS4+):
|
||||
case models.ExtPermAPIHTTP:
|
||||
httpCfg := ParseNetworkAccess(manifest)
|
||||
modules["http"] = BuildHTTPModule(ctx, httpCfg)
|
||||
|
||||
case models.ExtPermProviderComplete:
|
||||
if r.resolver != nil && rc != nil && rc.UserID != "" {
|
||||
provCfg, ok := ParseRequiresProvider(manifest)
|
||||
if ok {
|
||||
modules["provider"] = BuildProviderModule(ctx, r.resolver, rc.UserID, provCfg)
|
||||
}
|
||||
}
|
||||
|
||||
// Future permissions:
|
||||
// case models.ExtPermDBRead, models.ExtPermDBWrite:
|
||||
// modules["db"] = BuildDBModule(...)
|
||||
// case models.ExtPermAPIHTTP:
|
||||
// modules["http"] = BuildHTTPModule(...)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -302,7 +302,8 @@ func (e *Executor) executeStarlark(ctx context.Context, task models.Task, run *m
|
||||
|
||||
// Run the script and call on_run entry point
|
||||
// Build a context dict with task info
|
||||
val, output, err := e.runner.CallEntryPoint(ctx, pkg, "on_run", nil, nil)
|
||||
rc := &sandbox.RunContext{UserID: task.OwnerID, ChannelID: channelID}
|
||||
val, output, err := e.runner.CallEntryPoint(ctx, pkg, "on_run", nil, nil, rc)
|
||||
|
||||
wallClock := int(time.Since(startTime).Seconds())
|
||||
status := "completed"
|
||||
|
||||
Reference in New Issue
Block a user