Feat v0.8.2 capability negotiation (#69)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Has been skipped
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-go-pg (push) Successful in 2m47s
CI/CD / test-sqlite (push) Successful in 2m52s
CI/CD / build-and-deploy (push) Successful in 30s
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Has been skipped
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-go-pg (push) Successful in 2m47s
CI/CD / test-sqlite (push) Successful in 2m52s
CI/CD / build-and-deploy (push) Successful in 30s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #69.
This commit is contained in:
40
CHANGELOG.md
40
CHANGELOG.md
@@ -2,6 +2,46 @@
|
|||||||
|
|
||||||
All notable changes to Armature are documented here.
|
All notable changes to Armature are documented here.
|
||||||
|
|
||||||
|
## v0.8.2 — Capability Negotiation
|
||||||
|
|
||||||
|
Extensions declare environment requirements in their manifest. The kernel
|
||||||
|
validates at install time and exposes a runtime query for graceful degradation.
|
||||||
|
|
||||||
|
**Manifest: `capabilities` block**
|
||||||
|
|
||||||
|
- `capabilities.required` — array of capability names. Install is rejected
|
||||||
|
(HTTP 422) if any are unavailable. Rollback deletes the package row.
|
||||||
|
- `capabilities.optional` — array of capability names. Install succeeds
|
||||||
|
regardless; extensions query at runtime via `settings.has_capability()`.
|
||||||
|
|
||||||
|
**Detected capabilities:** `postgres`, `pgvector`, `object_storage`, `s3`,
|
||||||
|
`workspace`.
|
||||||
|
|
||||||
|
**Starlark API**
|
||||||
|
|
||||||
|
- `settings.has_capability(name)` — returns `True` or `False`. Always
|
||||||
|
available (no permission required).
|
||||||
|
|
||||||
|
**Admin API**
|
||||||
|
|
||||||
|
- `GET /api/v1/admin/capabilities` — re-probes and returns current state.
|
||||||
|
|
||||||
|
**Internal**
|
||||||
|
|
||||||
|
- New: `handlers/capabilities.go` (detection, parsing, validation, admin handler).
|
||||||
|
- New: `handlers/capabilities_test.go` (14 tests).
|
||||||
|
- New: `sandbox/settings_module_test.go` (4 tests).
|
||||||
|
- Modified: `handlers/packages.go` — replaced stub `checkCapabilities` with
|
||||||
|
real validation; `SetCapabilities` setter.
|
||||||
|
- Modified: `handlers/packages_bundled.go` — bundled packages with unmet
|
||||||
|
required capabilities are skipped on startup.
|
||||||
|
- Modified: `sandbox/settings_module.go` — `has_capability` builtin.
|
||||||
|
- Modified: `sandbox/runner.go` — `capabilities` field + `SetCapabilities` setter.
|
||||||
|
- Modified: `server/main.go` — `DetectCapabilities` at startup, wired to
|
||||||
|
runner and package handler, admin route registered.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v0.8.1 — Workspace Module
|
## v0.8.1 — Workspace Module
|
||||||
|
|
||||||
New `workspace` sandbox module. Managed disk directories for extensions
|
New `workspace` sandbox module. Managed disk directories for extensions
|
||||||
|
|||||||
11
ROADMAP.md
11
ROADMAP.md
@@ -410,16 +410,19 @@ New: `sandbox/workspace_module.go`. Modified: runner wiring, permission
|
|||||||
constants, config (`WORKSPACE_ROOT`, `WORKSPACE_QUOTA_MB`), main.go
|
constants, config (`WORKSPACE_ROOT`, `WORKSPACE_QUOTA_MB`), main.go
|
||||||
startup. 16 new tests.
|
startup. 16 new tests.
|
||||||
|
|
||||||
**v0.8.2 — Capability Negotiation**
|
**v0.8.2 — Capability Negotiation** ✅
|
||||||
|
|
||||||
Extensions declare environment requirements in manifest (`capabilities.required`,
|
Extensions declare environment requirements in manifest (`capabilities.required`,
|
||||||
`capabilities.optional`). Kernel validates at install time. Runtime query
|
`capabilities.optional`). Kernel validates at install time — required caps
|
||||||
via `settings.has_capability()`. Admin endpoint: `GET /admin/capabilities`.
|
reject with 422, optional caps log a warning. Runtime query via
|
||||||
|
`settings.has_capability()`. Admin endpoint: `GET /admin/capabilities`.
|
||||||
|
|
||||||
Detected capabilities: `pgvector`, `workspace`, `object_storage`, `s3`,
|
Detected capabilities: `pgvector`, `workspace`, `object_storage`, `s3`,
|
||||||
`postgres`.
|
`postgres`.
|
||||||
|
|
||||||
New: `handlers/capabilities.go`. Modified: install handler, settings module.
|
New: `handlers/capabilities.go`, `handlers/capabilities_test.go`,
|
||||||
|
`sandbox/settings_module_test.go`. Modified: install handler, bundled
|
||||||
|
handler, settings module, runner, main.go. 18 new tests.
|
||||||
|
|
||||||
**v0.8.3 — Vector Column Type**
|
**v0.8.3 — Vector Column Type**
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ Only `manifest.json` is required. All other directories are optional and include
|
|||||||
| `settings` | User-configurable settings with type, label, description, default |
|
| `settings` | User-configurable settings with type, label, description, default |
|
||||||
| `hooks` | Event bus subscription patterns |
|
| `hooks` | Event bus subscription patterns |
|
||||||
| `exports` | Functions exported by library packages |
|
| `exports` | Functions exported by library packages |
|
||||||
|
| `capabilities` | Environment requirements: `{"required": [...], "optional": [...]}` |
|
||||||
| `schema_version` | Integer for additive schema migrations |
|
| `schema_version` | Integer for additive schema migrations |
|
||||||
|
|
||||||
## Package Lifecycle
|
## Package Lifecycle
|
||||||
|
|||||||
146
server/handlers/capabilities.go
Normal file
146
server/handlers/capabilities.go
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"armature/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Capability detection ────────────────────────────────────────────
|
||||||
|
|
||||||
|
// DetectCapabilities probes the runtime environment and returns a map
|
||||||
|
// of capability name → available. Called once at startup and again on
|
||||||
|
// each admin GET /capabilities request.
|
||||||
|
func DetectCapabilities(db *sql.DB, isPostgres bool, objStore storage.ObjectStore, workspaceRoot string) map[string]bool {
|
||||||
|
caps := map[string]bool{
|
||||||
|
"postgres": false,
|
||||||
|
"pgvector": false,
|
||||||
|
"object_storage": false,
|
||||||
|
"s3": false,
|
||||||
|
"workspace": false,
|
||||||
|
}
|
||||||
|
|
||||||
|
// postgres: dialect check
|
||||||
|
if isPostgres {
|
||||||
|
caps["postgres"] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// pgvector: query pg_extension (PG only)
|
||||||
|
if isPostgres && db != nil {
|
||||||
|
var one int
|
||||||
|
err := db.QueryRow("SELECT 1 FROM pg_extension WHERE extname = 'vector'").Scan(&one)
|
||||||
|
caps["pgvector"] = err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// object_storage: configured and healthy
|
||||||
|
if objStore != nil {
|
||||||
|
if err := objStore.Healthy(context.Background()); err == nil {
|
||||||
|
caps["object_storage"] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// s3: specific backend check
|
||||||
|
if objStore != nil && objStore.Backend() == "s3" {
|
||||||
|
caps["s3"] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// workspace: root exists and is writable
|
||||||
|
if workspaceRoot != "" && storage.IsPathWritable(workspaceRoot) {
|
||||||
|
caps["workspace"] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return caps
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Manifest parsing ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Capabilities holds the parsed capabilities block from a package manifest.
|
||||||
|
type Capabilities struct {
|
||||||
|
Required []string
|
||||||
|
Optional []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseCapabilities extracts capabilities.required and capabilities.optional
|
||||||
|
// from a manifest map. Returns zero value + false if no capabilities block.
|
||||||
|
func ParseCapabilities(manifest map[string]any) (Capabilities, bool) {
|
||||||
|
capsRaw, ok := manifest["capabilities"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return Capabilities{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
var caps Capabilities
|
||||||
|
if req, ok := capsRaw["required"].([]any); ok {
|
||||||
|
for _, v := range req {
|
||||||
|
if s, ok := v.(string); ok && s != "" {
|
||||||
|
caps.Required = append(caps.Required, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if opt, ok := capsRaw["optional"].([]any); ok {
|
||||||
|
for _, v := range opt {
|
||||||
|
if s, ok := v.(string); ok && s != "" {
|
||||||
|
caps.Optional = append(caps.Optional, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return caps, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckRequiredCapabilities returns the subset of required capabilities
|
||||||
|
// that are not present in the detected set.
|
||||||
|
func CheckRequiredCapabilities(required []string, detected map[string]bool) []string {
|
||||||
|
var missing []string
|
||||||
|
for _, r := range required {
|
||||||
|
if !detected[r] {
|
||||||
|
missing = append(missing, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return missing
|
||||||
|
}
|
||||||
|
|
||||||
|
// capabilityHelpText returns operator-facing remediation hints keyed by
|
||||||
|
// capability name.
|
||||||
|
func capabilityHelpText(missing []string) map[string]string {
|
||||||
|
hints := map[string]string{
|
||||||
|
"postgres": "Switch DB_DRIVER to postgres and configure DATABASE_URL.",
|
||||||
|
"pgvector": "Run `CREATE EXTENSION vector;` in your PostgreSQL database.",
|
||||||
|
"object_storage": "Configure STORAGE_BACKEND (pvc or s3) and ensure the backend is healthy.",
|
||||||
|
"s3": "Set STORAGE_BACKEND=s3 and configure S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY, S3_SECRET_KEY.",
|
||||||
|
"workspace": "Set WORKSPACE_ROOT to a writable directory path.",
|
||||||
|
}
|
||||||
|
result := make(map[string]string, len(missing))
|
||||||
|
for _, m := range missing {
|
||||||
|
if h, ok := hints[m]; ok {
|
||||||
|
result[m] = h
|
||||||
|
} else {
|
||||||
|
result[m] = "Unknown capability — check package documentation."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admin endpoint ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// CapabilitiesHandler serves the admin capabilities endpoint.
|
||||||
|
type CapabilitiesHandler struct {
|
||||||
|
detect func() map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCapabilitiesHandler creates a handler that re-probes capabilities on
|
||||||
|
// each request so the response reflects current state.
|
||||||
|
func NewCapabilitiesHandler(detect func() map[string]bool) *CapabilitiesHandler {
|
||||||
|
return &CapabilitiesHandler{detect: detect}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCapabilities returns detected environment capabilities.
|
||||||
|
// GET /api/v1/admin/capabilities
|
||||||
|
func (h *CapabilitiesHandler) GetCapabilities(c *gin.Context) {
|
||||||
|
caps := h.detect()
|
||||||
|
log.Printf("[capabilities] probed: %v", caps)
|
||||||
|
c.JSON(200, caps)
|
||||||
|
}
|
||||||
205
server/handlers/capabilities_test.go
Normal file
205
server/handlers/capabilities_test.go
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── ParseCapabilities ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestParseCapabilities_Full(t *testing.T) {
|
||||||
|
manifest := map[string]any{
|
||||||
|
"id": "test-pkg",
|
||||||
|
"capabilities": map[string]any{
|
||||||
|
"required": []any{"postgres", "pgvector"},
|
||||||
|
"optional": []any{"s3"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
caps, ok := ParseCapabilities(manifest)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected ok=true")
|
||||||
|
}
|
||||||
|
if len(caps.Required) != 2 || caps.Required[0] != "postgres" || caps.Required[1] != "pgvector" {
|
||||||
|
t.Errorf("required = %v, want [postgres pgvector]", caps.Required)
|
||||||
|
}
|
||||||
|
if len(caps.Optional) != 1 || caps.Optional[0] != "s3" {
|
||||||
|
t.Errorf("optional = %v, want [s3]", caps.Optional)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCapabilities_Missing(t *testing.T) {
|
||||||
|
manifest := map[string]any{"id": "test-pkg"}
|
||||||
|
_, ok := ParseCapabilities(manifest)
|
||||||
|
if ok {
|
||||||
|
t.Error("expected ok=false when no capabilities block")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCapabilities_EmptyArrays(t *testing.T) {
|
||||||
|
manifest := map[string]any{
|
||||||
|
"capabilities": map[string]any{
|
||||||
|
"required": []any{},
|
||||||
|
"optional": []any{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
caps, ok := ParseCapabilities(manifest)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected ok=true")
|
||||||
|
}
|
||||||
|
if len(caps.Required) != 0 {
|
||||||
|
t.Errorf("required should be empty, got %v", caps.Required)
|
||||||
|
}
|
||||||
|
if len(caps.Optional) != 0 {
|
||||||
|
t.Errorf("optional should be empty, got %v", caps.Optional)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCapabilities_IgnoresEmpty(t *testing.T) {
|
||||||
|
manifest := map[string]any{
|
||||||
|
"capabilities": map[string]any{
|
||||||
|
"required": []any{"pgvector", "", "postgres"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
caps, _ := ParseCapabilities(manifest)
|
||||||
|
if len(caps.Required) != 2 {
|
||||||
|
t.Errorf("expected 2 entries (empty filtered), got %v", caps.Required)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CheckRequiredCapabilities ───────────────────────────────────────
|
||||||
|
|
||||||
|
func TestCheckRequired_AllMet(t *testing.T) {
|
||||||
|
detected := map[string]bool{"postgres": true, "pgvector": true, "s3": true}
|
||||||
|
missing := CheckRequiredCapabilities([]string{"postgres", "pgvector"}, detected)
|
||||||
|
if len(missing) != 0 {
|
||||||
|
t.Errorf("expected no missing, got %v", missing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckRequired_SomeUnmet(t *testing.T) {
|
||||||
|
detected := map[string]bool{"postgres": true, "pgvector": false, "s3": false}
|
||||||
|
missing := CheckRequiredCapabilities([]string{"postgres", "pgvector", "s3"}, detected)
|
||||||
|
if len(missing) != 2 {
|
||||||
|
t.Errorf("expected 2 missing, got %v", missing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckRequired_EmptyRequired(t *testing.T) {
|
||||||
|
detected := map[string]bool{"postgres": true}
|
||||||
|
missing := CheckRequiredCapabilities(nil, detected)
|
||||||
|
if len(missing) != 0 {
|
||||||
|
t.Errorf("expected no missing for nil required, got %v", missing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckRequired_UnknownCap(t *testing.T) {
|
||||||
|
detected := map[string]bool{"postgres": true}
|
||||||
|
missing := CheckRequiredCapabilities([]string{"gpu"}, detected)
|
||||||
|
if len(missing) != 1 || missing[0] != "gpu" {
|
||||||
|
t.Errorf("expected [gpu], got %v", missing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── capabilityHelpText ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestCapabilityHelpText_KnownCaps(t *testing.T) {
|
||||||
|
hints := capabilityHelpText([]string{"pgvector", "s3"})
|
||||||
|
if len(hints) != 2 {
|
||||||
|
t.Fatalf("expected 2 hints, got %d", len(hints))
|
||||||
|
}
|
||||||
|
if hints["pgvector"] == "" {
|
||||||
|
t.Error("pgvector hint is empty")
|
||||||
|
}
|
||||||
|
if hints["s3"] == "" {
|
||||||
|
t.Error("s3 hint is empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCapabilityHelpText_UnknownCap(t *testing.T) {
|
||||||
|
hints := capabilityHelpText([]string{"gpu"})
|
||||||
|
if hints["gpu"] == "" {
|
||||||
|
t.Error("expected fallback hint for unknown cap")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DetectCapabilities (SQLite path — no real DB) ───────────────────
|
||||||
|
|
||||||
|
func TestDetectCapabilities_SQLite_NilStore(t *testing.T) {
|
||||||
|
caps := DetectCapabilities(nil, false, nil, "")
|
||||||
|
if caps["postgres"] {
|
||||||
|
t.Error("postgres should be false on SQLite")
|
||||||
|
}
|
||||||
|
if caps["pgvector"] {
|
||||||
|
t.Error("pgvector should be false on SQLite")
|
||||||
|
}
|
||||||
|
if caps["object_storage"] {
|
||||||
|
t.Error("object_storage should be false with nil store")
|
||||||
|
}
|
||||||
|
if caps["s3"] {
|
||||||
|
t.Error("s3 should be false with nil store")
|
||||||
|
}
|
||||||
|
if caps["workspace"] {
|
||||||
|
t.Error("workspace should be false with empty root")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectCapabilities_WorkspaceWritable(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
caps := DetectCapabilities(nil, false, nil, tmpDir)
|
||||||
|
if !caps["workspace"] {
|
||||||
|
t.Error("workspace should be true for writable temp dir")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectCapabilities_WorkspaceUnwritable(t *testing.T) {
|
||||||
|
caps := DetectCapabilities(nil, false, nil, "/nonexistent/path/xyz")
|
||||||
|
if caps["workspace"] {
|
||||||
|
t.Error("workspace should be false for nonexistent path")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admin endpoint ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestGetCapabilities_HTTP(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
h := NewCapabilitiesHandler(func() map[string]bool {
|
||||||
|
return map[string]bool{
|
||||||
|
"postgres": false,
|
||||||
|
"pgvector": false,
|
||||||
|
"object_storage": true,
|
||||||
|
"s3": false,
|
||||||
|
"workspace": true,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
r := gin.New()
|
||||||
|
r.GET("/api/v1/admin/capabilities", h.GetCapabilities)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/capabilities", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result map[string]bool
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result["postgres"] {
|
||||||
|
t.Error("postgres should be false")
|
||||||
|
}
|
||||||
|
if !result["object_storage"] {
|
||||||
|
t.Error("object_storage should be true")
|
||||||
|
}
|
||||||
|
if !result["workspace"] {
|
||||||
|
t.Error("workspace should be true")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ type PackageHandler struct {
|
|||||||
sandbox *sandbox.Sandbox
|
sandbox *sandbox.Sandbox
|
||||||
runner *sandbox.Runner
|
runner *sandbox.Runner
|
||||||
hub *events.Hub
|
hub *events.Hub
|
||||||
|
capabilities map[string]bool // detected environment capabilities
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPackageHandler(s store.Stores, packagesDir ...string) *PackageHandler {
|
func NewPackageHandler(s store.Stores, packagesDir ...string) *PackageHandler {
|
||||||
@@ -69,6 +70,12 @@ func (h *PackageHandler) SetHub(hub *events.Hub) {
|
|||||||
h.hub = hub
|
h.hub = hub
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetCapabilities stores the detected environment capabilities map.
|
||||||
|
// Used at install time to validate manifest capabilities.required.
|
||||||
|
func (h *PackageHandler) SetCapabilities(caps map[string]bool) {
|
||||||
|
h.capabilities = caps
|
||||||
|
}
|
||||||
|
|
||||||
// broadcastPackageChanged emits a package.changed event to all clients.
|
// broadcastPackageChanged emits a package.changed event to all clients.
|
||||||
func (h *PackageHandler) broadcastPackageChanged(action, id string) {
|
func (h *PackageHandler) broadcastPackageChanged(action, id string) {
|
||||||
if h.hub == nil {
|
if h.hub == nil {
|
||||||
@@ -286,8 +293,23 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 10: Check capabilities → mark dormant if unmet
|
// Phase 10: Validate required capabilities
|
||||||
isDormant := h.checkCapabilities(c, pkgID, manifest, &preservedEnabled)
|
if caps, ok := ParseCapabilities(manifest); ok {
|
||||||
|
missing := CheckRequiredCapabilities(caps.Required, h.capabilities)
|
||||||
|
if len(missing) > 0 {
|
||||||
|
h.stores.Packages.Delete(c.Request.Context(), pkgID)
|
||||||
|
c.JSON(http.StatusUnprocessableEntity, gin.H{
|
||||||
|
"error": "missing required capabilities",
|
||||||
|
"missing": missing,
|
||||||
|
"help": capabilityHelpText(missing),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
unmetOpt := CheckRequiredCapabilities(caps.Optional, h.capabilities)
|
||||||
|
if len(unmetOpt) > 0 {
|
||||||
|
log.Printf("[packages] %s: optional capabilities unavailable: %v", pkgID, unmetOpt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
resp := gin.H{
|
resp := gin.H{
|
||||||
"id": pkgID,
|
"id": pkgID,
|
||||||
@@ -297,9 +319,6 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
|||||||
"source": pkgSource,
|
"source": pkgSource,
|
||||||
"enabled": preservedEnabled,
|
"enabled": preservedEnabled,
|
||||||
}
|
}
|
||||||
if isDormant {
|
|
||||||
resp["status"] = "dormant"
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, resp)
|
c.JSON(http.StatusOK, resp)
|
||||||
h.broadcastPackageChanged("installed", pkgID)
|
h.broadcastPackageChanged("installed", pkgID)
|
||||||
}
|
}
|
||||||
@@ -591,7 +610,7 @@ func (h *PackageHandler) resolveDependencies(c *gin.Context, pkgID string, manif
|
|||||||
pkgPath := filepath.Join(h.bundledDir, libID+".pkg")
|
pkgPath := filepath.Join(h.bundledDir, libID+".pkg")
|
||||||
if _, statErr := os.Stat(pkgPath); statErr == nil {
|
if _, statErr := os.Stat(pkgPath); statErr == nil {
|
||||||
log.Printf("[packages] auto-installing bundled dependency %s for %s", libID, pkgID)
|
log.Printf("[packages] auto-installing bundled dependency %s for %s", libID, pkgID)
|
||||||
if _, installErr := installBundledPackage(pkgPath, h.packagesDir, h.stores, h.runner, c.GetString("user_id")); installErr != nil {
|
if _, installErr := installBundledPackage(pkgPath, h.packagesDir, h.stores, h.runner, c.GetString("user_id"), h.capabilities); installErr != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
"error": fmt.Sprintf("dependency %s auto-install failed: %v", libID, installErr),
|
"error": fmt.Sprintf("dependency %s auto-install failed: %v", libID, installErr),
|
||||||
})
|
})
|
||||||
@@ -634,27 +653,6 @@ func (h *PackageHandler) resolveDependencies(c *gin.Context, pkgID string, manif
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkCapabilities marks a package dormant if it declares unmet `requires` entries.
|
|
||||||
func (h *PackageHandler) checkCapabilities(c *gin.Context, pkgID string, manifest map[string]any, preservedEnabled *bool) bool {
|
|
||||||
knownCaps := map[string]bool{}
|
|
||||||
var unmetReqs []string
|
|
||||||
if reqs, ok := manifest["requires"].([]any); ok && len(reqs) > 0 {
|
|
||||||
for _, r := range reqs {
|
|
||||||
req, _ := r.(string)
|
|
||||||
if req != "" && !knownCaps[req] {
|
|
||||||
unmetReqs = append(unmetReqs, req)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(unmetReqs) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
h.stores.Packages.SetStatus(c.Request.Context(), pkgID, "dormant")
|
|
||||||
h.stores.Packages.SetEnabled(c.Request.Context(), pkgID, false)
|
|
||||||
*preservedEnabled = false
|
|
||||||
log.Printf("[packages] %s: dormant (unmet requires: %v)", pkgID, unmetReqs)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractableRelPath returns the relative path for a zip entry if it
|
// extractableRelPath returns the relative path for a zip entry if it
|
||||||
// belongs to an extractable directory (js/, css/, assets/, star/) or
|
// belongs to an extractable directory (js/, css/, assets/, star/) or
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ var defaultBundledPackages = map[string]bool{}
|
|||||||
//
|
//
|
||||||
// Design: install-once, skip-if-present. If an admin uninstalls a bundled
|
// Design: install-once, skip-if-present. If an admin uninstalls a bundled
|
||||||
// package, it won't be re-installed on the next restart.
|
// package, it won't be re-installed on the next restart.
|
||||||
func InstallBundledPackages(bundledDir, packagesDir, allowlist string, stores store.Stores, runner *sandbox.Runner) {
|
func InstallBundledPackages(bundledDir, packagesDir, allowlist string, stores store.Stores, runner *sandbox.Runner, detectedCaps map[string]bool) {
|
||||||
entries, err := os.ReadDir(bundledDir)
|
entries, err := os.ReadDir(bundledDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
@@ -82,7 +82,7 @@ func InstallBundledPackages(bundledDir, packagesDir, allowlist string, stores st
|
|||||||
}
|
}
|
||||||
|
|
||||||
pkgPath := filepath.Join(bundledDir, entry.Name())
|
pkgPath := filepath.Join(bundledDir, entry.Name())
|
||||||
result, err := installBundledPackage(pkgPath, packagesDir, stores, runner, adminUserID)
|
result, err := installBundledPackage(pkgPath, packagesDir, stores, runner, adminUserID, detectedCaps)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[bundled] ⚠️ Failed to install %s: %v", entry.Name(), err)
|
log.Printf("[bundled] ⚠️ Failed to install %s: %v", entry.Name(), err)
|
||||||
continue
|
continue
|
||||||
@@ -129,7 +129,7 @@ func parseBundleAllowlist(s string) map[string]bool {
|
|||||||
|
|
||||||
// installBundledPackage installs a single .pkg archive if its package ID
|
// installBundledPackage installs a single .pkg archive if its package ID
|
||||||
// doesn't already exist in the database. Returns "installed" or "skipped".
|
// doesn't already exist in the database. Returns "installed" or "skipped".
|
||||||
func installBundledPackage(pkgPath, packagesDir string, stores store.Stores, runner *sandbox.Runner, adminUserID string) (string, error) {
|
func installBundledPackage(pkgPath, packagesDir string, stores store.Stores, runner *sandbox.Runner, adminUserID string, detectedCaps map[string]bool) (string, error) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Open as zip
|
// Open as zip
|
||||||
@@ -237,20 +237,17 @@ func installBundledPackage(pkgPath, packagesDir string, stores store.Stores, run
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle dormant status for packages with unmet requires
|
// Validate required capabilities — skip package if unmet
|
||||||
knownCaps := map[string]bool{}
|
if caps, ok := ParseCapabilities(manifest); ok {
|
||||||
if reqs, ok := manifest["requires"].([]any); ok && len(reqs) > 0 {
|
missing := CheckRequiredCapabilities(caps.Required, detectedCaps)
|
||||||
var unmetReqs []string
|
if len(missing) > 0 {
|
||||||
for _, r := range reqs {
|
stores.Packages.Delete(ctx, pkgID)
|
||||||
req, _ := r.(string)
|
log.Printf("[bundled] %s: skipped (missing required capabilities: %v)", pkgID, missing)
|
||||||
if req != "" && !knownCaps[req] {
|
return "skipped", nil
|
||||||
unmetReqs = append(unmetReqs, req)
|
|
||||||
}
|
}
|
||||||
}
|
unmetOpt := CheckRequiredCapabilities(caps.Optional, detectedCaps)
|
||||||
if len(unmetReqs) > 0 {
|
if len(unmetOpt) > 0 {
|
||||||
stores.Packages.SetStatus(ctx, pkgID, "dormant")
|
log.Printf("[bundled] %s: optional capabilities unavailable: %v", pkgID, unmetOpt)
|
||||||
stores.Packages.SetEnabled(ctx, pkgID, false)
|
|
||||||
log.Printf("[bundled] %s: dormant (unmet requires: %v)", pkgID, unmetReqs)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ func TestBundledInstall_FreshDB(t *testing.T) {
|
|||||||
"route": "/s/test-b",
|
"route": "/s/test-b",
|
||||||
})
|
})
|
||||||
|
|
||||||
InstallBundledPackages(bundledDir, packagesDir, "*", stores, nil)
|
InstallBundledPackages(bundledDir, packagesDir, "*", stores, nil, nil)
|
||||||
|
|
||||||
// Both packages should be installed and enabled
|
// Both packages should be installed and enabled
|
||||||
for _, id := range []string{"test-surface-a", "test-surface-b"} {
|
for _, id := range []string{"test-surface-a", "test-surface-b"} {
|
||||||
@@ -127,7 +127,7 @@ func TestBundledInstall_SkipsExisting(t *testing.T) {
|
|||||||
"type": "surface",
|
"type": "surface",
|
||||||
})
|
})
|
||||||
|
|
||||||
InstallBundledPackages(bundledDir, packagesDir, "*", stores, nil)
|
InstallBundledPackages(bundledDir, packagesDir, "*", stores, nil, nil)
|
||||||
|
|
||||||
// Package should retain original title (not overwritten)
|
// Package should retain original title (not overwritten)
|
||||||
pkg, err := stores.Packages.Get(ctx, "pre-existing")
|
pkg, err := stores.Packages.Get(ctx, "pre-existing")
|
||||||
@@ -145,7 +145,7 @@ func TestBundledInstall_MissingDir(t *testing.T) {
|
|||||||
stores := newTestStores(t)
|
stores := newTestStores(t)
|
||||||
|
|
||||||
// Should not panic or error — just log and return
|
// Should not panic or error — just log and return
|
||||||
InstallBundledPackages("/nonexistent/path", "", "", stores, nil)
|
InstallBundledPackages("/nonexistent/path", "", "", stores, nil, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestBundledInstall_EmptyDir verifies no-op when directory exists but is empty.
|
// TestBundledInstall_EmptyDir verifies no-op when directory exists but is empty.
|
||||||
@@ -155,12 +155,12 @@ func TestBundledInstall_EmptyDir(t *testing.T) {
|
|||||||
bundledDir := t.TempDir()
|
bundledDir := t.TempDir()
|
||||||
|
|
||||||
// Should not panic or error
|
// Should not panic or error
|
||||||
InstallBundledPackages(bundledDir, "", "", stores, nil)
|
InstallBundledPackages(bundledDir, "", "", stores, nil, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestBundledInstall_DormantPackage verifies that bundled packages with
|
// TestBundledInstall_SkippedOnUnmetRequiredCaps verifies that bundled
|
||||||
// unmet requires are marked dormant.
|
// packages with unmet capabilities.required are skipped (not registered).
|
||||||
func TestBundledInstall_DormantPackage(t *testing.T) {
|
func TestBundledInstall_SkippedOnUnmetRequiredCaps(t *testing.T) {
|
||||||
stores := newTestStores(t)
|
stores := newTestStores(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -168,24 +168,21 @@ func TestBundledInstall_DormantPackage(t *testing.T) {
|
|||||||
packagesDir := t.TempDir()
|
packagesDir := t.TempDir()
|
||||||
|
|
||||||
buildTestPkg(t, bundledDir, map[string]any{
|
buildTestPkg(t, bundledDir, map[string]any{
|
||||||
"id": "dormant-pkg",
|
"id": "needs-pgvector",
|
||||||
"title": "Dormant Package",
|
"title": "Needs pgvector",
|
||||||
"type": "extension",
|
"type": "extension",
|
||||||
"hooks": []string{"on_install"},
|
"hooks": []string{"on_install"},
|
||||||
"requires": []string{"chat"},
|
"capabilities": map[string]any{
|
||||||
|
"required": []string{"pgvector"},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
InstallBundledPackages(bundledDir, packagesDir, "*", stores, nil)
|
// No capabilities detected → pgvector is unavailable
|
||||||
|
InstallBundledPackages(bundledDir, packagesDir, "*", stores, nil, nil)
|
||||||
|
|
||||||
pkg, err := stores.Packages.Get(ctx, "dormant-pkg")
|
pkg, _ := stores.Packages.Get(ctx, "needs-pgvector")
|
||||||
if err != nil || pkg == nil {
|
if pkg != nil {
|
||||||
t.Fatal("dormant package not found after install")
|
t.Error("package with unmet required capabilities should not be registered")
|
||||||
}
|
|
||||||
if pkg.Status != "dormant" {
|
|
||||||
t.Errorf("status = %q, want %q", pkg.Status, "dormant")
|
|
||||||
}
|
|
||||||
if pkg.Enabled {
|
|
||||||
t.Error("dormant package should not be enabled")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,7 +206,7 @@ func TestBundledInstall_Allowlist(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Only allow alpha and gamma
|
// Only allow alpha and gamma
|
||||||
InstallBundledPackages(bundledDir, packagesDir, "pkg-alpha,pkg-gamma", stores, nil)
|
InstallBundledPackages(bundledDir, packagesDir, "pkg-alpha,pkg-gamma", stores, nil, nil)
|
||||||
|
|
||||||
// Alpha and gamma should be installed
|
// Alpha and gamma should be installed
|
||||||
for _, id := range []string{"pkg-alpha", "pkg-gamma"} {
|
for _, id := range []string{"pkg-alpha", "pkg-gamma"} {
|
||||||
|
|||||||
@@ -459,7 +459,7 @@ func TestBundledInstall_DefaultAllowlist(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Empty allowlist → empty default set → nothing installed
|
// Empty allowlist → empty default set → nothing installed
|
||||||
InstallBundledPackages(bundledDir, packagesDir, "", stores, nil)
|
InstallBundledPackages(bundledDir, packagesDir, "", stores, nil, nil)
|
||||||
|
|
||||||
pkg, _ := stores.Packages.Get(ctx, "notes")
|
pkg, _ := stores.Packages.Get(ctx, "notes")
|
||||||
if pkg != nil {
|
if pkg != nil {
|
||||||
@@ -484,7 +484,7 @@ func TestBundledInstall_WildcardAllowlist(t *testing.T) {
|
|||||||
"id": "any-pkg", "title": "Any", "type": "surface", "version": "1.0.0",
|
"id": "any-pkg", "title": "Any", "type": "surface", "version": "1.0.0",
|
||||||
})
|
})
|
||||||
|
|
||||||
InstallBundledPackages(bundledDir, packagesDir, "*", stores, nil)
|
InstallBundledPackages(bundledDir, packagesDir, "*", stores, nil, nil)
|
||||||
|
|
||||||
pkg, err := stores.Packages.Get(ctx, "any-pkg")
|
pkg, err := stores.Packages.Get(ctx, "any-pkg")
|
||||||
if err != nil || pkg == nil {
|
if err != nil || pkg == nil {
|
||||||
|
|||||||
@@ -411,7 +411,7 @@ func TestUpgrade_BundledSkipExistingOnRestart(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// First install
|
// First install
|
||||||
InstallBundledPackages(bundledDir, packagesDir, "*", stores, nil)
|
InstallBundledPackages(bundledDir, packagesDir, "*", stores, nil, nil)
|
||||||
|
|
||||||
pkg, _ := stores.Packages.Get(ctx, "restart-pkg")
|
pkg, _ := stores.Packages.Get(ctx, "restart-pkg")
|
||||||
if pkg == nil {
|
if pkg == nil {
|
||||||
@@ -427,7 +427,7 @@ func TestUpgrade_BundledSkipExistingOnRestart(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Second install — should skip because package exists
|
// Second install — should skip because package exists
|
||||||
InstallBundledPackages(bundledDir, packagesDir, "*", stores, nil)
|
InstallBundledPackages(bundledDir, packagesDir, "*", stores, nil, nil)
|
||||||
|
|
||||||
pkg, _ = stores.Packages.Get(ctx, "restart-pkg")
|
pkg, _ = stores.Packages.Get(ctx, "restart-pkg")
|
||||||
if pkg.Version != "1.0.0" {
|
if pkg.Version != "1.0.0" {
|
||||||
@@ -438,7 +438,7 @@ func TestUpgrade_BundledSkipExistingOnRestart(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUpgrade_PackageDormantOnUnmetRequires(t *testing.T) {
|
func TestUpgrade_PackageSkippedOnUnmetRequiredCaps(t *testing.T) {
|
||||||
stores := newTestStores(t)
|
stores := newTestStores(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -452,20 +452,17 @@ func TestUpgrade_PackageDormantOnUnmetRequires(t *testing.T) {
|
|||||||
"type": "extension",
|
"type": "extension",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"hooks": []string{"on_install"},
|
"hooks": []string{"on_install"},
|
||||||
"requires": []string{"kernel>=99.0.0"},
|
"capabilities": map[string]any{
|
||||||
|
"required": []string{"pgvector"},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
InstallBundledPackages(bundledDir, packagesDir, "*", stores, nil)
|
// No capabilities detected → pgvector is unavailable
|
||||||
|
InstallBundledPackages(bundledDir, packagesDir, "*", stores, nil, nil)
|
||||||
|
|
||||||
pkg, _ := stores.Packages.Get(ctx, "future-pkg")
|
pkg, _ := stores.Packages.Get(ctx, "future-pkg")
|
||||||
if pkg == nil {
|
if pkg != nil {
|
||||||
t.Fatal("package should still be registered")
|
t.Error("package with unmet required capabilities should not be registered")
|
||||||
}
|
|
||||||
if pkg.Status != "dormant" {
|
|
||||||
t.Errorf("status = %q, want %q", pkg.Status, "dormant")
|
|
||||||
}
|
|
||||||
if pkg.Enabled {
|
|
||||||
t.Error("dormant package should not be enabled")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -185,6 +185,13 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Capability Detection ──────────
|
||||||
|
// Probe the runtime environment to discover available capabilities.
|
||||||
|
// Used by install validation and the settings.has_capability() Starlark API.
|
||||||
|
detectedCaps := handlers.DetectCapabilities(database.DB, database.IsPostgres(), objStore, cfg.WorkspaceRoot)
|
||||||
|
starlarkRunner.SetCapabilities(detectedCaps)
|
||||||
|
log.Printf(" capabilities: %v", detectedCaps)
|
||||||
|
|
||||||
// ── Bundled Packages ───────────────
|
// ── Bundled Packages ───────────────
|
||||||
// Auto-install bundled .pkg archives on first run.
|
// Auto-install bundled .pkg archives on first run.
|
||||||
// Skipped if SKIP_BUNDLED_PACKAGES=true or packages already exist.
|
// Skipped if SKIP_BUNDLED_PACKAGES=true or packages already exist.
|
||||||
@@ -193,7 +200,7 @@ func main() {
|
|||||||
if cfg.StoragePath != "" {
|
if cfg.StoragePath != "" {
|
||||||
bundledPkgDir = cfg.StoragePath + "/packages"
|
bundledPkgDir = cfg.StoragePath + "/packages"
|
||||||
}
|
}
|
||||||
handlers.InstallBundledPackages(cfg.BundledPackagesDir, bundledPkgDir, cfg.BundledPackages, stores, starlarkRunner)
|
handlers.InstallBundledPackages(cfg.BundledPackagesDir, bundledPkgDir, cfg.BundledPackages, stores, starlarkRunner, detectedCaps)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Register extension user permissions ─────
|
// ── Register extension user permissions ─────
|
||||||
@@ -830,6 +837,7 @@ func main() {
|
|||||||
pkgAdm.SetSandbox(sandbox.New(sandbox.DefaultConfig()))
|
pkgAdm.SetSandbox(sandbox.New(sandbox.DefaultConfig()))
|
||||||
pkgAdm.SetBundledDir(cfg.BundledPackagesDir)
|
pkgAdm.SetBundledDir(cfg.BundledPackagesDir)
|
||||||
pkgAdm.SetHub(hub)
|
pkgAdm.SetHub(hub)
|
||||||
|
pkgAdm.SetCapabilities(detectedCaps)
|
||||||
|
|
||||||
// Package registry — must be registered before /packages/:id
|
// Package registry — must be registered before /packages/:id
|
||||||
registryH := handlers.NewRegistryHandler(stores, packagesDir, pkgAdm)
|
registryH := handlers.NewRegistryHandler(stores, packagesDir, pkgAdm)
|
||||||
@@ -894,6 +902,12 @@ func main() {
|
|||||||
metricsH := handlers.NewMetricsHandler(metricsCollector)
|
metricsH := handlers.NewMetricsHandler(metricsCollector)
|
||||||
admin.GET("/metrics", metricsH.GetMetrics)
|
admin.GET("/metrics", metricsH.GetMetrics)
|
||||||
|
|
||||||
|
// ── Capabilities ──────────
|
||||||
|
capsH := handlers.NewCapabilitiesHandler(func() map[string]bool {
|
||||||
|
return handlers.DetectCapabilities(database.DB, database.IsPostgres(), objStore, cfg.WorkspaceRoot)
|
||||||
|
})
|
||||||
|
admin.GET("/capabilities", capsH.GetCapabilities)
|
||||||
|
|
||||||
// ── Backup/Restore ─────────
|
// ── Backup/Restore ─────────
|
||||||
backupH := handlers.NewBackupHandler(stores, packagesDir, cfg.StoragePath)
|
backupH := handlers.NewBackupHandler(stores, packagesDir, cfg.StoragePath)
|
||||||
admin.POST("/backup", backupH.CreateBackup)
|
admin.POST("/backup", backupH.CreateBackup)
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ type Runner struct {
|
|||||||
objectStore storage.ObjectStore // nil = files module unavailable
|
objectStore storage.ObjectStore // nil = files module unavailable
|
||||||
workspaceRoot string // empty = workspace module unavailable
|
workspaceRoot string // empty = workspace module unavailable
|
||||||
workspaceQuota int // MB, 0 = unlimited
|
workspaceQuota int // MB, 0 = unlimited
|
||||||
|
capabilities map[string]bool // detected environment capabilities
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRunner creates a runner with the given sandbox and dependencies.
|
// NewRunner creates a runner with the given sandbox and dependencies.
|
||||||
@@ -132,6 +133,12 @@ func (r *Runner) SetWorkspaceRoot(root string, quotaMB int) {
|
|||||||
r.workspaceQuota = quotaMB
|
r.workspaceQuota = quotaMB
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetCapabilities stores the detected environment capabilities map.
|
||||||
|
// Passed to the settings module so extensions can call has_capability().
|
||||||
|
func (r *Runner) SetCapabilities(caps map[string]bool) {
|
||||||
|
r.capabilities = caps
|
||||||
|
}
|
||||||
|
|
||||||
// SetAllowPrivateIPs disables the SSRF check that blocks connections to
|
// SetAllowPrivateIPs disables the SSRF check that blocks connections to
|
||||||
// private/loopback IPs. For self-hosted environments where extensions
|
// private/loopback IPs. For self-hosted environments where extensions
|
||||||
// reach internal services. Controlled by EXT_ALLOW_PRIVATE_IPS env var.
|
// reach internal services. Controlled by EXT_ALLOW_PRIVATE_IPS env var.
|
||||||
@@ -441,7 +448,7 @@ func (r *Runner) buildModulesWithLibCtx(ctx context.Context, packageID string, m
|
|||||||
userID = rc.UserID
|
userID = rc.UserID
|
||||||
teamID = rc.TeamID
|
teamID = rc.TeamID
|
||||||
}
|
}
|
||||||
modules["settings"] = BuildSettingsModule(ctx, r.stores, packageID, userID, teamID)
|
modules["settings"] = BuildSettingsModule(ctx, r.stores, packageID, userID, teamID, r.capabilities)
|
||||||
|
|
||||||
// Always available — read-only check against kernel permission data
|
// Always available — read-only check against kernel permission data
|
||||||
modules["permissions"] = BuildPermissionsModule(ctx, r.stores)
|
modules["permissions"] = BuildPermissionsModule(ctx, r.stores)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ package sandbox
|
|||||||
// Starlark API:
|
// Starlark API:
|
||||||
// val = settings.get("key") # returns string, number, bool, or None
|
// val = settings.get("key") # returns string, number, bool, or None
|
||||||
// val = settings.get("key", "default") # returns default if key not set
|
// val = settings.get("key", "default") # returns default if key not set
|
||||||
|
// ok = settings.has_capability("pgvector") # returns True or False
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -24,12 +25,28 @@ import (
|
|||||||
// BuildSettingsModule creates the "settings" Starlark module for a package.
|
// BuildSettingsModule creates the "settings" Starlark module for a package.
|
||||||
// It resolves the three-tier cascade (global → team → user) respecting
|
// It resolves the three-tier cascade (global → team → user) respecting
|
||||||
// the user_overridable flag from the package manifest.
|
// the user_overridable flag from the package manifest.
|
||||||
func BuildSettingsModule(ctx context.Context, stores store.Stores, packageID, userID, teamID string) *starlarkstruct.Module {
|
func BuildSettingsModule(ctx context.Context, stores store.Stores, packageID, userID, teamID string, capabilities map[string]bool) *starlarkstruct.Module {
|
||||||
return MakeModule("settings", starlark.StringDict{
|
return MakeModule("settings", starlark.StringDict{
|
||||||
"get": starlark.NewBuiltin("settings.get", settingsGet(ctx, stores, packageID, userID, teamID)),
|
"get": starlark.NewBuiltin("settings.get", settingsGet(ctx, stores, packageID, userID, teamID)),
|
||||||
|
"has_capability": starlark.NewBuiltin("settings.has_capability", hasCapability(capabilities)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hasCapability returns a Starlark builtin that checks whether a named
|
||||||
|
// environment capability is available. Read-only, no permission needed.
|
||||||
|
func hasCapability(caps map[string]bool) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) {
|
||||||
|
return func(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
|
||||||
|
var name string
|
||||||
|
if err := starlark.UnpackPositionalArgs("settings.has_capability", args, kwargs, 1, &name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if caps == nil {
|
||||||
|
return starlark.False, nil
|
||||||
|
}
|
||||||
|
return starlark.Bool(caps[name]), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func settingsGet(ctx context.Context, stores store.Stores, packageID, userID, teamID string) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) {
|
func settingsGet(ctx context.Context, stores store.Stores, packageID, userID, teamID string) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) {
|
||||||
return func(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
|
return func(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
|
||||||
var key string
|
var key string
|
||||||
|
|||||||
75
server/sandbox/settings_module_test.go
Normal file
75
server/sandbox/settings_module_test.go
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
package sandbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.starlark.net/starlark"
|
||||||
|
|
||||||
|
"armature/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func execWithSettings(t *testing.T, caps map[string]bool, script string) (*Result, error) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
mod := BuildSettingsModule(ctx, store.Stores{}, "", "", "", caps)
|
||||||
|
sb := New(DefaultConfig())
|
||||||
|
return sb.Exec(ctx, "test.star", script, map[string]starlark.Value{
|
||||||
|
"settings": mod,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasCapability_True(t *testing.T) {
|
||||||
|
caps := map[string]bool{"postgres": true, "pgvector": true}
|
||||||
|
result, err := execWithSettings(t, caps, `
|
||||||
|
result = settings.has_capability("pgvector")
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("exec error: %v", err)
|
||||||
|
}
|
||||||
|
v := result.Globals["result"]
|
||||||
|
if v != starlark.True {
|
||||||
|
t.Errorf("has_capability('pgvector') = %v, want True", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasCapability_False(t *testing.T) {
|
||||||
|
caps := map[string]bool{"postgres": true, "pgvector": false}
|
||||||
|
result, err := execWithSettings(t, caps, `
|
||||||
|
result = settings.has_capability("pgvector")
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("exec error: %v", err)
|
||||||
|
}
|
||||||
|
v := result.Globals["result"]
|
||||||
|
if v != starlark.False {
|
||||||
|
t.Errorf("has_capability('pgvector') = %v, want False", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasCapability_UnknownCap(t *testing.T) {
|
||||||
|
caps := map[string]bool{"postgres": true}
|
||||||
|
result, err := execWithSettings(t, caps, `
|
||||||
|
result = settings.has_capability("gpu")
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("exec error: %v", err)
|
||||||
|
}
|
||||||
|
v := result.Globals["result"]
|
||||||
|
if v != starlark.False {
|
||||||
|
t.Errorf("has_capability('gpu') = %v, want False", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasCapability_NilCaps(t *testing.T) {
|
||||||
|
result, err := execWithSettings(t, nil, `
|
||||||
|
result = settings.has_capability("postgres")
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("exec error: %v", err)
|
||||||
|
}
|
||||||
|
v := result.Globals["result"]
|
||||||
|
if v != starlark.False {
|
||||||
|
t.Errorf("has_capability with nil caps = %v, want False", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -134,7 +134,7 @@ func (e *Engine) buildRestrictedModules(ctx context.Context, creatorID, runAs st
|
|||||||
// Settings module — read-only access to platform settings
|
// Settings module — read-only access to platform settings
|
||||||
// (no package context for scheduled tasks; they read global settings)
|
// (no package context for scheduled tasks; they read global settings)
|
||||||
if e.stores.Packages != nil {
|
if e.stores.Packages != nil {
|
||||||
modules["settings"] = sandbox.BuildSettingsModule(ctx, e.stores, "", creatorID, "")
|
modules["settings"] = sandbox.BuildSettingsModule(ctx, e.stores, "", creatorID, "", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notifications module — if available
|
// Notifications module — if available
|
||||||
|
|||||||
Reference in New Issue
Block a user