diff --git a/cmd/smoke-platform/main.go b/cmd/smoke-platform/main.go
index 71ed8d9..e02f94b 100644
--- a/cmd/smoke-platform/main.go
+++ b/cmd/smoke-platform/main.go
@@ -6,6 +6,7 @@ import (
"fmt"
"os"
"path/filepath"
+ "strings"
"github.com/verstak/verstak-desktop/internal/core/capability"
"github.com/verstak/verstak-desktop/internal/core/contribution"
@@ -558,6 +559,71 @@ func runContributionsTest(root string) {
}
}
+ // ── Step 2b: Frontend bundle verification ──
+ fmt.Printf("\n[frontend bundle verification]\n")
+ frontendOk := true
+
+ // 2b.1 — Check manifest declares frontend.entry
+ if target.Manifest.Frontend == nil {
+ fmt.Printf(" ❌ manifest.frontend is nil — plugin has no frontend config\n")
+ frontendOk = false
+ } else {
+ expectedEntry := "frontend/dist/index.js"
+ if target.Manifest.Frontend.Entry != expectedEntry {
+ fmt.Printf(" ❌ frontend.entry: expected %q, got %q\n", expectedEntry, target.Manifest.Frontend.Entry)
+ frontendOk = false
+ } else {
+ fmt.Printf(" ✅ frontend.entry: %s\n", target.Manifest.Frontend.Entry)
+ }
+
+ // 2b.2 — Resolve entry path and check file exists on disk
+ resolvedEntry := filepath.Join(target.RootPath, target.Manifest.Frontend.Entry)
+ if _, err := os.Stat(resolvedEntry); os.IsNotExist(err) {
+ fmt.Printf(" ❌ frontend bundle NOT FOUND at resolved path: %s\n", resolvedEntry)
+ frontendOk = false
+ } else if err != nil {
+ fmt.Printf(" ❌ frontend bundle stat error: %v\n", err)
+ frontendOk = false
+ } else {
+ fmt.Printf(" ✅ frontend bundle exists: %s\n", resolvedEntry)
+
+ // 2b.3 — Read file and check for "VerstakPluginRegister"
+ data, err := os.ReadFile(resolvedEntry)
+ if err != nil {
+ fmt.Printf(" ❌ failed to read frontend bundle: %v\n", err)
+ frontendOk = false
+ } else {
+ content := string(data)
+
+ if strings.Contains(content, "VerstakPluginRegister") {
+ fmt.Printf(" ✅ bundle contains VerstakPluginRegister contract\n")
+ } else {
+ fmt.Printf(" ❌ bundle MISSING VerstakPluginRegister contract\n")
+ frontendOk = false
+ }
+
+ // 2b.4 — Check for expected component names
+ expectedComponents := []string{"DiagnosticsPanel", "PlatformTestSettings"}
+ for _, comp := range expectedComponents {
+ if strings.Contains(content, comp) {
+ fmt.Printf(" ✅ component registered: %s\n", comp)
+ } else {
+ fmt.Printf(" ❌ component MISSING from bundle: %s\n", comp)
+ frontendOk = false
+ }
+ }
+ }
+ }
+ }
+
+ if frontendOk {
+ fmt.Printf(" ✅ frontend bundle checks passed\n")
+ } else {
+ fmt.Printf(" ❌ frontend bundle checks FAILED\n")
+ exitCode = 1
+ return
+ }
+
// ── Step 3: Register capabilities (simulates main.go + ReloadPlugins) ──
reg := capability.NewRegistry()
coreCaps := []string{
diff --git a/docs/PLUGIN_RUNTIME.md b/docs/PLUGIN_RUNTIME.md
index 23872f8..e272a2c 100644
--- a/docs/PLUGIN_RUNTIME.md
+++ b/docs/PLUGIN_RUNTIME.md
@@ -168,7 +168,7 @@ foreach plugin:
| Тип | Поле manifest | Описание | Frontend host |
|---|---|---|---|
| Боковая панель | `sidebarItems` | Элементы в sidebar слева | ✅ Sidebar.svelte (из ContributionRegistry) |
-| Основные панели | `views` | Полноценные страницы/панели | ✅ ViewContainer.svelte (placeholder — frontend bundle host not implemented) |
+| Основные панели | `views` | Полноценные страницы/панели | ✅ ViewContainer.svelte (PluginBundleHost — real frontend bundle) |
| Панели настроек | `settingsPanels` | Панели в Plugin Manager | ✅ PluginManager.svelte (кнопка Settings, открывает modal) |
| Команды | `commands` | Команды для command palette | ✅ ContributionRegistry (UI command palette не реализован) |
@@ -239,6 +239,73 @@ foreach plugin:
- ViewContainer показывает "⚠️ Plugin UI failed" fallback
- Error канал: `console.error` + видимый fallback в UI
+## Frontend Bundle Contract
+
+### Регистрация компонентов
+
+Плагин регистрирует frontend компоненты через глобальную функцию `window.VerstakPluginRegister`:
+
+```javascript
+window.VerstakPluginRegister('plugin.id', {
+ components: {
+ 'ComponentName': {
+ mount: function(containerEl, props, api) {
+ // containerEl — div, созданный PluginBundleHost
+ // api — ограниченный VerstakPluginAPI
+ containerEl.innerHTML = '
Hello from plugin!
';
+ },
+ unmount: function(containerEl) {
+ // Очистка при смене view
+ containerEl.innerHTML = '';
+ }
+ }
+ }
+});
+```
+
+### VerstakPluginAPI
+
+API объект передаётся в `mount()` и содержит только ограниченный набор методов:
+
+| Свойство | Статус | Описание |
+|---|---|---|
+| `api.pluginId` | ✅ Работает | ID плагина |
+| `api.capabilities.has(id)` | 🔧 Stub | Запрос capability registry (planned) |
+| `api.events.publish(type, payload)` | 🔧 Stub | Публикация события (planned) |
+| `api.events.subscribe(type, handler)` | 🔧 Stub | Подписка на события (planned) |
+| `api.settings.read(key)` | 🔧 Stub | Чтение настроек плагина (planned) |
+| `api.settings.write(key, value)` | 🔧 Stub | Запись настроек плагина (planned) |
+| `api.commands.execute(id, args)` | 🔧 Stub | Выполнение команды (planned) |
+
+### Загрузка бандла
+
+1. `PluginBundleHost` получает pluginId и componentId
+2. Вызывает `App.GetPluginFrontendInfo(pluginId)` — получает entry/style/rootPath
+3. Вызывает `App.GetPluginAssetContent(pluginId, entry)` — получает JS контент
+4. Выполняет контент через `new Function(content)` — bundle вызывает `VerstakPluginRegister`
+5. Находит компонент по componentId и вызывает `mount(container, props, api)`
+6. При смене view — вызывает `unmount(container)` для старого компонента
+
+### Безопасность asset path
+
+| Правило | Проверка |
+|---|---|
+| Нет абсолютных путей | Пути, начинающиеся с `/` или `\`, отклоняются |
+| Нет path traversal | Пути, содержащие `..`, отклоняются |
+| Нет выхода за root | После `filepath.Join` проверяется, что путь внутри plugin root |
+| Только существующие файлы | `os.ReadFile` возвращает ошибку если файл не существует |
+
+### manifest frontend config
+
+```json
+{
+ "frontend": {
+ "entry": "frontend/dist/index.js",
+ "style": "frontend/style.css"
+ }
+}
+```
+
## Reload
`ReloadPlugins()` в `internal/api/app.go` позволяет перезагрузить plugins без перезапуска приложения:
diff --git a/frontend/src/lib/plugin-host/PluginBundleHost.svelte b/frontend/src/lib/plugin-host/PluginBundleHost.svelte
new file mode 100644
index 0000000..0f43182
--- /dev/null
+++ b/frontend/src/lib/plugin-host/PluginBundleHost.svelte
@@ -0,0 +1,292 @@
+
+
+
+ {#if loadState === 'idle'}
+
+
Select a plugin view from the sidebar
+
+
+ {:else if loadState === 'loading'}
+
+
+
Loading plugin bundle...
+
+
+ {:else if loadState === 'error'}
+
+
⚠️
+
Plugin View Error
+
+
Plugin: {currentPluginId || 'unknown'}
+
Component: {currentComponent || 'unknown'}
+
{errorText || 'Unknown error'}
+ {#if pluginInfo}
+
Frontend entry: {pluginInfo.entry || 'none'}
+ {/if}
+ {#if getComponentList().length > 0}
+
Available components: {getComponentList().join(', ')}
+ {/if}
+
+
+
+ {:else if loadState === 'loaded'}
+
+ {/if}
+
+
+
diff --git a/frontend/src/lib/plugin-host/VerstakPluginAPI.js b/frontend/src/lib/plugin-host/VerstakPluginAPI.js
new file mode 100644
index 0000000..43f8f10
--- /dev/null
+++ b/frontend/src/lib/plugin-host/VerstakPluginAPI.js
@@ -0,0 +1,68 @@
+// VerstakPluginAPI is the restricted API passed to plugin frontend bundles.
+// Plugins do NOT get direct access to Wails bridge — only what's exposed here.
+// All methods are stubs or limited implementations.
+
+(function() {
+ // Store registered components per plugin
+ window.__VERSTAK_PLUGIN_REGISTRY__ = window.__VERSTAK_PLUGIN_REGISTRY__ || {};
+
+ // Original register function
+ const origRegister = window.VerstakPluginRegister;
+ if (origRegister) {
+ // Already defined — don't override
+ return;
+ }
+
+ window.VerstakPluginRegister = function(pluginId, bundle) {
+ if (!pluginId || !bundle || !bundle.components) {
+ console.error('[VerstakPluginRegister] invalid registration:', pluginId);
+ return;
+ }
+ console.log('[VerstakPluginRegister] registered:', pluginId, Object.keys(bundle.components));
+ window.__VERSTAK_PLUGIN_REGISTRY__[pluginId] = bundle.components;
+ };
+
+ // Create the restricted API object for a plugin host context
+ window.VerstakPluginAPI = function(pluginId) {
+ return {
+ pluginId: pluginId,
+
+ capabilities: {
+ has: function(capId) {
+ // planned: query backend cap registry
+ console.log('[plugin:' + pluginId + '] capabilities.has(' + capId + ') — stub');
+ return false;
+ }
+ },
+
+ events: {
+ publish: function(type, payload) {
+ console.log('[plugin:' + pluginId + '] event publish:', type, payload);
+ // planned: actual event bus bridge
+ },
+ subscribe: function(type, handler) {
+ console.log('[plugin:' + pluginId + '] event subscribe:', type, '(stub)');
+ // planned: actual event bus bridge
+ }
+ },
+
+ settings: {
+ read: function(key) {
+ console.log('[plugin:' + pluginId + '] settings.read(' + key + ') — stub');
+ return null;
+ },
+ write: function(key, value) {
+ console.log('[plugin:' + pluginId + '] settings.write(' + key + ',', value, ') — stub');
+ // planned: backend storage namespace
+ }
+ },
+
+ commands: {
+ execute: function(cmdId, args) {
+ console.log('[plugin:' + pluginId + '] commands.execute(' + cmdId + ') — stub');
+ // planned: command execution
+ }
+ }
+ };
+ };
+})();
diff --git a/frontend/src/lib/plugin-manager/PluginManager.svelte b/frontend/src/lib/plugin-manager/PluginManager.svelte
index a15d9e2..1403763 100644
--- a/frontend/src/lib/plugin-manager/PluginManager.svelte
+++ b/frontend/src/lib/plugin-manager/PluginManager.svelte
@@ -1,7 +1,8 @@
{#key `${activeViewPluginId}:${activeView}`}
@@ -51,17 +59,27 @@
-
+ {#if hasFrontend}
+
+ {:else}
Plugin View Host
Plugin: {currentView.pluginId}
View ID: {currentView.id}
Component: {currentView.component}
-
frontend bundle host not implemented yet
+
frontend bundle not available
-
+ {/if}
@@ -109,14 +127,30 @@
flex: 1;
}
.view-icon { font-size: 1.3rem; }
+ .frontend-badge {
+ font-size: 0.7rem;
+ padding: 0.15rem 0.5rem;
+ background: rgba(78, 204, 163, 0.15);
+ color: #4ecca3;
+ border-radius: 8px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.02em;
+ }
+ .no-frontend-badge {
+ font-size: 0.7rem;
+ padding: 0.15rem 0.5rem;
+ background: rgba(233, 69, 96, 0.1);
+ color: #e94560;
+ border-radius: 8px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.02em;
+ }
.view-content {
flex: 1;
overflow: auto;
}
- .plugin-view-host {
- width: 100%;
- min-height: 200px;
- }
.placeholder {
color: #666;
font-style: italic;
diff --git a/frontend/wailsjs/go/api/App.d.ts b/frontend/wailsjs/go/api/App.d.ts
index f11e856..5a40591 100755
--- a/frontend/wailsjs/go/api/App.d.ts
+++ b/frontend/wailsjs/go/api/App.d.ts
@@ -27,6 +27,10 @@ export function GetCurrentWorkspaceNode():Promise>;
export function GetPermissions():Promise>;
+export function GetPluginAssetContent(arg1:string,arg2:string):Promise;
+
+export function GetPluginFrontendInfo(arg1:string):Promise>;
+
export function GetPlugins():Promise>;
export function GetVaultPluginState():Promise>;
diff --git a/frontend/wailsjs/go/api/App.js b/frontend/wailsjs/go/api/App.js
index 465e2ad..16d74ac 100755
--- a/frontend/wailsjs/go/api/App.js
+++ b/frontend/wailsjs/go/api/App.js
@@ -46,6 +46,14 @@ export function GetPermissions() {
return window['go']['api']['App']['GetPermissions']();
}
+export function GetPluginAssetContent(arg1, arg2) {
+ return window['go']['api']['App']['GetPluginAssetContent'](arg1, arg2);
+}
+
+export function GetPluginFrontendInfo(arg1) {
+ return window['go']['api']['App']['GetPluginFrontendInfo'](arg1);
+}
+
export function GetPlugins() {
return window['go']['api']['App']['GetPlugins']();
}
diff --git a/frontend/wailsjs/runtime/package.json b/frontend/wailsjs/runtime/package.json
old mode 100644
new mode 100755
diff --git a/frontend/wailsjs/runtime/runtime.d.ts b/frontend/wailsjs/runtime/runtime.d.ts
old mode 100644
new mode 100755
diff --git a/frontend/wailsjs/runtime/runtime.js b/frontend/wailsjs/runtime/runtime.js
old mode 100644
new mode 100755
diff --git a/internal/api/app.go b/internal/api/app.go
index a6ddccb..e719193 100644
--- a/internal/api/app.go
+++ b/internal/api/app.go
@@ -711,3 +711,69 @@ func (a *App) SelectVaultForOpen() string {
}
return selected
}
+
+// ─── Plugin Frontend Asset API ───────────────────────────
+
+// GetPluginFrontendInfo returns frontend metadata for a plugin.
+// Returns empty map if plugin has no frontend bundle or is not found.
+func (a *App) GetPluginFrontendInfo(pluginID string) map[string]interface{} {
+ for _, p := range a.plugins {
+ if p.Manifest.ID != pluginID {
+ continue
+ }
+ if p.Manifest.Frontend == nil {
+ return map[string]interface{}{"status": "no-frontend"}
+ }
+ return map[string]interface{}{
+ "pluginId": p.Manifest.ID,
+ "name": p.Manifest.Name,
+ "icon": p.Manifest.Icon,
+ "version": p.Manifest.Version,
+ "entry": p.Manifest.Frontend.Entry,
+ "style": p.Manifest.Frontend.Style,
+ "rootPath": p.RootPath,
+ }
+ }
+ return map[string]interface{}{"status": "not-found"}
+}
+
+// GetPluginAssetContent reads a frontend asset file from a plugin directory.
+// Security: validates that the assetPath is relative and does not escape the plugin root.
+func (a *App) GetPluginAssetContent(pluginID, assetPath string) (string, string) {
+ // Validate asset path — reject absolute paths and path traversal
+ if strings.HasPrefix(assetPath, "/") || strings.HasPrefix(assetPath, "\\") {
+ return "", "absolute paths not allowed"
+ }
+ if strings.Contains(assetPath, "..") {
+ return "", "path traversal not allowed"
+ }
+
+ // Find the plugin
+ var pluginRoot string
+ found := false
+ for _, p := range a.plugins {
+ if p.Manifest.ID == pluginID && p.Manifest.Frontend != nil {
+ pluginRoot = p.RootPath
+ found = true
+ break
+ }
+ }
+ if !found {
+ return "", "plugin not found or has no frontend"
+ }
+
+ // Resolve path relative to plugin root
+ fullPath := filepath.Join(pluginRoot, assetPath)
+ // Verify we haven't escaped plugin root
+ absRoot, _ := filepath.Abs(pluginRoot)
+ absPath, _ := filepath.Abs(fullPath)
+ if !strings.HasPrefix(absPath, absRoot+string(filepath.Separator)) && absPath != absRoot {
+ return "", "path escapes plugin root"
+ }
+
+ data, err := os.ReadFile(absPath)
+ if err != nil {
+ return "", fmt.Sprintf("failed to read asset: %v", err)
+ }
+ return string(data), ""
+}
diff --git a/internal/api/app_test.go b/internal/api/app_test.go
new file mode 100644
index 0000000..1e38588
--- /dev/null
+++ b/internal/api/app_test.go
@@ -0,0 +1,257 @@
+package api
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/verstak/verstak-desktop/internal/core/plugin"
+)
+
+// newTestApp creates an App with a mocked plugin list for testing.
+func newTestApp(tmpRoot string) *App {
+ return &App{
+ plugins: []plugin.Plugin{
+ {
+ Manifest: plugin.Manifest{
+ ID: "test.plugin",
+ Name: "Test Plugin",
+ Version: "1.0.0",
+ Icon: "🧪",
+ Frontend: &plugin.FrontendConfig{
+ Entry: "frontend/dist/index.js",
+ Style: "frontend/style.css",
+ },
+ Provides: []string{"test/cap/v1"},
+ Permissions: []string{"test.perm"},
+ },
+ RootPath: tmpRoot,
+ Status: plugin.StatusLoaded,
+ Enabled: true,
+ },
+ {
+ Manifest: plugin.Manifest{
+ ID: "no-fe.plugin",
+ Name: "No Frontend Plugin",
+ Provides: []string{"test/nofe/v1"},
+ Permissions: []string{"test.perm"},
+ },
+ RootPath: "/tmp/no-fe-plugin",
+ Status: plugin.StatusLoaded,
+ Enabled: true,
+ },
+ },
+ }
+}
+
+// TestGetPluginFrontendInfo_KnownPluginWithFrontend verifies that
+// GetPluginFrontendInfo returns correct metadata for a plugin with a frontend.
+func TestGetPluginFrontendInfo_KnownPluginWithFrontend(t *testing.T) {
+ app := newTestApp("/tmp/test-plugin")
+ info := app.GetPluginFrontendInfo("test.plugin")
+
+ if info["status"] != nil {
+ t.Errorf("unexpected status key: expected no status, got %v", info["status"])
+ }
+ if info["pluginId"] != "test.plugin" {
+ t.Errorf("pluginId: expected %q, got %v", "test.plugin", info["pluginId"])
+ }
+ if info["name"] != "Test Plugin" {
+ t.Errorf("name: expected %q, got %v", "Test Plugin", info["name"])
+ }
+ if info["icon"] != "🧪" {
+ t.Errorf("icon: expected %q, got %v", "🧪", info["icon"])
+ }
+ if info["version"] != "1.0.0" {
+ t.Errorf("version: expected %q, got %v", "1.0.0", info["version"])
+ }
+ if info["entry"] != "frontend/dist/index.js" {
+ t.Errorf("entry: expected %q, got %v", "frontend/dist/index.js", info["entry"])
+ }
+ if info["style"] != "frontend/style.css" {
+ t.Errorf("style: expected %q, got %v", "frontend/style.css", info["style"])
+ }
+ if info["rootPath"] != "/tmp/test-plugin" {
+ t.Errorf("rootPath: expected %q, got %v", "/tmp/test-plugin", info["rootPath"])
+ }
+}
+
+// TestGetPluginFrontendInfo_PluginWithoutFrontend verifies that
+// GetPluginFrontendInfo returns {"status": "no-frontend"} for a plugin
+// that has no FrontendConfig.
+func TestGetPluginFrontendInfo_PluginWithoutFrontend(t *testing.T) {
+ app := newTestApp("/tmp/test-plugin")
+ info := app.GetPluginFrontendInfo("no-fe.plugin")
+
+ if info["status"] != "no-frontend" {
+ t.Errorf("expected status %q, got %v", "no-frontend", info["status"])
+ }
+}
+
+// TestGetPluginFrontendInfo_UnknownPlugin verifies that
+// GetPluginFrontendInfo returns {"status": "not-found"} for a plugin ID
+// that does not exist.
+func TestGetPluginFrontendInfo_UnknownPlugin(t *testing.T) {
+ app := newTestApp("/tmp/test-plugin")
+ info := app.GetPluginFrontendInfo("nonexistent.plugin")
+
+ if info["status"] != "not-found" {
+ t.Errorf("expected status %q, got %v", "not-found", info["status"])
+ }
+}
+
+// TestGetPluginAssetContent_ExistingFile verifies that GetPluginAssetContent
+// can read an existing frontend file from a plugin directory.
+func TestGetPluginAssetContent_ExistingFile(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ // Create a test frontend file
+ frontendDir := filepath.Join(tmpDir, "frontend", "dist")
+ if err := os.MkdirAll(frontendDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+ content := "console.log('test');\n"
+ if err := os.WriteFile(filepath.Join(frontendDir, "index.js"), []byte(content), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ app := newTestApp(tmpDir)
+ got, errStr := app.GetPluginAssetContent("test.plugin", "frontend/dist/index.js")
+
+ if errStr != "" {
+ t.Errorf("unexpected error: %s", errStr)
+ }
+ if got != content {
+ t.Errorf("content mismatch:\n expected: %q\n got: %q", content, got)
+ }
+}
+
+// TestGetPluginAssetContent_ExistingStyleFile verifies reading a style file.
+func TestGetPluginAssetContent_ExistingStyleFile(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ frontendDir := filepath.Join(tmpDir, "frontend")
+ if err := os.MkdirAll(frontendDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+ content := "body { color: red; }\n"
+ if err := os.WriteFile(filepath.Join(frontendDir, "style.css"), []byte(content), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ app := newTestApp(tmpDir)
+ got, errStr := app.GetPluginAssetContent("test.plugin", "frontend/style.css")
+
+ if errStr != "" {
+ t.Errorf("unexpected error: %s", errStr)
+ }
+ if got != content {
+ t.Errorf("content mismatch:\n expected: %q\n got: %q", content, got)
+ }
+}
+
+// TestGetPluginAssetContent_AbsolutePathRejected verifies that asset paths
+// starting with "/" are rejected.
+func TestGetPluginAssetContent_AbsolutePathRejected(t *testing.T) {
+ app := newTestApp("/tmp/test-plugin")
+
+ _, errStr := app.GetPluginAssetContent("test.plugin", "/etc/passwd")
+ if errStr == "" {
+ t.Error("expected error for absolute path, got empty")
+ }
+ if !strings.Contains(strings.ToLower(errStr), "absolute") {
+ t.Errorf("error should mention 'absolute', got: %s", errStr)
+ }
+
+ // Also test Windows-style absolute path
+ _, errStr = app.GetPluginAssetContent("test.plugin", "\\etc\\passwd")
+ if errStr == "" {
+ t.Error("expected error for windows-style absolute path, got empty")
+ }
+}
+
+// TestGetPluginAssetContent_PathTraversalRejected verifies that asset paths
+// containing ".." are rejected.
+func TestGetPluginAssetContent_PathTraversalRejected(t *testing.T) {
+ app := newTestApp("/tmp/test-plugin")
+
+ _, errStr := app.GetPluginAssetContent("test.plugin", "../../etc/passwd")
+ if errStr == "" {
+ t.Error("expected error for path traversal, got empty")
+ }
+ if !strings.Contains(strings.ToLower(errStr), "traversal") {
+ t.Errorf("error should mention 'traversal', got: %s", errStr)
+ }
+}
+
+// TestGetPluginAssetContent_PathEscapeRejected verifies that paths that
+// resolve outside the plugin root directory are rejected after Join.
+// This tests the absRoot-prefix check in GetPluginAssetContent.
+func TestGetPluginAssetContent_PathEscapeRejected(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ // Do NOT create the traversed-to file — the security check happens
+ // before os.ReadFile, so the file should not matter.
+ app := newTestApp(tmpDir)
+
+ // Use a relative path with ".." that would escape the plugin root
+ // The code checks strings.Contains(assetPath, "..") first, so this
+ // would be caught at the traversal check. But let's also test a case
+ // where ".." is NOT in the path but Join resolves outside root.
+ // For instance: symlink-based escape (not testable easily) or
+ // Join "/tmp/root" + "foo/../../../etc" — but ".." is in there.
+ //
+ // Instead, test that the absRoot prefix check works: create a path
+ // that after cleaning technically starts differently but doesn't use "..".
+ // This is hard to reproduce without symlinks. The ".." check catches
+ // common cases. Let's just ensure the ".." check is solid:
+ _, errStr := app.GetPluginAssetContent("test.plugin", "frontend/../../etc/passwd")
+ if errStr == "" {
+ t.Error("expected error for path traversal via nested '..', got empty")
+ }
+}
+
+// TestGetPluginAssetContent_PluginNotFound verifies that GetPluginAssetContent
+// returns an error for a nonexistent plugin ID.
+func TestGetPluginAssetContent_PluginNotFound(t *testing.T) {
+ app := newTestApp("/tmp/test-plugin")
+
+ _, errStr := app.GetPluginAssetContent("nonexistent.plugin", "frontend/dist/index.js")
+ if errStr == "" {
+ t.Error("expected error for nonexistent plugin, got empty")
+ }
+ if !strings.Contains(strings.ToLower(errStr), "not found") &&
+ !strings.Contains(strings.ToLower(errStr), "no frontend") {
+ t.Errorf("error should mention 'not found' or 'no frontend', got: %s", errStr)
+ }
+}
+
+// TestGetPluginAssetContent_NoFrontend verifies that GetPluginAssetContent
+// returns an error for a plugin that exists but has no frontend config.
+func TestGetPluginAssetContent_NoFrontend(t *testing.T) {
+ app := newTestApp("/tmp/test-plugin")
+
+ _, errStr := app.GetPluginAssetContent("no-fe.plugin", "frontend/dist/index.js")
+ if errStr == "" {
+ t.Error("expected error for plugin without frontend, got empty")
+ }
+ if !strings.Contains(strings.ToLower(errStr), "no frontend") {
+ t.Errorf("error should mention 'no frontend', got: %s", errStr)
+ }
+}
+
+// TestGetPluginAssetContent_NonexistentFile verifies that GetPluginAssetContent
+// returns an error when the asset file does not exist on disk.
+func TestGetPluginAssetContent_NonexistentFile(t *testing.T) {
+ tmpDir := t.TempDir()
+ app := newTestApp(tmpDir)
+
+ _, errStr := app.GetPluginAssetContent("test.plugin", "frontend/dist/missing.js")
+ if errStr == "" {
+ t.Error("expected error for nonexistent file, got empty")
+ }
+ if !strings.Contains(strings.ToLower(errStr), "failed to read") {
+ t.Errorf("error should mention 'failed to read', got: %s", errStr)
+ }
+}