From 44bf63e5fee6b7b5695cefebaa177e14b078e3cf Mon Sep 17 00:00:00 2001 From: Jeffrey Smith Date: Fri, 3 Apr 2026 08:38:37 +0000 Subject: [PATCH] Feat v0.8.2 capability negotiation (#69) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extensions declare environment requirements via capabilities.required and capabilities.optional in their manifest. The kernel validates at install time — required capabilities reject with HTTP 422 and rollback, optional capabilities log a warning. Runtime query via settings.has_capability(). Admin endpoint GET /api/v1/admin/capabilities re-probes live. Detected capabilities: postgres, pgvector, object_storage, s3, workspace. 18 new tests, all passing with -race. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 40 +++++ ROADMAP.md | 11 +- VERSION | 2 +- docs/PACKAGE-FORMAT.md | 1 + server/handlers/capabilities.go | 146 ++++++++++++++++ server/handlers/capabilities_test.go | 205 +++++++++++++++++++++++ server/handlers/packages.go | 52 +++--- server/handlers/packages_bundled.go | 29 ++-- server/handlers/packages_bundled_test.go | 12 +- server/handlers/packages_update_test.go | 4 +- server/handlers/upgrade_test.go | 6 +- server/main.go | 16 +- server/sandbox/runner.go | 9 +- server/sandbox/settings_module.go | 21 ++- server/sandbox/settings_module_test.go | 75 +++++++++ server/triggers/schedule.go | 2 +- 16 files changed, 567 insertions(+), 64 deletions(-) create mode 100644 server/handlers/capabilities.go create mode 100644 server/handlers/capabilities_test.go create mode 100644 server/sandbox/settings_module_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index e9fba1a..73b65f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,46 @@ 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 New `workspace` sandbox module. Managed disk directories for extensions diff --git a/ROADMAP.md b/ROADMAP.md index cc0ebe3..1582feb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -410,16 +410,19 @@ New: `sandbox/workspace_module.go`. Modified: runner wiring, permission constants, config (`WORKSPACE_ROOT`, `WORKSPACE_QUOTA_MB`), main.go startup. 16 new tests. -**v0.8.2 — Capability Negotiation** +**v0.8.2 — Capability Negotiation** ✅ Extensions declare environment requirements in manifest (`capabilities.required`, -`capabilities.optional`). Kernel validates at install time. Runtime query -via `settings.has_capability()`. Admin endpoint: `GET /admin/capabilities`. +`capabilities.optional`). Kernel validates at install time — required caps +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`, `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** diff --git a/VERSION b/VERSION index 6f4eebd..100435b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.1 +0.8.2 diff --git a/docs/PACKAGE-FORMAT.md b/docs/PACKAGE-FORMAT.md index 82f5a1c..8ef48eb 100644 --- a/docs/PACKAGE-FORMAT.md +++ b/docs/PACKAGE-FORMAT.md @@ -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 | | `hooks` | Event bus subscription patterns | | `exports` | Functions exported by library packages | +| `capabilities` | Environment requirements: `{"required": [...], "optional": [...]}` | | `schema_version` | Integer for additive schema migrations | ## Package Lifecycle diff --git a/server/handlers/capabilities.go b/server/handlers/capabilities.go new file mode 100644 index 0000000..117b47e --- /dev/null +++ b/server/handlers/capabilities.go @@ -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) +} diff --git a/server/handlers/capabilities_test.go b/server/handlers/capabilities_test.go new file mode 100644 index 0000000..aebd29d --- /dev/null +++ b/server/handlers/capabilities_test.go @@ -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") + } +} diff --git a/server/handlers/packages.go b/server/handlers/packages.go index 0d516ce..be85681 100644 --- a/server/handlers/packages.go +++ b/server/handlers/packages.go @@ -38,6 +38,7 @@ type PackageHandler struct { sandbox *sandbox.Sandbox runner *sandbox.Runner hub *events.Hub + capabilities map[string]bool // detected environment capabilities } func NewPackageHandler(s store.Stores, packagesDir ...string) *PackageHandler { @@ -69,6 +70,12 @@ func (h *PackageHandler) SetHub(hub *events.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. func (h *PackageHandler) broadcastPackageChanged(action, id string) { if h.hub == nil { @@ -286,8 +293,23 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) { } } - // Phase 10: Check capabilities → mark dormant if unmet - isDormant := h.checkCapabilities(c, pkgID, manifest, &preservedEnabled) + // Phase 10: Validate required capabilities + 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{ "id": pkgID, @@ -297,9 +319,6 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) { "source": pkgSource, "enabled": preservedEnabled, } - if isDormant { - resp["status"] = "dormant" - } c.JSON(http.StatusOK, resp) 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") if _, statErr := os.Stat(pkgPath); statErr == nil { 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{ "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 } -// 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 // belongs to an extractable directory (js/, css/, assets/, star/) or diff --git a/server/handlers/packages_bundled.go b/server/handlers/packages_bundled.go index 3d06439..7c38bd9 100644 --- a/server/handlers/packages_bundled.go +++ b/server/handlers/packages_bundled.go @@ -40,7 +40,7 @@ var defaultBundledPackages = map[string]bool{} // // Design: install-once, skip-if-present. If an admin uninstalls a bundled // 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) if err != nil { if os.IsNotExist(err) { @@ -82,7 +82,7 @@ func InstallBundledPackages(bundledDir, packagesDir, allowlist string, stores st } 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 { log.Printf("[bundled] ⚠️ Failed to install %s: %v", entry.Name(), err) continue @@ -129,7 +129,7 @@ func parseBundleAllowlist(s string) map[string]bool { // installBundledPackage installs a single .pkg archive if its package ID // 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() // Open as zip @@ -237,20 +237,17 @@ func installBundledPackage(pkgPath, packagesDir string, stores store.Stores, run } } - // Handle dormant status for packages with unmet requires - knownCaps := map[string]bool{} - if reqs, ok := manifest["requires"].([]any); ok && len(reqs) > 0 { - var unmetReqs []string - for _, r := range reqs { - req, _ := r.(string) - if req != "" && !knownCaps[req] { - unmetReqs = append(unmetReqs, req) - } + // Validate required capabilities — skip package if unmet + if caps, ok := ParseCapabilities(manifest); ok { + missing := CheckRequiredCapabilities(caps.Required, detectedCaps) + if len(missing) > 0 { + stores.Packages.Delete(ctx, pkgID) + log.Printf("[bundled] %s: skipped (missing required capabilities: %v)", pkgID, missing) + return "skipped", nil } - if len(unmetReqs) > 0 { - stores.Packages.SetStatus(ctx, pkgID, "dormant") - stores.Packages.SetEnabled(ctx, pkgID, false) - log.Printf("[bundled] %s: dormant (unmet requires: %v)", pkgID, unmetReqs) + unmetOpt := CheckRequiredCapabilities(caps.Optional, detectedCaps) + if len(unmetOpt) > 0 { + log.Printf("[bundled] %s: optional capabilities unavailable: %v", pkgID, unmetOpt) } } diff --git a/server/handlers/packages_bundled_test.go b/server/handlers/packages_bundled_test.go index 98ac2fa..cc933f4 100644 --- a/server/handlers/packages_bundled_test.go +++ b/server/handlers/packages_bundled_test.go @@ -88,7 +88,7 @@ func TestBundledInstall_FreshDB(t *testing.T) { "route": "/s/test-b", }) - InstallBundledPackages(bundledDir, packagesDir, "*", stores, nil) + InstallBundledPackages(bundledDir, packagesDir, "*", stores, nil, nil) // Both packages should be installed and enabled for _, id := range []string{"test-surface-a", "test-surface-b"} { @@ -127,7 +127,7 @@ func TestBundledInstall_SkipsExisting(t *testing.T) { "type": "surface", }) - InstallBundledPackages(bundledDir, packagesDir, "*", stores, nil) + InstallBundledPackages(bundledDir, packagesDir, "*", stores, nil, nil) // Package should retain original title (not overwritten) pkg, err := stores.Packages.Get(ctx, "pre-existing") @@ -145,7 +145,7 @@ func TestBundledInstall_MissingDir(t *testing.T) { stores := newTestStores(t) // 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. @@ -155,7 +155,7 @@ func TestBundledInstall_EmptyDir(t *testing.T) { bundledDir := t.TempDir() // Should not panic or error - InstallBundledPackages(bundledDir, "", "", stores, nil) + InstallBundledPackages(bundledDir, "", "", stores, nil, nil) } // TestBundledInstall_DormantPackage verifies that bundled packages with @@ -175,7 +175,7 @@ func TestBundledInstall_DormantPackage(t *testing.T) { "requires": []string{"chat"}, }) - InstallBundledPackages(bundledDir, packagesDir, "*", stores, nil) + InstallBundledPackages(bundledDir, packagesDir, "*", stores, nil, nil) pkg, err := stores.Packages.Get(ctx, "dormant-pkg") if err != nil || pkg == nil { @@ -209,7 +209,7 @@ func TestBundledInstall_Allowlist(t *testing.T) { }) // 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 for _, id := range []string{"pkg-alpha", "pkg-gamma"} { diff --git a/server/handlers/packages_update_test.go b/server/handlers/packages_update_test.go index 6c8a894..91af19c 100644 --- a/server/handlers/packages_update_test.go +++ b/server/handlers/packages_update_test.go @@ -459,7 +459,7 @@ func TestBundledInstall_DefaultAllowlist(t *testing.T) { }) // Empty allowlist → empty default set → nothing installed - InstallBundledPackages(bundledDir, packagesDir, "", stores, nil) + InstallBundledPackages(bundledDir, packagesDir, "", stores, nil, nil) pkg, _ := stores.Packages.Get(ctx, "notes") if pkg != nil { @@ -484,7 +484,7 @@ func TestBundledInstall_WildcardAllowlist(t *testing.T) { "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") if err != nil || pkg == nil { diff --git a/server/handlers/upgrade_test.go b/server/handlers/upgrade_test.go index eb37650..4f62745 100644 --- a/server/handlers/upgrade_test.go +++ b/server/handlers/upgrade_test.go @@ -411,7 +411,7 @@ func TestUpgrade_BundledSkipExistingOnRestart(t *testing.T) { }) // First install - InstallBundledPackages(bundledDir, packagesDir, "*", stores, nil) + InstallBundledPackages(bundledDir, packagesDir, "*", stores, nil, nil) pkg, _ := stores.Packages.Get(ctx, "restart-pkg") if pkg == nil { @@ -427,7 +427,7 @@ func TestUpgrade_BundledSkipExistingOnRestart(t *testing.T) { }) // 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") if pkg.Version != "1.0.0" { @@ -455,7 +455,7 @@ func TestUpgrade_PackageDormantOnUnmetRequires(t *testing.T) { "requires": []string{"kernel>=99.0.0"}, }) - InstallBundledPackages(bundledDir, packagesDir, "*", stores, nil) + InstallBundledPackages(bundledDir, packagesDir, "*", stores, nil, nil) pkg, _ := stores.Packages.Get(ctx, "future-pkg") if pkg == nil { diff --git a/server/main.go b/server/main.go index 1168983..4d6a162 100644 --- a/server/main.go +++ b/server/main.go @@ -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 ─────────────── // Auto-install bundled .pkg archives on first run. // Skipped if SKIP_BUNDLED_PACKAGES=true or packages already exist. @@ -193,7 +200,7 @@ func main() { if cfg.StoragePath != "" { 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 ───── @@ -830,6 +837,7 @@ func main() { pkgAdm.SetSandbox(sandbox.New(sandbox.DefaultConfig())) pkgAdm.SetBundledDir(cfg.BundledPackagesDir) pkgAdm.SetHub(hub) + pkgAdm.SetCapabilities(detectedCaps) // Package registry — must be registered before /packages/:id registryH := handlers.NewRegistryHandler(stores, packagesDir, pkgAdm) @@ -894,6 +902,12 @@ func main() { metricsH := handlers.NewMetricsHandler(metricsCollector) 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 ───────── backupH := handlers.NewBackupHandler(stores, packagesDir, cfg.StoragePath) admin.POST("/backup", backupH.CreateBackup) diff --git a/server/sandbox/runner.go b/server/sandbox/runner.go index 50511b4..f301fd3 100644 --- a/server/sandbox/runner.go +++ b/server/sandbox/runner.go @@ -81,6 +81,7 @@ type Runner struct { objectStore storage.ObjectStore // nil = files module unavailable workspaceRoot string // empty = workspace module unavailable workspaceQuota int // MB, 0 = unlimited + capabilities map[string]bool // detected environment capabilities } // 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 } +// 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 // private/loopback IPs. For self-hosted environments where extensions // 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 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 modules["permissions"] = BuildPermissionsModule(ctx, r.stores) diff --git a/server/sandbox/settings_module.go b/server/sandbox/settings_module.go index 62b3df5..99e91c5 100644 --- a/server/sandbox/settings_module.go +++ b/server/sandbox/settings_module.go @@ -11,6 +11,7 @@ package sandbox // Starlark API: // val = settings.get("key") # returns string, number, bool, or None // val = settings.get("key", "default") # returns default if key not set +// ok = settings.has_capability("pgvector") # returns True or False import ( "context" @@ -24,12 +25,28 @@ import ( // BuildSettingsModule creates the "settings" Starlark module for a package. // It resolves the three-tier cascade (global → team → user) respecting // 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{ - "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) { return func(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { var key string diff --git a/server/sandbox/settings_module_test.go b/server/sandbox/settings_module_test.go new file mode 100644 index 0000000..44e5323 --- /dev/null +++ b/server/sandbox/settings_module_test.go @@ -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) + } +} diff --git a/server/triggers/schedule.go b/server/triggers/schedule.go index df62bae..d7439e7 100644 --- a/server/triggers/schedule.go +++ b/server/triggers/schedule.go @@ -134,7 +134,7 @@ func (e *Engine) buildRestrictedModules(ctx context.Context, creatorID, runAs st // Settings module — read-only access to platform settings // (no package context for scheduled tasks; they read global settings) 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