diff --git a/Dockerfile b/Dockerfile
index e92ba1f01..935bc0e6c 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -416,6 +416,9 @@ ENV NVIDIA_VISIBLE_DEVICES=all
WORKDIR /
+COPY deploy/addons/host-policy.json /etc/localai/addons/host-policy.json
+ENV LOCALAI_ADDON_HOST_POLICY=/etc/localai/addons/host-policy.json
+
COPY ./entrypoint.sh .
COPY ./scripts/build/healthcheck.sh .
diff --git a/addon_host_policy_architecture_test.go b/addon_host_policy_architecture_test.go
new file mode 100644
index 000000000..40a9aa3d8
--- /dev/null
+++ b/addon_host_policy_architecture_test.go
@@ -0,0 +1,102 @@
+// SPDX-License-Identifier: MIT
+package localai_test
+
+import (
+ "go/ast"
+ "go/parser"
+ "go/token"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "testing"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+func TestHostPolicyArchitecture(t *testing.T) {
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "Host policy architecture")
+}
+
+var _ = Describe("generic host policy architecture", func() {
+ It("keeps official addon identities out of generic CLI and config policy code", func() {
+ // Tests may retain historical fixtures. Deployment policy is deliberately
+ // outside these packages; unrelated model fields (e.g. quantization) are not IDs.
+ ids := []string{"fine-tuning", "quantization", "sandbox", "office", "web", "telegram"}
+ for _, root := range []string{"core/cli", "core/config"} {
+ err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+ if entry.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
+ return nil
+ }
+ file, err := parser.ParseFile(token.NewFileSet(), path, nil, 0)
+ if err != nil {
+ return err
+ }
+ policyCode := strings.Contains(filepath.Base(path), "addon_host_") || path == "core/cli/run.go"
+ ast.Inspect(file, func(node ast.Node) bool {
+ literal, ok := node.(*ast.BasicLit)
+ if !ok || literal.Kind != token.STRING {
+ return true
+ }
+ value, err := strconv.Unquote(literal.Value)
+ Expect(err).NotTo(HaveOccurred())
+ for _, id := range ids {
+ Expect(value).NotTo(ContainSubstring(id+"="), "%s embeds an official grant", path)
+ if policyCode {
+ Expect(value).NotTo(Equal(id), "%s embeds an official addon identity", path)
+ }
+ }
+ Expect(value).NotTo(ContainSubstring("default:\"fine-tuning="), "%s embeds a CLI default", path)
+ return true
+ })
+ return nil
+ })
+ Expect(err).NotTo(HaveOccurred())
+ }
+ })
+})
+
+var _ = Describe("typed extension architecture", func() {
+ It("keeps provider and Home dispatch independent of official addon IDs", func() {
+ // Only generic live dispatch surfaces are guarded. Legacy URL redirects,
+ // pin migration, native Studio code, deployment policy, and tests are not
+ // live addon dispatch and deliberately do not belong to this list.
+ paths := []string{"core/services/addons/apps", "core/services/addons/gateway/binding.go", "core/services/studio/executionselection.go"}
+ for _, root := range paths {
+ Expect(filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
+ return nil
+ }
+ f, err := parser.ParseFile(token.NewFileSet(), path, nil, 0)
+ if err != nil {
+ return err
+ }
+ ast.Inspect(f, func(n ast.Node) bool {
+ lit, ok := n.(*ast.BasicLit)
+ if !ok || lit.Kind != token.STRING {
+ return true
+ }
+ value, err := strconv.Unquote(lit.Value)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(value).NotTo(BeElementOf("studio", "sandbox"), "%s hardcodes a product ID in generic dispatch", path)
+ return true
+ })
+ return nil
+ })).To(Succeed())
+ }
+ data, err := os.ReadFile("core/http/react-ui/src/addons/homeCommands.js")
+ Expect(err).NotTo(HaveOccurred())
+ for _, literal := range []string{"'studio'", `"studio"`, "'sandbox'", `"sandbox"`} {
+ Expect(string(data)).NotTo(ContainSubstring(literal))
+ }
+ })
+})
diff --git a/addons/face/manifest.json b/addons/face/manifest.json
index 9aba07e9c..285557e6e 100644
--- a/addons/face/manifest.json
+++ b/addons/face/manifest.json
@@ -16,6 +16,7 @@
"sdk": { "min": 2, "max": 2 },
"pages": ["recognition"],
"navigation": [],
+ "modelActions": [{"capability": "FLAG_FACE_RECOGNITION", "page": "recognition"}],
"mainPage": "recognition",
"icon": "ui/icon.png",
"title": "Face",
diff --git a/addons/fine-tuning/manifest.json b/addons/fine-tuning/manifest.json
index b0bbe1f4d..9f714a9ed 100644
--- a/addons/fine-tuning/manifest.json
+++ b/addons/fine-tuning/manifest.json
@@ -22,6 +22,17 @@
"max": 1
},
"capabilities": {
+ "hostOperations": {
+ "required": [
+ "finetune.checkpoints",
+ "finetune.dataset",
+ "finetune.export",
+ "finetune.get",
+ "finetune.list",
+ "finetune.start",
+ "finetune.stop"
+ ]
+ },
"tools": [
"resource-exists",
"fine-tune-list",
diff --git a/addons/internal/host-compat/src/route.js b/addons/internal/host-compat/src/route.js
index 2228353c6..d8514817f 100644
--- a/addons/internal/host-compat/src/route.js
+++ b/addons/internal/host-compat/src/route.js
@@ -10,6 +10,7 @@
export const MOVED = Object.freeze([
{ prefix: '/app/studio', addon: 'studio', page: 'studio' },
{ prefix: '/app/talk', addon: 'talk', page: 'talk' },
+ { prefix: '/app/voice', addon: 'voice', page: 'voice' },
])
export function parseAddress(to) {
diff --git a/addons/internal/host-compat/test/route.test.mjs b/addons/internal/host-compat/test/route.test.mjs
index b05cb4652..a4245a174 100644
--- a/addons/internal/host-compat/test/route.test.mjs
+++ b/addons/internal/host-compat/test/route.test.mjs
@@ -38,3 +38,8 @@ test('params: the tab is the first segment, the model is ?model= or the second s
assert.deepEqual(paramsOf({ path: 'images/by-hand', search: '' }), { tab: 'images', model: 'by-hand' })
assert.deepEqual(paramsOf({ path: 'images/by-hand', search: 'model=wins' }), { tab: 'images', model: 'wins' })
})
+
+test('remembered voices opens the declared Voice addon page, not a removed host alias', () => {
+ assert.deepEqual(resolveTarget('/app/voice', 'studio'), { own: false, address: '/app/addons/voice/voice' })
+ assert.deepEqual(resolveTarget('/app/voice?model=encoder', 'voice'), { own: true, subpath: 'voice?model=encoder' })
+})
diff --git a/addons/internal/protocol/extensions_test.go b/addons/internal/protocol/extensions_test.go
new file mode 100644
index 000000000..86b507a7b
--- /dev/null
+++ b/addons/internal/protocol/extensions_test.go
@@ -0,0 +1,66 @@
+// SPDX-License-Identifier: MIT
+package protocol
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+
+ "github.com/mudler/LocalAI/pkg/addons/contract"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("typed declaration echo", func() {
+ It("echoes model actions and typed declarations together separately and absent over authenticated health", func() {
+ for mask := 0; mask < 16; mask++ {
+ caps := contract.Capabilities{Service: true}
+ if mask&1 != 0 {
+ caps.UI = &contract.UI{Entry: "ui/main.js", SDK: contract.VersionRange{Min: 2, Max: 2}, Pages: []string{"home"}, MainPage: "home", HomeCommands: []contract.HomeCommand{{ID: "open"}}}
+ }
+ if mask&8 != 0 {
+ if caps.UI == nil {
+ caps.UI = &contract.UI{Entry: "ui.js", SDK: contract.VersionRange{Min: 2, Max: 2}, Pages: []string{"home"}}
+ }
+ caps.UI.ModelActions = []contract.ModelAction{{Capability: "FLAG_IMAGE", Page: "home", Path: "images"}}
+ }
+ if mask&2 != 0 {
+ caps.Actions = true
+ caps.Tools = []string{"run", "health"}
+ caps.ToolDefinitions = []contract.ToolDefinition{{Name: "run", Description: "Run", Kind: "action", Shadows: "bash", InputSchema: json.RawMessage(`{"type":"object","properties":{"command":{"type":"string"}},"required":["command"],"additionalProperties":false}`)}, {Name: "health", Description: "Status", Kind: "read", UIOnly: true, InputSchema: json.RawMessage(`{"type":"object","additionalProperties":false}`)}}
+ caps.ExecutionProvider = &contract.ExecutionProvider{Tools: []string{"run"}, StatusTool: "health"}
+ }
+ if mask&4 != 0 {
+ caps.HostOperations = &contract.HostOperations{Required: []string{"workspace.materialize"}, Optional: []string{"egress.check"}}
+ }
+ v := contract.VersionRange{Min: 1, Max: 1}
+ manifest := contract.Manifest{SchemaVersion: 1, ID: "sample", Version: "1.0.0", Executable: "bin/sample", OS: "linux", Arch: "amd64", Host: v, API: v, Protocol: v, DataFormat: v, Capabilities: caps}
+ b, e := json.Marshal(manifest)
+ Expect(e).NotTo(HaveOccurred())
+ parsed, e := contract.ParseManifest(b)
+ Expect(e).NotTo(HaveOccurred())
+ Expect(parsed.Capabilities).To(Equal(caps))
+ original := Startup{Registration: Registration{AddonID: "sample", Generation: 7, Incarnation: "inc", Protocol: 1, Capabilities: parsed.Capabilities}, Credential: "secret", Limits: contract.DefaultLimits()}
+ b, e = json.Marshal(original)
+ Expect(e).NotTo(HaveOccurred())
+ var startup Startup
+ Expect(json.Unmarshal(b, &startup)).To(Succeed())
+ req := httptest.NewRequest(http.MethodPost, "/health", bytes.NewBufferString(`{}`))
+ req.Header.Set("Authorization", "Bearer secret")
+ req.Header.Set("X-LocalAI-Incarnation", "inc")
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ Handler(startup, nil).ServeHTTP(rec, req)
+ Expect(rec.Code).To(Equal(http.StatusOK))
+ var echo Registration
+ Expect(json.Unmarshal(rec.Body.Bytes(), &echo)).To(Succeed())
+ Expect(echo).To(Equal(original.Registration))
+ if mask == 0 {
+ for _, key := range []string{"homeCommands", "executionProvider", "hostOperations", "modelActions"} {
+ Expect(rec.Body.String()).NotTo(ContainSubstring(key))
+ }
+ }
+ }
+ })
+})
diff --git a/addons/office/manifest.json b/addons/office/manifest.json
index 315d00fda..1f1fc2405 100644
--- a/addons/office/manifest.json
+++ b/addons/office/manifest.json
@@ -5,6 +5,12 @@
},
"arch": "amd64",
"capabilities": {
+ "hostOperations": {
+ "required": [
+ "workspace.export",
+ "workspace.import"
+ ]
+ },
"actions": true,
"skillDefinitions": [
{
diff --git a/addons/quantization/manifest.json b/addons/quantization/manifest.json
index f8fb9914c..319221ba1 100644
--- a/addons/quantization/manifest.json
+++ b/addons/quantization/manifest.json
@@ -22,6 +22,15 @@
"max": 1
},
"capabilities": {
+ "hostOperations": {
+ "required": [
+ "quantize.get",
+ "quantize.import",
+ "quantize.list",
+ "quantize.start",
+ "quantize.stop"
+ ]
+ },
"tools": [
"resource-exists",
"quantize-list",
diff --git a/addons/sandbox/internal/tools/executionstatus_test.go b/addons/sandbox/internal/tools/executionstatus_test.go
new file mode 100644
index 000000000..23caf6ffb
--- /dev/null
+++ b/addons/sandbox/internal/tools/executionstatus_test.go
@@ -0,0 +1,39 @@
+// SPDX-License-Identifier: MIT
+package tools
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+
+ "github.com/mudler/LocalAI/addons/sandbox/internal/backends"
+ "github.com/mudler/LocalAI/pkg/addons/contract"
+)
+
+type typedSettings struct {
+ Settings
+ status backends.Status
+}
+
+func (s typedSettings) Status() backends.Status { return s.status }
+
+var _ = Describe("typed execution status", func() {
+ for _, tc := range []struct{ active, readiness, isolation string }{
+ {"gvisor", "available", "isolated"}, {"kvm", "available", "isolated"}, {"host", "available", "host"}, {"", "unavailable", "unknown"}, {"future", "unknown", "unknown"},
+ } {
+ It(fmt.Sprintf("reports %s as %s with %s isolation", tc.active, tc.readiness, tc.isolation), func() {
+ r := New(nil, typedSettings{status: backends.Status{Active: tc.active}}, nil)
+ var env contract.Envelope
+ env.Request.OperationID = "typed-read"
+ env.Request.Arguments = json.RawMessage(`{"tool":"execution-status"}`)
+ result, err := r.Read(context.Background(), env)
+ Expect(err).NotTo(HaveOccurred())
+ status, err := contract.DecodeExecutionStatus(result.Payload)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(status.Readiness).To(Equal(tc.readiness))
+ Expect(status.Isolation).To(Equal(tc.isolation))
+ })
+ }
+})
diff --git a/addons/sandbox/internal/tools/tools.go b/addons/sandbox/internal/tools/tools.go
index fa0953bf8..8fe7e5fe8 100644
--- a/addons/sandbox/internal/tools/tools.go
+++ b/addons/sandbox/internal/tools/tools.go
@@ -51,14 +51,15 @@ func Runs(acquire runner.Acquire) []runner.Tool {
// confirm-host are for the Settings page only (UIOnly), so no model is offered
// them, and each asks the host whether the caller is an administrator.
const (
- ToolStatus = "status"
- ToolSetBackend = "set-backend"
- ToolConfirmHost = "confirm-host"
+ ToolExecutionStatus = "execution-status"
+ ToolStatus = "status"
+ ToolSetBackend = "set-backend"
+ ToolConfirmHost = "confirm-host"
)
// Declared is everything the manifest lists under capabilities.tools.
func Declared() []string {
- return append(append([]string{}, Tools...), ToolStatus, ToolSetBackend, ToolConfirmHost)
+ return append(append([]string{}, Tools...), ToolExecutionStatus, ToolStatus, ToolSetBackend, ToolConfirmHost)
}
// Settings is the choice of backend. *backends.Selector implements all but
@@ -140,6 +141,10 @@ func Definitions() []contract.ToolDefinition {
// them from every model; the add-on still asks the host whether the caller
// is an administrator.
return append(out, contract.ToolDefinition{
+ Name: ToolExecutionStatus, Kind: contract.ToolKindRead, UIOnly: true,
+ Description: "Report typed execution readiness and isolation.",
+ InputSchema: json.RawMessage(`{"type":"object","properties":{},"additionalProperties":false}`),
+ }, contract.ToolDefinition{
Name: ToolStatus, Kind: contract.ToolKindRead,
Description: "Say whether this host can run commands in a sandbox now, and which backend runs them.",
InputSchema: json.RawMessage(`{"type":"object","properties":{},"additionalProperties":false}`),
@@ -176,6 +181,22 @@ func (r *runtime) Read(ctx context.Context, env contract.Envelope) (contract.Res
refuse := func(msg string) (contract.Result, error) {
return contract.Result{Payload: protocol.Payload(map[string]any{"ok": false, "error": msg})}, nil
}
+ // The typed observation is callback-free, including administrator lookups.
+ if a.Tool == ToolExecutionStatus {
+ status := contract.ExecutionStatus{Readiness: "unknown", Isolation: "unknown"}
+ if r.settings != nil {
+ switch r.settings.Status().Active {
+ case "gvisor", "kvm":
+ status.Readiness, status.Isolation = "available", "isolated"
+ case "host":
+ status.Readiness, status.Isolation = "available", "host"
+ case "":
+ status.Readiness = "unavailable"
+ status.Explanation = "No execution backend is available."
+ }
+ }
+ return contract.Result{Payload: protocol.Payload(status)}, nil
+ }
admin := r.admin != nil && r.admin.IsAdmin(ctx, op)
switch a.Tool {
case ToolStatus:
diff --git a/addons/sandbox/internal/tools/tools_test.go b/addons/sandbox/internal/tools/tools_test.go
index a56c6b41a..f191f62e1 100644
--- a/addons/sandbox/internal/tools/tools_test.go
+++ b/addons/sandbox/internal/tools/tools_test.go
@@ -160,7 +160,7 @@ var _ = Describe("Sandbox tools", func() {
// chat's own file tools. Its bash replaces the chat's built-in bash.
It("offers bash and the settings tools only, and bash shadows the built-in", func() {
Expect(tools.Tools).To(Equal([]string{"bash"}))
- Expect(tools.Declared()).To(Equal([]string{"bash", "status", "set-backend", "confirm-host"}))
+ Expect(tools.Declared()).To(Equal([]string{"bash", "execution-status", "status", "set-backend", "confirm-host"}))
for _, d := range tools.Definitions() {
if d.Name == "bash" {
Expect(d.Shadows).To(Equal("bash"))
@@ -279,7 +279,7 @@ var _ = Describe("Sandbox tools", func() {
switch d.Name {
case "status":
Expect(d.Kind).To(Equal(contract.ToolKindRead), "status must be a read tool")
- case "set-backend", "confirm-host":
+ case "execution-status", "set-backend", "confirm-host":
Expect(d.Kind).To(Equal(contract.ToolKindRead), "%s must be a read tool for the settings page only", d.Name)
Expect(d.UIOnly).To(BeTrue(), "%s must be a read tool for the settings page only", d.Name)
default:
diff --git a/addons/sandbox/manifest.json b/addons/sandbox/manifest.json
index 15673c4d6..b698af0ec 100644
--- a/addons/sandbox/manifest.json
+++ b/addons/sandbox/manifest.json
@@ -5,6 +5,18 @@
},
"arch": "amd64",
"capabilities": {
+ "hostOperations": {
+ "required": [
+ "workspace.changes",
+ "workspace.commit",
+ "workspace.materialize"
+ ],
+ "optional": [
+ "egress.audit",
+ "egress.check",
+ "workspace.admin"
+ ]
+ },
"actions": true,
"toolDefinitions": [
{
@@ -31,6 +43,17 @@
},
"shadows": "bash"
},
+ {
+ "name": "execution-status",
+ "kind": "read",
+ "uiOnly": true,
+ "description": "Report typed execution readiness and isolation.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {},
+ "additionalProperties": false
+ }
+ },
{
"name": "status",
"description": "Say whether this host can run commands in a sandbox now, and which backend runs them.",
@@ -87,6 +110,7 @@
],
"tools": [
"bash",
+ "execution-status",
"status",
"set-backend",
"confirm-host"
@@ -136,7 +160,13 @@
"name": "xlsx",
"shadows": "xlsx"
}
- ]
+ ],
+ "executionProvider": {
+ "tools": [
+ "bash"
+ ],
+ "statusTool": "execution-status"
+ }
},
"dataFormat": {
"max": 1,
diff --git a/addons/studio/manifest.json b/addons/studio/manifest.json
index 683e0b5f0..aca466ab3 100644
--- a/addons/studio/manifest.json
+++ b/addons/studio/manifest.json
@@ -16,7 +16,9 @@
"sdk": { "min": 2, "max": 2 },
"pages": ["studio"],
"navigation": ["studio"],
+ "modelActions": [{"capability": "FLAG_IMAGE", "page": "studio", "path": "images"}, {"capability": "FLAG_VIDEO", "page": "studio", "path": "video"}, {"capability": "FLAG_TTS", "page": "studio", "path": "tts"}, {"capability": "FLAG_SOUND_GENERATION", "page": "studio", "path": "sound"}],
"mainPage": "studio",
+ "homeCommands": [{ "id": "open", "alias": "studio", "icon": "palette" }],
"icon": "ui/icon.png",
"title": "Studio",
"description": "Make images, video, 3D models, speech, sound and transformed audio with the models you have installed, and keep what you make."
diff --git a/addons/talk/manifest.json b/addons/talk/manifest.json
index bc47216aa..58f8452a0 100644
--- a/addons/talk/manifest.json
+++ b/addons/talk/manifest.json
@@ -16,6 +16,7 @@
"sdk": { "min": 2, "max": 2 },
"pages": ["talk"],
"navigation": ["talk"],
+ "modelActions": [{"capability": "FLAG_TRANSCRIPT", "page": "talk"}],
"mainPage": "talk",
"icon": "ui/icon.png",
"title": "Talk",
diff --git a/addons/voice/manifest.json b/addons/voice/manifest.json
index 3a3eb1d68..ac19090ac 100644
--- a/addons/voice/manifest.json
+++ b/addons/voice/manifest.json
@@ -16,6 +16,7 @@
"sdk": { "min": 2, "max": 2 },
"pages": ["voice", "recognition", "library", "create", "speech"],
"navigation": ["voice"],
+ "modelActions": [{"capability": "FLAG_SPEAKER_RECOGNITION", "page": "recognition"}],
"mainPage": "voice",
"icon": "ui/icon.png",
"title": "Voice",
diff --git a/addons/web/manifest.json b/addons/web/manifest.json
index bc22265ee..9a8e352bf 100644
--- a/addons/web/manifest.json
+++ b/addons/web/manifest.json
@@ -5,6 +5,11 @@
},
"arch": "amd64",
"capabilities": {
+ "hostOperations": {
+ "optional": [
+ "workspace.admin"
+ ]
+ },
"toolDefinitions": [
{
"name": "search",
diff --git a/core/application/addon_gateway.go b/core/application/addon_gateway.go
index ac7f8f6e4..b08d40216 100644
--- a/core/application/addon_gateway.go
+++ b/core/application/addon_gateway.go
@@ -313,6 +313,9 @@ func (a *Application) startAddonGateway() error {
}
return auth.ValidateAddonActor(ctx, a.authDB, actor, true)
}
+ if e := a.wireExecutionBinding(g); e != nil {
+ return e
+ }
// Only the owner holds the policy store. Other frontends reach it through the
// gateway, and without it the gateway keeps denying every resource.
if !filepath.IsAbs(cfg.Root) || cfg.ListenAddress == "" {
@@ -662,12 +665,13 @@ func filterByFeature(tools []studio.AddonTool, hostOps map[string][]string, has
return out
}
-// sandboxAddonID is the add-on that runs commands and code.
-const sandboxAddonID = "sandbox"
-
// needsWorkspace says whether the add-on works on the files of the user.
func (s *studioAddons) needsWorkspace(addonID string) bool {
- for _, p := range s.hostOps[addonID] {
+ return hasWorkspaceOperation(s.hostOps[addonID])
+}
+
+func hasWorkspaceOperation(operations []string) bool {
+ for _, p := range operations {
if strings.HasPrefix(p, "workspace.") {
return true
}
@@ -675,69 +679,28 @@ func (s *studioAddons) needsWorkspace(addonID string) bool {
return false
}
-// Unavailable says why the tools of an add-on cannot work now, or "". An
-// add-on that needs the Files workspace cannot work without it, and the
-// Sandbox cannot run anything on a host where no backend works.
+// Unavailable consumes only the owner-selected request-local observation.
func (s *studioAddons) Unavailable(ctx context.Context, user, addonID string, workspace bool) string {
if !workspace && s.needsWorkspace(addonID) {
return "the Files workspace is off on this server, so there is no place for files or code"
}
- if addonID != sandboxAddonID {
- return ""
+ value, explanation := studio.ExecutionObservation(ctx, user, addonID)
+ if value.Readiness == "unavailable" {
+ if explanation != "" {
+ return explanation
+ }
+ return "the selected execution provider is unavailable"
}
- actor, err := s.actor(user)
- if err != nil {
- return ""
- }
- st, ok := s.sandboxStatus(ctx, actor)
- if !ok || st.CanRun {
- return ""
- }
- return "no sandbox backend works on this host, so commands cannot run"
+ return ""
}
-// sandboxState is the part of the Sandbox add-on's status read that the chats use.
-type sandboxState struct {
- OK bool `json:"ok"`
- CanRun bool `json:"canRun"`
- Active string `json:"active"`
- HostNotice bool `json:"hostNotice"`
-}
-
-// sandboxStatus reads the Sandbox add-on's status as the actor. ok is false
-// when the read failed or the add-on did not answer ok.
-func (s *studioAddons) sandboxStatus(ctx context.Context, actor contract.Actor) (sandboxState, bool) {
- ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
- defer cancel()
- res, err := s.adapter.Execute(ctx, actor, addontools.ToolName(sandboxAddonID, "status"), json.RawMessage(`{}`))
- if err != nil || res.Kind != addontools.ResultData {
- return sandboxState{}, false
- }
- var st sandboxState
- if json.Unmarshal(res.Result, &st) != nil || !st.OK {
- return sandboxState{}, false
- }
- return st, true
-}
-
-// Isolation says how the Sandbox add-on runs commands: "isolated", "host" (on
-// the server, with no isolation) or "" when it cannot tell.
+// Isolation is unknown outside an admitted scope or after freshness is lost.
func (s *studioAddons) Isolation(ctx context.Context, user, addonID string) string {
- if addonID != sandboxAddonID {
+ value, _ := studio.ExecutionObservation(ctx, user, addonID)
+ if value.Readiness != "available" || value.Isolation == "unknown" {
return ""
}
- actor, err := s.actor(user)
- if err != nil {
- return ""
- }
- st, ok := s.sandboxStatus(ctx, actor)
- switch {
- case !ok || !st.CanRun:
- return ""
- case st.HostNotice || st.Active == "host":
- return "host"
- }
- return "isolated"
+ return string(value.Isolation)
}
// Controls returns the host controls the apps list draws for the add-on, as
diff --git a/core/application/addon_pin_store_internal_test.go b/core/application/addon_pin_store_internal_test.go
index b35467db4..f3e5b9ab4 100644
--- a/core/application/addon_pin_store_internal_test.go
+++ b/core/application/addon_pin_store_internal_test.go
@@ -3,32 +3,82 @@ package application
import (
"bytes"
+ "context"
+ "fmt"
"log/slog"
+ "os"
+ "os/exec"
"path/filepath"
+ "testing"
+ "time"
"github.com/mudler/xlog"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
-func capture() *bytes.Buffer {
+const warningCaseEnv = "LOCALAI_WARNING_TEST_CASE"
+const warningDirEnv = "LOCALAI_WARNING_TEST_DIR"
+
+// Run only the helper, not the Ginkgo suite: other specs leave background log
+// producers running. Replacing xlog's global logger alongside them is a race.
+func runWarningCase(test, id string) {
+ executable, err := os.Executable()
+ Expect(err).NotTo(HaveOccurred())
+ ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
+ defer cancel()
+ cmd := exec.CommandContext(ctx, executable, "-test.run=^"+test+"$", "-test.count=1", "-test.v")
+ cmd.Env = append(os.Environ(), warningCaseEnv+"="+id, warningDirEnv+"="+GinkgoT().TempDir())
+ output := &warningOutput{}
+ cmd.Stdout, cmd.Stderr = output, output
+ err = cmd.Run()
+ Expect(err).NotTo(HaveOccurred(), "warning case %s (%v):\n%s", id, ctx.Err(), output.String())
+ Expect(output.String()).To(ContainSubstring("warning case passed: "+id), "child must execute its assertions")
+}
+
+// Keep failure diagnostics bounded even if a child unexpectedly logs forever.
+type warningOutput struct{ bytes.Buffer }
+
+func (b *warningOutput) Write(p []byte) (int, error) {
+ n := len(p)
+ if remaining := 64*1024 - b.Len(); remaining > 0 {
+ if len(p) > remaining {
+ p = p[:remaining]
+ }
+ _, _ = b.Buffer.Write(p)
+ }
+ return n, nil
+}
+
+func TestPinStoreWarningProcess(t *testing.T) {
+ id := os.Getenv(warningCaseEnv)
+ if id == "" {
+ return
+ }
+ RegisterTestingT(t)
var buf bytes.Buffer
+ // This process starts no background application and never restores the global.
xlog.SetLogger(xlog.NewLoggerWithHandler(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}), xlog.LogLevelDebug))
- return &buf
+ switch id {
+ case "memory-pins":
+ _, err := pinStoreFor(nil, "")
+ Expect(err).NotTo(HaveOccurred())
+ Expect(bytes.Count(buf.Bytes(), []byte("memory only"))).To(Equal(1), "log: %q", buf.String())
+ case "file-pins":
+ _, err := pinStoreFor(nil, filepath.Join(os.Getenv(warningDirEnv), "addon-pins.json"))
+ Expect(err).NotTo(HaveOccurred())
+ Expect(buf.Len()).To(BeZero(), "unexpected log: %q", buf.String())
+ default:
+ Expect(id).To(BeEmpty(), "unknown pin warning case")
+ }
+ fmt.Println("warning case passed: " + id)
}
var _ = Describe("pinStoreFor", func() {
It("logs one warning for memory-only pins", func() {
- buf := capture()
- _, err := pinStoreFor(nil, "")
- Expect(err).ToNot(HaveOccurred())
- Expect(bytes.Count(buf.Bytes(), []byte("memory only"))).To(Equal(1), "log: %q", buf.String())
+ runWarningCase("TestPinStoreWarningProcess", "memory-pins")
})
-
It("does not warn with a pins file", func() {
- buf := capture()
- _, err := pinStoreFor(nil, filepath.Join(GinkgoT().TempDir(), "addon-pins.json"))
- Expect(err).ToNot(HaveOccurred())
- Expect(buf.Len()).To(BeZero(), "unexpected log: %q", buf.String())
+ runWarningCase("TestPinStoreWarningProcess", "file-pins")
})
})
diff --git a/core/application/addon_pins.go b/core/application/addon_pins.go
index d2ca3bba1..629b3aa85 100644
--- a/core/application/addon_pins.go
+++ b/core/application/addon_pins.go
@@ -7,7 +7,6 @@ import (
"path/filepath"
"time"
- "github.com/mudler/LocalAI/core/http/auth"
"github.com/mudler/LocalAI/core/services/addons/builtin"
"github.com/mudler/LocalAI/core/services/addons/pins"
"github.com/mudler/LocalAI/core/services/advisorylock"
@@ -18,12 +17,8 @@ import (
// addonPinSweepEvery is how often the retention sweep runs.
const addonPinSweepEvery = 6 * time.Hour
-// addonPinSeedTimeout bounds the one-time upgrade seed at start.
-const addonPinSeedTimeout = 30 * time.Second
-
// AddonPinStore returns the pin store, creating it once so the routes and the
-// retention sweep share it. Creating it also runs the one-time upgrade seed for
-// the accounts that exist now. An error is cached: a store that could not be
+// retention sweep share it. An error is cached: a store that could not be
// opened stays unavailable, and startup must stop rather than fall back to
// memory and lose every unpin at the next restart.
func (a *Application) AddonPinStore() (pins.Store, error) {
@@ -34,39 +29,10 @@ func (a *Application) AddonPinStore() (pins.Store, error) {
return
}
a.addonPinStore = s
- // The seed gets a short bound so a stuck database cannot hold startup.
- ctx, cancel := context.WithTimeout(context.Background(), addonPinSeedTimeout)
- defer cancel()
- accounts, err := a.existingPinAccounts()
- if err != nil {
- // No marker is written: the seed runs again at the next start, so a
- // failed read does not cost the existing accounts their pins.
- xlog.Warn("addon pin upgrade seed skipped; it will run again at the next start", "error", err)
- return
- }
- if _, err := s.SeedExisting(ctx, accounts, pins.SeedIDs); err != nil {
- xlog.Warn("addon pin upgrade seed failed; it will run again at the next start", "error", err)
- }
})
return a.addonPinStore, a.addonPinStoreErr
}
-// existingPinAccounts are the keys pins are stored under for the accounts that
-// exist at this moment: every user of the account database, or the one local
-// owner when authentication is off. The local owner counts as an existing
-// account, so a deployment with authentication off keeps its Studio and Talk
-// entries too.
-func (a *Application) existingPinAccounts() ([]string, error) {
- if a.authDB == nil {
- return []string{auth.AddonLocalOwnerID}, nil
- }
- var ids []string
- if err := a.authDB.Model(&auth.User{}).Pluck("id", &ids).Error; err != nil {
- return nil, fmt.Errorf("list the accounts for the pin seed: %w", err)
- }
- return ids, nil
-}
-
// pinStoreFor picks the store: the shared account database when there is one,
// otherwise a file next to the other add-on data (one local owner, one host).
func pinStoreFor(db *gorm.DB, dataPath string) (pins.Store, error) {
diff --git a/core/application/addon_pins_test.go b/core/application/addon_pins_test.go
index f0634800f..78166fead 100644
--- a/core/application/addon_pins_test.go
+++ b/core/application/addon_pins_test.go
@@ -16,7 +16,7 @@ import (
"gorm.io/gorm"
)
-var _ = Describe("add-on pin store and the upgrade seed", func() {
+var _ = Describe("add-on pin store", func() {
pinIDs := func(s pins.Store, user string) []string {
list, err := s.List(context.Background(), user)
Expect(err).NotTo(HaveOccurred())
@@ -38,68 +38,72 @@ var _ = Describe("add-on pin store and the upgrade seed", func() {
return s
}
- It("pins studio and talk once for the accounts that exist and never again", func() {
+ It("starts the local owner without implicit pins", func() {
+ s := store(appOver(nil, GinkgoT().TempDir()))
+ Expect(pinIDs(s, auth.AddonLocalOwnerID)).To(BeEmpty())
+ })
+ It("starts existing accounts without implicit pins", func() {
db, err := auth.InitDB(filepath.Join(GinkgoT().TempDir(), "auth.db"))
Expect(err).NotTo(HaveOccurred())
- for _, id := range []string{"u1", "u2"} {
- Expect(db.Create(&auth.User{ID: id, Email: id + "@example.test", Provider: auth.ProviderLocal, Role: auth.RoleUser, Status: auth.StatusActive}).Error).To(Succeed())
- }
+ Expect(db.Create(&auth.User{ID: "existing", Email: "existing@example.test", Provider: auth.ProviderLocal, Role: auth.RoleUser, Status: auth.StatusActive}).Error).To(Succeed())
+ Expect(pinIDs(store(appOver(db, "")), "existing")).To(BeEmpty())
+ })
+
+ It("preserves saved user pins across a second frontend and reopen", func() {
+ path := filepath.Join(GinkgoT().TempDir(), "auth.db")
+ db, err := auth.InitDB(path)
+ Expect(err).NotTo(HaveOccurred())
+ saved, err := pins.NewGormStore(db)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(saved.Replace(context.Background(), "u1", []string{"studio", "talk", "arbitrary"})).To(Succeed())
a := appOver(db, "")
s := store(a)
- Expect(pinIDs(s, "u1")).To(Equal([]string{"studio", "talk"}))
- Expect(pinIDs(s, "u2")).To(Equal([]string{"studio", "talk"}))
-
- // An account created later starts with no pins, and a second seed does nothing.
- Expect(db.Create(&auth.User{ID: "u3", Email: "u3@example.test", Provider: auth.ProviderLocal, Role: auth.RoleUser, Status: auth.StatusActive}).Error).To(Succeed())
- Expect(pinIDs(s, "u3")).To(BeEmpty())
- ran, err := s.SeedExisting(context.Background(), []string{"u3"}, pins.SeedIDs)
- Expect(err).NotTo(HaveOccurred())
- Expect(ran).To(BeFalse())
-
- // The store is created once per application.
Expect(store(a)).To(BeIdenticalTo(s))
-
- // An unpin sticks across a restart or a second frontend over the same database.
+ Expect(pinIDs(s, "u1")).To(Equal([]string{"studio", "talk", "arbitrary"}))
Expect(s.Replace(context.Background(), "u1", []string{"talk"})).To(Succeed())
- again := store(appOver(db, ""))
- Expect(pinIDs(again, "u1")).To(Equal([]string{"talk"}))
- Expect(pinIDs(again, "u3")).To(BeEmpty())
+ Expect(pinIDs(store(appOver(db, "")), "u1")).To(Equal([]string{"talk"}))
+ db2, err := auth.InitDB(path)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(pinIDs(store(appOver(db2, "")), "u1")).To(Equal([]string{"talk"}))
+ Expect(pinIDs(store(appOver(db2, "")), "new")).To(BeEmpty())
})
- It("does not mark the seed as run when the accounts cannot be read, and seeds at the next start", func() {
+ It("opens the pin store without scanning accounts or creating seed metadata", func() {
db, err := auth.InitDB(filepath.Join(GinkgoT().TempDir(), "auth.db"))
Expect(err).NotTo(HaveOccurred())
- Expect(db.Create(&auth.User{ID: "u1", Email: "u1@example.test", Provider: auth.ProviderLocal, Role: auth.RoleUser, Status: auth.StatusActive}).Error).To(Succeed())
Expect(db.Migrator().RenameTable(&auth.User{}, "users_away")).To(Succeed())
-
s := store(appOver(db, ""))
Expect(pinIDs(s, "u1")).To(BeEmpty())
- var marks int64
- Expect(db.Model(&pins.SeedRun{}).Count(&marks).Error).To(Succeed())
- Expect(marks).To(BeZero())
-
- Expect(db.Migrator().RenameTable("users_away", &auth.User{})).To(Succeed())
- next := store(appOver(db, ""))
- Expect(pinIDs(next, "u1")).To(Equal([]string{"studio", "talk"}))
+ Expect(db.Migrator().HasTable("addon_pin_seed_runs")).To(BeFalse())
+ Expect(s.Replace(context.Background(), "u1", []string{"arbitrary"})).To(Succeed())
+ Expect(pinIDs(store(appOver(db, "")), "u1")).To(Equal([]string{"arbitrary"}))
})
- It("seeds the local owner when authentication is off and keeps an unpin", func() {
+ It("keeps explicit local-owner pins and unpins across reopen", func() {
dir := GinkgoT().TempDir()
s := store(appOver(nil, dir))
- Expect(pinIDs(s, auth.AddonLocalOwnerID)).To(Equal([]string{"studio", "talk"}))
+ Expect(pinIDs(s, auth.AddonLocalOwnerID)).To(BeEmpty())
+ Expect(s.Replace(context.Background(), auth.AddonLocalOwnerID, []string{"studio", "talk"})).To(Succeed())
Expect(s.Replace(context.Background(), auth.AddonLocalOwnerID, []string{"talk"})).To(Succeed())
again := store(appOver(nil, dir))
Expect(pinIDs(again, auth.AddonLocalOwnerID)).To(Equal([]string{"talk"}))
raw, err := os.ReadFile(filepath.Join(dir, "addon-pins.json"))
Expect(err).NotTo(HaveOccurred())
- Expect(string(raw)).To(ContainSubstring(pins.UpgradeSeedName))
+ Expect(string(raw)).NotTo(ContainSubstring("seedRuns"))
+ Expect(again.Replace(context.Background(), auth.AddonLocalOwnerID, []string{})).To(Succeed())
+ Expect(pinIDs(store(appOver(nil, dir)), auth.AddonLocalOwnerID)).To(BeEmpty())
})
It("reports a corrupt pins file instead of falling back to memory", func() {
dir := GinkgoT().TempDir()
Expect(os.WriteFile(filepath.Join(dir, "addon-pins.json"), []byte("{broken"), 0o600)).To(Succeed())
- _, err := appOver(nil, dir).AddonPinStore()
+ a := appOver(nil, dir)
+ _, err := a.AddonPinStore()
Expect(err).To(HaveOccurred())
+ Expect(os.Remove(filepath.Join(dir, "addon-pins.json"))).To(Succeed())
+ s, again := a.AddonPinStore()
+ Expect(s).To(BeNil())
+ Expect(again).To(BeIdenticalTo(err))
})
})
diff --git a/core/application/addons_auth_test.go b/core/application/addons_auth_test.go
index 30a6d073f..b79cec469 100644
--- a/core/application/addons_auth_test.go
+++ b/core/application/addons_auth_test.go
@@ -14,6 +14,7 @@ import (
"crypto/x509/pkix"
"encoding/pem"
"errors"
+ "fmt"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/http/auth"
"github.com/mudler/LocalAI/core/services/addons"
@@ -32,6 +33,7 @@ import (
"path/filepath"
"sync"
"sync/atomic"
+ "testing"
"time"
)
@@ -260,39 +262,7 @@ var _ = Describe("addon policy bootstrap", func() {
It("keeps named-user mode by default and selects local-owner only explicitly with a warning", func() {
requireAddonRuntime()
- logs := &logCapture{}
- xlog.SetLogger(xlog.NewLoggerWithHandler(slog.NewTextHandler(logs, &slog.HandlerOptions{Level: slog.LevelWarn}), xlog.LogLevelWarn))
- DeferCleanup(func() { xlog.SetLogger(xlog.NewLogger(xlog.LogLevel("info"), "text")) })
-
- named := setup()
- named.authDB = authDB()
- Expect(start(named, base())).To(Succeed())
- DeferCleanup(named.Shutdown)
- Expect(named.AddonOwnershipMode()).To(Equal("named-user"))
- Expect(logs.String()).NotTo(ContainSubstring("local-owner"))
- // Named mode still refuses to run without the account database.
- noDB := setup()
- Expect(start(noDB, base())).NotTo(Succeed())
-
- c := base()
- c.OwnershipMode = "local-owner"
- a := setup() // no account database: local-owner mode does not need one
- Expect(start(a, c)).To(Succeed())
- DeferCleanup(a.Shutdown)
- Expect(a.AddonOwnershipMode()).To(Equal("local-owner"))
- Expect(logs.String()).To(ContainSubstring("local-owner"))
- Expect(logs.String()).To(ContainSubstring("unauthenticated callers share"))
- Expect(logs.String()).To(ContainSubstring("can reach this port can confirm addon proposals"))
- local := contract.Actor{UserID: "local-owner", CredentialID: "local-owner", Mode: "local-owner"}
- key, e := a.AddonPolicy().Register(context.Background(), local, "ghost")
- Expect(e).To(HaveOccurred(), "creation authority still needs an installed addon")
- _ = key
- // Sharing is disabled by the store mode.
- Expect(a.AddonPolicy().SetGrant(context.Background(), local, policy.ResourceKey{AddonID: "sample", ResourceID: "x"}, "bob", policy.RoleViewer)).NotTo(Succeed())
-
- bad := base()
- bad.OwnershipMode = "shared"
- Expect(start(setup(), bad)).NotTo(Succeed())
+ runWarningCase("TestAddonOwnershipWarningProcess", "owner-mode")
})
})
@@ -336,3 +306,66 @@ var _ = Describe("addon owner in a full application start", func() {
Expect(a.AddonManager()).NotTo(BeNil())
})
})
+
+// TestAddonOwnershipWarningProcess deliberately does not run the Ginkgo suite.
+// The logger is installed before producers start and is never restored.
+func TestAddonOwnershipWarningProcess(t *testing.T) {
+ if os.Getenv(warningCaseEnv) == "" {
+ return
+ }
+ RegisterTestingT(t)
+ Expect(os.Getenv(warningCaseEnv)).To(Equal("owner-mode"))
+ dir, err := filepath.EvalSymlinks(os.Getenv(warningDirEnv))
+ Expect(err).NotTo(HaveOccurred())
+ next := 0
+ setup := func() *Application {
+ return &Application{applicationConfig: &config.ApplicationConfig{Context: context.Background()}}
+ }
+ base := func() config.AddonConfig {
+ next++
+ return config.AddonConfig{Enabled: true, InstanceID: "owner", OwnerID: "owner", OwnerAddress: "https://owner.example", StorageID: "local", Root: filepath.Join(dir, fmt.Sprintf("addons-%d", next)), ListenAddress: "127.0.0.1:0", Transport: ownerTransport()}
+ }
+ authDB := func() *gorm.DB {
+ db, err := auth.InitDB(filepath.Join(dir, "auth.db"))
+ Expect(err).NotTo(HaveOccurred())
+ return db
+ }
+ start := func(a *Application, c config.AddonConfig) error {
+ a.applicationConfig.Addons = c
+ return a.startAddons()
+ }
+
+ logs := &logCapture{}
+ xlog.SetLogger(xlog.NewLoggerWithHandler(slog.NewTextHandler(logs, &slog.HandlerOptions{Level: slog.LevelWarn}), xlog.LogLevelWarn))
+
+ named := setup()
+ named.authDB = authDB()
+ Expect(start(named, base())).To(Succeed())
+ defer func() { Expect(named.Shutdown()).To(Succeed()) }()
+ Expect(named.AddonOwnershipMode()).To(Equal("named-user"))
+ Expect(logs.String()).NotTo(ContainSubstring("local-owner"))
+ // Named mode still refuses to run without the account database.
+ noDB := setup()
+ Expect(start(noDB, base())).NotTo(Succeed())
+
+ c := base()
+ c.OwnershipMode = "local-owner"
+ a := setup() // no account database: local-owner mode does not need one
+ Expect(start(a, c)).To(Succeed())
+ defer func() { Expect(a.Shutdown()).To(Succeed()) }()
+ Expect(a.AddonOwnershipMode()).To(Equal("local-owner"))
+ Expect(logs.String()).To(ContainSubstring("local-owner"))
+ Expect(logs.String()).To(ContainSubstring("unauthenticated callers share"))
+ Expect(logs.String()).To(ContainSubstring("can reach this port can confirm addon proposals"))
+ local := contract.Actor{UserID: "local-owner", CredentialID: "local-owner", Mode: "local-owner"}
+ key, e := a.AddonPolicy().Register(context.Background(), local, "ghost")
+ Expect(e).To(HaveOccurred(), "creation authority still needs an installed addon")
+ _ = key
+ // Sharing is disabled by the store mode.
+ Expect(a.AddonPolicy().SetGrant(context.Background(), local, policy.ResourceKey{AddonID: "sample", ResourceID: "x"}, "bob", policy.RoleViewer)).NotTo(Succeed())
+
+ bad := base()
+ bad.OwnershipMode = "shared"
+ Expect(start(setup(), bad)).NotTo(Succeed())
+ fmt.Println("warning case passed: owner-mode")
+}
diff --git a/core/application/application.go b/core/application/application.go
index 06e137872..cf00e3952 100644
--- a/core/application/application.go
+++ b/core/application/application.go
@@ -703,7 +703,6 @@ func (a *Application) Shutdown() error {
if a.studioService != nil {
_ = a.studioService.Close(context.Background())
}
- // After the studio service: a chat may still search a collection.
if svc := a.collectionsService.Load(); svc != nil {
svc.Close()
}
diff --git a/core/application/execution_binding.go b/core/application/execution_binding.go
new file mode 100644
index 000000000..f5c72c18b
--- /dev/null
+++ b/core/application/execution_binding.go
@@ -0,0 +1,136 @@
+// SPDX-License-Identifier: MIT
+package application
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "slices"
+
+ "github.com/mudler/LocalAI/core/http/auth"
+ "github.com/mudler/LocalAI/core/services/addons/gateway"
+ addontools "github.com/mudler/LocalAI/core/services/addons/tools"
+ "github.com/mudler/LocalAI/core/services/studio"
+ "github.com/mudler/LocalAI/pkg/addons/contract"
+)
+
+// Status is advisory: each admission reads current authorization and selection.
+// It does not promise to detect changes that were restored between reads.
+func (a *Application) wireExecutionBinding(g *gateway.Gateway) error {
+ g.ResolveExecutionBinding = a.resolveExecutionBinding
+ g.AuthorizeExecutionStatus = g.ValidateExecutionBinding
+ return nil
+}
+
+func (a *Application) resolveExecutionBinding(ctx context.Context, actor contract.Actor, claim gateway.ExecutionBindingClaim) (gateway.ExecutionStatusBinding, error) {
+ return a.resolveExecutionBindingWithTools(ctx, actor, claim, a.StudioAddonTools())
+}
+
+func (a *Application) resolveExecutionBindingWithTools(ctx context.Context, actor contract.Actor, claim gateway.ExecutionBindingClaim, broker studio.AddonTools) (gateway.ExecutionStatusBinding, error) {
+ empty := gateway.ExecutionStatusBinding{}
+ if err := auth.AddonRevalidate(a.authDB, a.AddonOwnershipMode())(ctx, actor); err != nil {
+ return empty, err
+ }
+ if a.addonManager == nil {
+ return empty, gateway.ErrUnavailable
+ }
+ if a.studioStore == nil {
+ return empty, gateway.ErrUnavailable
+ }
+ principal := actor.UserID
+ if a.AddonOwnershipMode() == auth.AddonModeLocalOwner {
+ principal = studio.AnonymousPrincipal
+ }
+ cfg := a.applicationConfig.ChatSelectionSnapshot()
+ pins, err := a.studioToolPins(ctx)
+ if err != nil {
+ return empty, err
+ }
+ rec, err := a.studioStore.GetChat(ctx, principal, claim.ConversationID)
+ if err != nil || rec.Principal != principal || rec.ID != claim.ConversationID {
+ return empty, gateway.ErrForbidden
+ }
+ var grants []string
+ if rec.AgentID != "" {
+ if a.studioAgents == nil {
+ return empty, gateway.ErrUnavailable
+ }
+ ag, err := a.studioAgents.Get(ctx, principal, rec.AgentID)
+ if err != nil || ag.Principal != principal {
+ return empty, gateway.ErrForbidden
+ }
+ grants = ag.Addons
+ }
+ // Discover through the actual Studio adapter: only currently running,
+ // actor-visible native replacements are candidates. No readiness read occurs.
+ if broker == nil {
+ return empty, gateway.ErrUnavailable
+ }
+ defs, err := broker.Tools(ctx, rec.Principal)
+ if err != nil {
+ return empty, err
+ }
+ lister, ok := broker.(studio.ShadowLister)
+ if !ok {
+ return empty, gateway.ErrUnavailable
+ }
+ installed, err := lister.AddonShadowers(ctx, rec.Principal)
+ if err != nil {
+ return empty, err
+ }
+ // Workspace-dependent tools cannot be selected without the actual workspace.
+ eligible := defs[:0:0]
+ for _, d := range defs {
+ needsWorkspace := hasWorkspaceOperation(a.applicationConfig.AddonHostOps[d.AddonID])
+ if needsWorkspace && !a.StudioFiles().Available() {
+ continue
+ }
+ eligible = append(eligible, d)
+ }
+ selected, ok := studio.SelectExecutionProvider(eligible, installed, rec, grants, pins["bash"])
+ if !ok {
+ return empty, gateway.ErrForbidden
+ } // Native bash is not an addon binding; no host fallback.
+ instance, err := a.addonManager.Runtime(ctx, selected.AddonID)
+ if err != nil {
+ return empty, gateway.ErrUnavailable
+ }
+ caps, err := a.addonManager.DeclaredCapabilities(ctx, selected.AddonID, instance.Generation)
+ if err != nil {
+ return empty, err
+ }
+ provider := caps.ExecutionProvider
+ addonID, tool, valid := addontools.ParseToolName(selected.Name)
+ if !valid || addonID != selected.AddonID {
+ return empty, gateway.ErrForbidden
+ }
+ if provider == nil || !slices.Contains(provider.Tools, tool) {
+ return empty, gateway.ErrForbidden
+ }
+ executionOK, statusOK := false, false
+ for _, d := range caps.ToolDefinitions {
+ if d.Name == tool && contract.IsExecutionAction(caps, d) {
+ executionOK = true
+ }
+ if d.Name == provider.StatusTool && contract.IsExecutionStatusTool(caps, d) && contract.ExecutionStatusAcceptsEmptyArguments(d) {
+ statusOK = true
+ }
+ }
+ if !executionOK || !statusOK || tool == provider.StatusTool {
+ return empty, gateway.ErrForbidden
+ }
+ // Exact runtime identity is checked again; the gateway re-resolves current
+ // authorization and selection before dispatch and positive publication.
+ after, err := a.addonManager.Runtime(ctx, selected.AddonID)
+ if err != nil || after.Generation != instance.Generation || after.Incarnation != instance.Incarnation {
+ return empty, gateway.ErrUnavailable
+ }
+ if err = auth.AddonRevalidate(a.authDB, a.AddonOwnershipMode())(ctx, actor); err != nil {
+ return empty, err
+ }
+ // Compare current selection inputs even when they resolve to the same provider.
+ selection, _ := json.Marshal([]any{rec.Principal, rec.AgentID, rec.Origin, rec.AddonsOff, grants, cfg.HostExec, pins})
+ digest := sha256.Sum256(selection)
+ return gateway.ExecutionStatusBinding{ConversationID: rec.ID, Actor: actor, AddonID: selected.AddonID, Generation: instance.Generation, Incarnation: instance.Incarnation, ExecutionTool: tool, StatusTool: provider.StatusTool, CurrentSelection: hex.EncodeToString(digest[:])}, nil
+}
diff --git a/core/application/execution_binding_test.go b/core/application/execution_binding_test.go
new file mode 100644
index 000000000..96b84bad6
--- /dev/null
+++ b/core/application/execution_binding_test.go
@@ -0,0 +1,441 @@
+// SPDX-License-Identifier: MIT
+package application
+
+import (
+ "archive/tar"
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/json"
+ "fmt"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "time"
+
+ "github.com/mudler/LocalAI/core/config"
+ "github.com/mudler/LocalAI/core/http/auth"
+ "github.com/mudler/LocalAI/core/services/addons"
+ "github.com/mudler/LocalAI/core/services/addons/catalog"
+ "github.com/mudler/LocalAI/core/services/addons/gateway"
+ "github.com/mudler/LocalAI/core/services/addons/hostsettings"
+ "github.com/mudler/LocalAI/core/services/studio"
+ "github.com/mudler/LocalAI/core/services/studio/agents"
+ "github.com/mudler/LocalAI/core/services/studio/session"
+ "github.com/mudler/LocalAI/core/services/studio/store"
+ "github.com/mudler/LocalAI/pkg/addons/contract"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+type bindingNameBroker struct {
+ studio.AddonTools
+ name string
+}
+
+func (b bindingNameBroker) Tools(ctx context.Context, user string) ([]studio.AddonTool, error) {
+ defs, err := b.AddonTools.Tools(ctx, user)
+ for i := range defs {
+ if defs[i].AddonID == "renamed-runner" {
+ defs[i].Name = b.name
+ }
+ }
+ return defs, err
+}
+
+func (b bindingNameBroker) AddonShadowers(ctx context.Context, user string) ([]studio.AddonShadower, error) {
+ return b.AddonTools.(studio.ShadowLister).AddonShadowers(ctx, user)
+}
+
+var _ = Describe("execution binding owner resolver", func() {
+ var a *Application
+ var ctx context.Context
+ var actor contract.Actor
+ var claim gateway.ExecutionBindingClaim
+ setOwnerPin := func(pin string) {
+ snap, err := a.addonHostSettings.Snapshot(ctx)
+ Expect(err).NotTo(HaveOccurred())
+ var value *string
+ if pin != "" {
+ value = &pin
+ }
+ _, err = a.addonHostSettings.Update(ctx, hostsettings.Patch{ToolProviders: map[string]*string{"bash": value}}, snap.Revision, fmt.Sprintf("pin-%d", snap.Revision))
+ Expect(err).NotTo(HaveOccurred())
+ }
+
+ BeforeEach(func() {
+ requireAddonRuntime()
+ ctx = context.Background()
+ actor = contract.Actor{UserID: auth.AddonLocalOwnerID, CredentialID: auth.AddonLocalOwnerID, Mode: auth.AddonModeLocalOwner}
+ root := realTempDir()
+ DeferCleanup(func() {
+ _ = filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
+ if err == nil && info.IsDir() {
+ return os.Chmod(p, 0700)
+ }
+ return nil
+ })
+ })
+ binary := filepath.Join(root, "fixture")
+ output, err := exec.Command("go", "build", "-o", binary, "../../tests/addons/fixture/cmd").CombinedOutput()
+ Expect(err).NotTo(HaveOccurred(), string(output))
+ executable, err := os.ReadFile(binary)
+ Expect(err).NotTo(HaveOccurred())
+ raw, err := os.ReadFile("../../tests/addons/fixtures/packages/manifest.json")
+ Expect(err).NotTo(HaveOccurred())
+ var manifest map[string]any
+ Expect(json.Unmarshal(raw, &manifest)).To(Succeed())
+ providers := []struct{ id, execute, status string }{{"renamed-runner", "execute", "inspect"}, {"alternate-worker", "launch", "readiness"}}
+ archives := map[string][]byte{}
+ for _, provider := range providers {
+ manifest["id"] = provider.id
+ manifest["capabilities"] = contract.Capabilities{Service: true, Actions: true, Tools: []string{provider.execute, provider.status}, ExecutionProvider: &contract.ExecutionProvider{Tools: []string{provider.execute}, StatusTool: provider.status}, ToolDefinitions: []contract.ToolDefinition{
+ {Name: provider.execute, Kind: contract.ToolKindAction, Shadows: "bash", Description: "Run a command", InputSchema: json.RawMessage(`{"type":"object","properties":{"command":{"type":"string"}},"required":["command"],"additionalProperties":false}`)},
+ {Name: provider.status, Kind: contract.ToolKindRead, UIOnly: true, Description: "Inspect execution", InputSchema: json.RawMessage(`{"type":"object","properties":{},"additionalProperties":false}`)},
+ }}
+ raw, err = json.Marshal(manifest)
+ Expect(err).NotTo(HaveOccurred())
+ var archive bytes.Buffer
+ tw := tar.NewWriter(&archive)
+ for name, data := range map[string][]byte{"manifest.json": raw, "bin/sample": executable, "fixture.json": []byte(`{"mode":"execution-status"}`)} {
+ Expect(tw.WriteHeader(&tar.Header{Name: name, Mode: 0755, Size: int64(len(data))})).To(Succeed())
+ _, err = tw.Write(data)
+ Expect(err).NotTo(HaveOccurred())
+ }
+ Expect(tw.Close()).To(Succeed())
+ archives[provider.id] = append([]byte(nil), archive.Bytes()...)
+ }
+ var url string
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ var releases []catalog.Release
+ for _, provider := range providers {
+ data := archives[provider.id]
+ if r.URL.Path == "/"+provider.id+".tar" {
+ _, _ = w.Write(data)
+ return
+ }
+ releases = append(releases, catalog.Release{ID: provider.id, Name: provider.id, Version: "1.2.3", OS: "linux", Arch: "amd64", Artifact: url + "/" + provider.id + ".tar", Digest: fmt.Sprintf("sha256:%x", sha256.Sum256(data))})
+ }
+ _ = catalog.WriteIndex(w, releases)
+ }))
+ DeferCleanup(server.Close)
+ url = server.URL
+ ownerRoot := filepath.Join(root, "addons")
+ Expect(os.MkdirAll(ownerRoot, 0700)).To(Succeed())
+ admin := func(context.Context, contract.Actor) error { return nil }
+ sources, err := catalog.NewSources(filepath.Join(ownerRoot, "sources.json"), admin, catalog.CredentialResolver{})
+ Expect(err).NotTo(HaveOccurred())
+ Expect(sources.Save(ctx, actor, catalog.Source{ID: "test", Name: "Test", URL: url, Enabled: true, AllowLoopbackHTTP: true})).To(Succeed())
+ runCtx, cancel := context.WithCancel(ctx)
+ DeferCleanup(cancel)
+ a = &Application{applicationConfig: &config.ApplicationConfig{Context: runCtx, Addons: config.AddonConfig{Enabled: true, InstanceID: "owner", OwnerID: "owner", OwnerAddress: "https://owner.example", StorageID: "local", Root: ownerRoot, AdminCheck: admin, OwnershipMode: auth.AddonModeLocalOwner, Host: contract.HostCompatibility{Host: 1, API: 1, Protocol: 1, SDK: 1, DataFormat: 1, OS: "linux", Arch: "amd64"}}}}
+
+ keys := filepath.Join(root, "keys")
+ Expect(gateway.GenerateCarrierKeys(keys, []string{"127.0.0.1"})).To(Succeed())
+ listener, e := net.Listen("tcp", "127.0.0.1:0")
+ Expect(e).NotTo(HaveOccurred())
+ address := listener.Addr().String()
+ Expect(listener.Close()).To(Succeed())
+ transport, e := gateway.LoadCarrierConfig(keys, "owner", "https://"+address)
+ Expect(e).NotTo(HaveOccurred())
+ a.applicationConfig.Addons.InstanceID = transport.InstanceID
+ a.applicationConfig.Addons.OwnerID = transport.OwnerID
+ a.applicationConfig.Addons.OwnerAddress = transport.OwnerAddress
+ a.applicationConfig.Addons.ListenAddress = address
+ a.applicationConfig.Addons.Transport = transport
+ Expect(a.startAddons()).To(Succeed())
+ DeferCleanup(a.Shutdown)
+ for index, provider := range providers {
+ // Install timestamps have second precision; separate them to test age, not ID tie-breaking.
+ if index > 0 {
+ time.Sleep(time.Second)
+ }
+ _, err = a.addonManager.Submit(ctx, actor, addons.LifecycleCommand{OperationID: "install-" + provider.id, AddonID: provider.id, Action: "install", SourceID: "test", Version: "1.2.3"})
+ Expect(err).NotTo(HaveOccurred())
+ _, err = a.addonManager.Submit(ctx, actor, addons.LifecycleCommand{OperationID: "enable-" + provider.id, AddonID: provider.id, Action: "enable", Generation: 1})
+ Expect(err).NotTo(HaveOccurred())
+ }
+ a.studioStore, err = store.NewJSON(filepath.Join(root, "chats"))
+ Expect(err).NotTo(HaveOccurred())
+ a.studioAgents, err = agents.NewJSON(filepath.Join(root, "agents"))
+ Expect(err).NotTo(HaveOccurred())
+ claim = gateway.ExecutionBindingClaim{ConversationID: "conversation"}
+ Expect(a.studioStore.CreateChat(ctx, session.ChatRecord{ID: claim.ConversationID, Principal: studio.AnonymousPrincipal, Created: time.Now(), Updated: time.Now()})).To(Succeed())
+ Expect(a.addonGateway.ResolveExecutionBinding).NotTo(BeNil())
+ Expect(a.addonGateway.AuthorizeExecutionStatus).NotTo(BeNil())
+ })
+ It("rejects noncanonical and mismatched selected tool identities", func() {
+ setOwnerPin("renamed-runner")
+ for _, name := range []string{"execute", "bash", "addon__alternate-worker__execute", "addon__renamed-runner__bad_name", "addon__renamed-runner__execute__extra"} {
+ _, err := a.resolveExecutionBindingWithTools(ctx, actor, claim, bindingNameBroker{AddonTools: a.StudioAddonTools(), name: name})
+ Expect(err).To(MatchError(gateway.ErrForbidden), name)
+ }
+ _, err := a.resolveExecutionBindingWithTools(ctx, actor, claim, bindingNameBroker{AddonTools: a.StudioAddonTools(), name: "addon__renamed-runner__execute"})
+ Expect(err).NotTo(HaveOccurred())
+ })
+
+ It("uses owner CAS pins for execution and typed status and fails closed on policy outage", func() {
+ first, err := a.addonGateway.BindingForActor(ctx, actor, claim)
+ Expect(err).NotTo(HaveOccurred())
+ a.applicationConfig.SetChatsToolProviders(map[string]string{"bash": "renamed-runner"})
+ snap, err := a.addonHostSettings.Snapshot(ctx)
+ Expect(err).NotTo(HaveOccurred())
+ pin := "alternate-worker"
+ _, err = a.addonHostSettings.Update(ctx, hostsettings.Patch{ToolProviders: map[string]*string{"bash": &pin}}, snap.Revision, "pin-alternate")
+ Expect(err).NotTo(HaveOccurred())
+ pins, err := a.studioToolPins(ctx)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(pins["bash"]).To(Equal(pin))
+ second, err := a.addonGateway.BindingForActor(ctx, actor, claim)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(second.AddonID).To(Equal(pins["bash"]))
+ Expect(a.addonGateway.AuthorizeExecutionStatus(ctx, actor, first)).NotTo(Succeed())
+ Expect(a.addonGateway.AuthorizeExecutionStatus(ctx, actor, second)).To(Succeed())
+ _, err = a.addonHostSettings.Update(ctx, hostsettings.Patch{}, snap.Revision, "stale-pin")
+ Expect(err).To(MatchError(hostsettings.ErrConflict))
+ Expect(a.addonGateway.AuthorizeExecutionStatus(ctx, actor, second)).To(Succeed())
+ Expect(a.addonHostSettings.Close()).To(Succeed())
+ _, err = a.studioToolPins(ctx)
+ Expect(err).To(HaveOccurred())
+ _, err = a.addonGateway.BindingForActor(ctx, actor, claim)
+ Expect(err).To(HaveOccurred())
+ Expect(a.addonGateway.AuthorizeExecutionStatus(ctx, actor, second)).NotTo(Succeed())
+ })
+
+ It("resolves local-owner SQLite using current chat and agent reads", func() {
+ db, err := auth.InitDB(filepath.Join(realTempDir(), "identity.db"))
+ Expect(err).NotTo(HaveOccurred())
+ sqlDB, err := db.DB()
+ Expect(err).NotTo(HaveOccurred())
+ DeferCleanup(sqlDB.Close)
+ originalChats, originalAgents := a.studioStore, a.studioAgents
+ DeferCleanup(func() { a.studioStore, a.studioAgents = originalChats, originalAgents })
+ a.authDB = db
+ a.studioStore, err = store.NewGORM(db)
+ Expect(err).NotTo(HaveOccurred())
+ a.studioAgents, err = agents.NewGORM(db)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(a.studioStore.CreateChat(ctx, session.ChatRecord{ID: claim.ConversationID, Principal: studio.AnonymousPrincipal, Created: time.Now(), Updated: time.Now()})).To(Succeed())
+ binding, err := a.addonGateway.BindingForActor(ctx, actor, claim)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(binding.AddonID).To(Equal("renamed-runner"))
+ Expect(a.addonGateway.AuthorizeExecutionStatus(ctx, actor, binding)).To(Succeed())
+ })
+
+ It("resolves named SQLite chats and rechecks current accounts and credentials", func() {
+ db, err := auth.InitDB(filepath.Join(realTempDir(), "named.db"))
+ Expect(err).NotTo(HaveOccurred())
+ sqlDB, err := db.DB()
+ Expect(err).NotTo(HaveOccurred())
+ DeferCleanup(sqlDB.Close)
+ originalChats, originalAgents := a.studioStore, a.studioAgents
+ DeferCleanup(func() { a.studioStore, a.studioAgents = originalChats, originalAgents })
+ a.authDB = db
+ a.applicationConfig.Addons.OwnershipMode = auth.AddonModeNamedUser
+ // Install current named-account validation without restarting the runtime.
+ originalGateway := a.addonGateway
+ DeferCleanup(func() { a.addonGateway = originalGateway })
+ a.addonGateway = &gateway.Gateway{Manager: a.addonManager, Runtime: originalGateway.Runtime, Tools: originalGateway.Tools, HostSettings: originalGateway.HostSettings, Revalidate: auth.AddonRevalidate(db, auth.AddonModeNamedUser)}
+ Expect(a.wireExecutionBinding(a.addonGateway)).To(Succeed())
+ a.studioStore, err = store.NewGORM(db)
+ Expect(err).NotTo(HaveOccurred())
+ a.studioAgents, err = agents.NewGORM(db)
+ Expect(err).NotTo(HaveOccurred())
+ user := auth.User{ID: "runner", Role: auth.RoleUser, Status: auth.StatusActive}
+ Expect(db.Create(&user).Error).To(Succeed())
+ permissions := auth.UserPermission{UserID: user.ID, Permissions: auth.PermissionMap{auth.FeatureAddons: true}}
+ Expect(db.Create(&permissions).Error).To(Succeed())
+ actor = contract.Actor{UserID: user.ID, CredentialID: auth.AddonChatsCredential, Mode: auth.AddonModeNamedUser}
+ Expect(a.studioStore.CreateChat(ctx, session.ChatRecord{ID: claim.ConversationID, Principal: user.ID, Created: time.Now(), Updated: time.Now()})).To(Succeed())
+ binding, err := a.addonGateway.BindingForActor(ctx, actor, claim)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(binding.AddonID).To(Equal("renamed-runner"))
+ Expect(a.addonGateway.AuthorizeExecutionStatus(ctx, actor, binding)).To(Succeed())
+ _, err = a.addonGateway.BindingForActor(ctx, actor, gateway.ExecutionBindingClaim{ConversationID: "foreign"})
+ Expect(err).To(HaveOccurred())
+ Expect(db.Model(&user).Update("status", "disabled").Error).To(Succeed())
+ Expect(a.addonGateway.AuthorizeExecutionStatus(ctx, actor, binding)).NotTo(Succeed())
+ Expect(db.Model(&user).Update("status", auth.StatusActive).Error).To(Succeed())
+ permissions.Permissions = auth.PermissionMap{}
+ Expect(db.Model(&auth.UserPermission{}).Where("user_id = ?", user.ID).Update("permissions", permissions.Permissions).Error).To(Succeed())
+ Expect(a.addonGateway.AuthorizeExecutionStatus(ctx, actor, binding)).NotTo(Succeed())
+ permissions.Permissions = auth.PermissionMap{auth.FeatureAddons: true}
+ Expect(db.Model(&auth.UserPermission{}).Where("user_id = ?", user.ID).Update("permissions", permissions.Permissions).Error).To(Succeed())
+ _, err = a.addonGateway.BindingForActor(ctx, actor, claim)
+ Expect(err).NotTo(HaveOccurred())
+ _, key, err := auth.CreateAPIKey(db, user.ID, "test", auth.RoleUser, "secret", nil)
+ Expect(err).NotTo(HaveOccurred())
+ actor.CredentialID = "key:" + key.ID
+ binding, err = a.addonGateway.BindingForActor(ctx, actor, claim)
+ Expect(err).NotTo(HaveOccurred())
+ expired := time.Now().Add(-time.Hour)
+ Expect(db.Model(key).Update("expires_at", expired).Error).To(Succeed())
+ Expect(a.addonGateway.AuthorizeExecutionStatus(ctx, actor, binding)).NotTo(Succeed())
+ Expect(db.Create(&auth.Session{ID: "browser", UserID: user.ID, ExpiresAt: time.Now().Add(time.Hour)}).Error).To(Succeed())
+ actor.CredentialID = "session:browser"
+ binding, err = a.addonGateway.BindingForActor(ctx, actor, claim)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(db.Delete(&auth.Session{}, "id = ?", "browser").Error).To(Succeed())
+ Expect(a.addonGateway.AuthorizeExecutionStatus(ctx, actor, binding)).NotTo(Succeed())
+ })
+
+ It("selects the oldest real provider then pins a second distinct manifest and runtime", func() {
+ first, err := a.addonGateway.BindingForActor(ctx, actor, claim)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(first.AddonID).To(Equal("renamed-runner"))
+ Expect(first.ExecutionTool).To(Equal("execute"))
+ Expect(first.StatusTool).To(Equal("inspect"))
+ setOwnerPin("alternate-worker")
+ second, err := a.addonGateway.BindingForActor(ctx, actor, claim)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(second.AddonID).To(Equal("alternate-worker"))
+ Expect(second.ExecutionTool).To(Equal("launch"))
+ Expect(second.StatusTool).To(Equal("readiness"))
+ for _, binding := range []gateway.ExecutionStatusBinding{first, second} {
+ runtime, err := a.addonManager.Runtime(ctx, binding.AddonID)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(binding.Generation).To(Equal(runtime.Generation))
+ Expect(binding.Incarnation).To(Equal(runtime.Incarnation))
+ }
+ Expect(a.addonGateway.AuthorizeExecutionStatus(ctx, actor, first)).NotTo(Succeed())
+ Expect(a.addonGateway.AuthorizeExecutionStatus(ctx, actor, second)).To(Succeed())
+ })
+ It("binds the actual renamed running provider and rejects forged bindings and foreign actors", func() {
+ b, err := a.addonGateway.BindingForActor(ctx, actor, claim)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(b.AddonID).To(Equal("renamed-runner"))
+ Expect(b.ExecutionTool).To(Equal("execute"))
+ Expect(b.StatusTool).To(Equal("inspect"))
+ current, err := a.addonManager.Runtime(ctx, b.AddonID)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(b.Generation).To(Equal(current.Generation))
+ Expect(b.Incarnation).To(Equal(current.Incarnation))
+ Expect(a.addonGateway.AuthorizeExecutionStatus(ctx, actor, b)).To(Succeed())
+
+ forged := b
+ forged.StatusTool = "execute"
+ Expect(a.addonGateway.AuthorizeExecutionStatus(ctx, actor, forged)).NotTo(Succeed())
+ foreign := actor
+ foreign.UserID = "other"
+ Expect(a.addonGateway.AuthorizeExecutionStatus(ctx, foreign, b)).NotTo(Succeed())
+ })
+ It("observes the actual renamed provider through the Studio typed adapter and withdraws stale facts", func() {
+ broker := a.StudioAddonTools().(*studioAddons)
+ request, addon, explanation := broker.NewExecutionObservation(ctx, studio.AnonymousPrincipal, claim.ConversationID)
+ Expect(request).NotTo(BeNil())
+ defer request.Close()
+ Expect(addon).To(Equal("renamed-runner"))
+ value := request.Observe(ctx)
+ Expect(string(value.Readiness)).To(Equal("available"))
+ Expect(string(value.Isolation)).To(Equal("isolated"))
+ Expect(explanation()).To(BeEmpty())
+ Expect(request.Observe(ctx)).To(Equal(value))
+ Expect(a.studioStore.SetAddonsOff(ctx, studio.AnonymousPrincipal, claim.ConversationID, []string{"renamed-runner", "alternate-worker"})).To(Succeed())
+ Expect(string(request.Observe(ctx).Isolation)).To(Equal("unknown"))
+ Expect(broker.Isolation(ctx, studio.AnonymousPrincipal, "renamed-runner")).To(BeEmpty())
+ Expect(a.studioStore.SetAddonsOff(ctx, studio.AnonymousPrincipal, claim.ConversationID, nil)).To(Succeed())
+ fresh, selected, _ := broker.NewExecutionObservation(ctx, studio.AnonymousPrincipal, claim.ConversationID)
+ Expect(fresh).NotTo(BeNil())
+ defer fresh.Close()
+ Expect(selected).To(Equal("renamed-runner"))
+ Expect(string(fresh.Observe(ctx).Readiness)).To(Equal("available"))
+ })
+ It("validates current pins and chat switches without historical ABA guarantees", func() {
+ b, err := a.addonGateway.BindingForActor(ctx, actor, claim)
+ Expect(err).NotTo(HaveOccurred())
+ setOwnerPin("alternate-worker")
+ Expect(a.addonGateway.AuthorizeExecutionStatus(ctx, actor, b)).NotTo(Succeed())
+ setOwnerPin("")
+ b, err = a.addonGateway.BindingForActor(ctx, actor, claim)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(a.studioStore.SetAddonsOff(ctx, studio.AnonymousPrincipal, claim.ConversationID, []string{"renamed-runner", "alternate-worker"})).To(Succeed())
+ _, err = a.addonGateway.BindingForActor(ctx, actor, claim)
+ Expect(err).To(HaveOccurred())
+ Expect(a.studioStore.SetAddonsOff(ctx, studio.AnonymousPrincipal, claim.ConversationID, nil)).To(Succeed())
+ Expect(a.addonGateway.AuthorizeExecutionStatus(ctx, actor, b)).To(Succeed())
+ })
+ It("denies foreign chat access and invalid named credentials", func() {
+ Expect(a.studioStore.CreateChat(ctx, session.ChatRecord{ID: "foreign", Principal: "someone", Created: time.Now(), Updated: time.Now()})).To(Succeed())
+ _, err := a.addonGateway.BindingForActor(ctx, actor, gateway.ExecutionBindingClaim{ConversationID: "foreign"})
+ Expect(err).To(HaveOccurred())
+ a.applicationConfig.Addons.OwnershipMode = auth.AddonModeNamedUser
+ _, err = a.addonGateway.ResolveExecutionBinding(ctx, actor, claim)
+ Expect(err).To(HaveOccurred())
+ })
+ It("uses current agent grants and denies unattended actions", func() {
+ ag := agents.Agent{ID: "agent", Principal: studio.AnonymousPrincipal, Name: "Runner", Addons: []string{"renamed-runner"}}
+ Expect(a.studioAgents.Put(ctx, ag)).To(Succeed())
+ Expect(a.studioStore.CreateChat(ctx, session.ChatRecord{ID: "agent-chat", Principal: studio.AnonymousPrincipal, AgentID: ag.ID, Origin: "trigger", Created: time.Now(), Updated: time.Now()})).To(Succeed())
+ c := gateway.ExecutionBindingClaim{ConversationID: "agent-chat"}
+ b, err := a.addonGateway.BindingForActor(ctx, actor, c)
+ Expect(err).NotTo(HaveOccurred())
+ ag.Addons = nil
+ Expect(a.studioAgents.Put(ctx, ag)).To(Succeed())
+ _, err = a.addonGateway.BindingForActor(ctx, actor, c)
+ Expect(err).To(HaveOccurred())
+ ag.Addons = []string{"renamed-runner"}
+ Expect(a.studioAgents.Put(ctx, ag)).To(Succeed())
+ Expect(a.addonGateway.AuthorizeExecutionStatus(ctx, actor, b)).To(Succeed())
+ Expect(a.studioStore.CreateChat(ctx, session.ChatRecord{ID: "unattended", Principal: studio.AnonymousPrincipal, Origin: "trigger", Created: time.Now(), Updated: time.Now()})).To(Succeed())
+ _, err = a.addonGateway.BindingForActor(ctx, actor, gateway.ExecutionBindingClaim{ConversationID: "unattended"})
+ Expect(err).To(HaveOccurred())
+ Expect(a.studioAgents.Put(ctx, agents.Agent{ID: "foreign-agent", Principal: "foreign", Name: "Foreign", Addons: []string{"renamed-runner"}})).To(Succeed())
+ Expect(a.studioStore.CreateChat(ctx, session.ChatRecord{ID: "foreign-agent-chat", Principal: studio.AnonymousPrincipal, AgentID: "foreign-agent", Created: time.Now(), Updated: time.Now()})).To(Succeed())
+ _, err = a.addonGateway.BindingForActor(ctx, actor, gateway.ExecutionBindingClaim{ConversationID: "foreign-agent-chat"})
+ Expect(err).To(HaveOccurred())
+ })
+ It("rejects stale runtime incarnations without owner callback nonces", func() {
+ b, err := a.addonGateway.BindingForActor(ctx, actor, claim)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(a.wireExecutionBinding(a.addonGateway)).To(Succeed())
+ Expect(a.addonGateway.AuthorizeExecutionStatus(ctx, actor, b)).To(Succeed())
+ b, err = a.addonGateway.BindingForActor(ctx, actor, claim)
+ Expect(err).NotTo(HaveOccurred())
+ _, err = a.addonManager.Submit(ctx, actor, addons.LifecycleCommand{OperationID: "disable", AddonID: b.AddonID, Action: "disable", Generation: b.Generation})
+ Expect(err).NotTo(HaveOccurred())
+ Expect(a.addonGateway.AuthorizeExecutionStatus(ctx, actor, b)).NotTo(Succeed())
+ _, err = a.addonManager.Submit(ctx, actor, addons.LifecycleCommand{OperationID: "enable-again", AddonID: b.AddonID, Action: "enable", Generation: b.Generation})
+ Expect(err).NotTo(HaveOccurred())
+ now, err := a.addonGateway.BindingForActor(ctx, actor, claim)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(now.Incarnation).NotTo(Equal(b.Incarnation))
+ Expect(a.addonGateway.AuthorizeExecutionStatus(ctx, actor, b)).NotTo(Succeed())
+ })
+ It("uses the ordinary chat store interface", func() {
+ original := a.studioStore
+ a.studioStore = ordinaryBindingStore{original}
+ _, err := a.addonGateway.BindingForActor(ctx, actor, claim)
+ Expect(err).NotTo(HaveOccurred())
+ rec, err := a.studioStore.GetChat(ctx, studio.AnonymousPrincipal, claim.ConversationID)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(rec.ID).To(Equal(claim.ConversationID))
+ a.studioStore = original
+ })
+
+})
+
+// The resolver needs only the ordinary current-read store interface.
+type ordinaryBindingStore struct{ store.Store }
+
+var _ = Describe("workspace operation declarations", func() {
+ DescribeTable("retains the workspace dot prefix semantics", func(operations []string, want bool) {
+ Expect(hasWorkspaceOperation(operations)).To(Equal(want))
+ adapter := &studioAddons{hostOps: map[string][]string{"sample": operations}}
+ Expect(adapter.needsWorkspace("sample")).To(Equal(want))
+ Expect(adapter.needsWorkspace("other")).To(BeFalse())
+ },
+ Entry("nil", []string(nil), false),
+ Entry("empty", []string{}, false),
+ Entry("materialize", []string{"workspace.materialize"}, true),
+ Entry("read", []string{"workspace.read"}, true),
+ Entry("prefix only", []string{"workspace."}, true),
+ Entry("future suffix", []string{"workspace.future.operation"}, true),
+ Entry("mixed grants", []string{"egress.check", "workspace.materialize"}, true),
+ Entry("unrelated operations", []string{"egress.check", "settings.read"}, false),
+ Entry("bare workspace", []string{"workspace"}, false),
+ Entry("similar prefix", []string{"workspaces.read", "workspace-other.read", "Workspace.read", "other.workspace.read"}, false),
+ )
+})
diff --git a/core/application/startup_auth_test.go b/core/application/startup_auth_test.go
index 1c6bb8ee1..0889857e5 100644
--- a/core/application/startup_auth_test.go
+++ b/core/application/startup_auth_test.go
@@ -5,8 +5,12 @@ package application
import (
"context"
+ "fmt"
"os"
+ "os/exec"
"path/filepath"
+ "testing"
+ "time"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/pkg/system"
@@ -14,9 +18,35 @@ import (
. "github.com/onsi/gomega"
)
-var _ = Describe("auth database location at first start", func() {
- It("creates a missing data directory before opening the default auth database", func() {
- root := GinkgoT().TempDir()
+const startupAuthCaseEnv = "LOCALAI_STARTUP_AUTH_TEST_CASE"
+const startupAuthRootEnv = "LOCALAI_STARTUP_AUTH_TEST_ROOT"
+
+// Shutdown does not join every background producer. Keep the fixture in the
+// parent: child exit, not context cancellation, is the barrier before TempDir
+// cleanup. Running only this helper also avoids starting the Ginkgo suite again.
+func runStartupAuthCase(id string) {
+ executable, err := os.Executable()
+ Expect(err).NotTo(HaveOccurred())
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+ defer cancel()
+ cmd := exec.CommandContext(ctx, executable, "-test.run=^TestStartupAuthProcess$", "-test.count=1", "-test.v")
+ cmd.Env = append(os.Environ(), startupAuthCaseEnv+"="+id, startupAuthRootEnv+"="+GinkgoT().TempDir())
+ output := &warningOutput{}
+ cmd.Stdout, cmd.Stderr = output, output
+ err = cmd.Run()
+ Expect(err).NotTo(HaveOccurred(), "startup auth case %s (%v):\n%s", id, ctx.Err(), output.String())
+ Expect(output.String()).To(ContainSubstring("startup auth case passed: "+id), "child must execute its assertions")
+}
+
+func TestStartupAuthProcess(t *testing.T) {
+ id := os.Getenv(startupAuthCaseEnv)
+ if id == "" {
+ return
+ }
+ RegisterTestingT(t)
+ switch id {
+ case "default":
+ root := os.Getenv(startupAuthRootEnv)
models, backends := filepath.Join(root, "models"), filepath.Join(root, "backends")
Expect(os.Mkdir(models, 0750)).To(Succeed())
Expect(os.Mkdir(backends, 0750)).To(Succeed())
@@ -29,16 +59,15 @@ var _ = Describe("auth database location at first start", func() {
config.WithAuthEnabled(true), config.WithAuthDatabaseURL(filepath.Join(data, "database.db")),
config.WithAuthAPIKeyHMACSecret("secret"))
Expect(err).NotTo(HaveOccurred())
- DeferCleanup(func() { cancel(); _ = app.Shutdown() })
+ defer func() { cancel(); Expect(app.Shutdown()).To(Succeed()) }()
Expect(app.AuthDB()).NotTo(BeNil())
info, err := os.Stat(data)
Expect(err).NotTo(HaveOccurred())
Expect(info.Mode().Perm()&0o027).To(BeZero(), "directory keeps the 0750 semantics")
_, err = os.Stat(filepath.Join(data, "database.db"))
Expect(err).NotTo(HaveOccurred())
- })
- It("creates the parent of an explicit sqlite path outside the data directory", func() {
- root := GinkgoT().TempDir()
+ case "explicit":
+ root := os.Getenv(startupAuthRootEnv)
models, backends := filepath.Join(root, "models"), filepath.Join(root, "backends")
Expect(os.Mkdir(models, 0750)).To(Succeed())
Expect(os.Mkdir(backends, 0750)).To(Succeed())
@@ -50,9 +79,21 @@ var _ = Describe("auth database location at first start", func() {
app, err = New(config.WithContext(ctx), config.WithSystemState(state), config.WithDataPath(filepath.Join(root, "data")),
config.WithAuthEnabled(true), config.WithAuthDatabaseURL(db), config.WithAuthAPIKeyHMACSecret("secret"))
Expect(err).NotTo(HaveOccurred())
- DeferCleanup(func() { cancel(); _ = app.Shutdown() })
+ defer func() { cancel(); Expect(app.Shutdown()).To(Succeed()) }()
Expect(app.AuthDB()).NotTo(BeNil())
_, err = os.Stat(filepath.Join(root, "elsewhere", "auth", "users.db"))
Expect(err).NotTo(HaveOccurred())
+ default:
+ Expect(id).To(BeEmpty(), "unknown startup auth case")
+ }
+ fmt.Println("startup auth case passed: " + id)
+}
+
+var _ = Describe("auth database location at first start", func() {
+ It("creates a missing data directory before opening the default auth database", func() {
+ runStartupAuthCase("default")
+ })
+ It("creates the parent of an explicit sqlite path outside the data directory", func() {
+ runStartupAuthCase("explicit")
})
})
diff --git a/core/application/studio_execution.go b/core/application/studio_execution.go
new file mode 100644
index 000000000..3b5243f98
--- /dev/null
+++ b/core/application/studio_execution.go
@@ -0,0 +1,81 @@
+// SPDX-License-Identifier: MIT
+package application
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "strconv"
+ "sync"
+ "time"
+
+ "github.com/mudler/LocalAI/core/services/addons/gateway"
+ "github.com/mudler/LocalAI/core/services/studio"
+ "github.com/mudler/LocalAI/core/services/studio/executionrequest"
+ "github.com/mudler/LocalAI/pkg/addons/contract"
+ model "github.com/mudler/LocalAI/pkg/addons/model/executionstatus"
+)
+
+func (l lateStudioAddons) NewExecutionObservation(ctx context.Context, user, conversation string) (*executionrequest.Request, string, func() string) {
+ if p, ok := l.a.StudioAddonTools().(studio.ExecutionObserver); ok {
+ return p.NewExecutionObservation(ctx, user, conversation)
+ }
+ return nil, "", nil
+}
+
+func (s *studioAddons) NewExecutionObservation(ctx context.Context, user, conversation string) (*executionrequest.Request, string, func() string) {
+ actor, err := s.actor(user)
+ if err != nil {
+ return nil, "", nil
+ }
+ b, err := s.adapter.BindingForActor(ctx, actor, gateway.ExecutionBindingClaim{ConversationID: conversation})
+ if err != nil {
+ return nil, "", nil
+ }
+ var id [24]byte
+ if _, err = rand.Read(id[:]); err != nil {
+ return nil, "", nil
+ }
+ q := model.ObservationID(hex.EncodeToString(id[:]))
+ // Keep actor and conversation identity explicit. IO retains the original DTO.
+ mb := model.Binding{Actor: actor.UserID, Credential: actor.CredentialID, Conversation: b.ConversationID, Mode: actor.Mode, Addon: b.AddonID, Generation: strconv.FormatUint(b.Generation, 10), Incarnation: b.Incarnation, ExecutionTool: b.ExecutionTool, StatusTool: b.StatusTool}
+ var mu sync.Mutex
+ explanation := ""
+ io := executionrequest.IO{
+ Validate: func(ctx context.Context, current model.Binding) error {
+ if current != mb {
+ return gateway.ErrForbidden
+ }
+ return s.adapter.ValidateExecutionBinding(ctx, actor, b)
+ },
+ Read: func(ctx context.Context, current model.Binding, observation model.ObservationID, deadline time.Time) (model.Payload, error) {
+ if current != mb || observation != q {
+ return model.InvalidPayload(), gateway.ErrForbidden
+ }
+ ctx, cancel := context.WithDeadline(ctx, deadline)
+ defer cancel()
+ reply, err := s.adapter.ExecutionStatusRead(ctx, actor, gateway.ExecutionStatusRequest{Version: 1, Binding: b, ObservationID: string(q)})
+ if err != nil {
+ return model.InvalidPayload(), err
+ }
+ if reply.Version != 1 || reply.Binding != b || reply.ObservationID != string(q) {
+ return model.InvalidPayload(), gateway.ErrForbidden
+ }
+ raw, err := json.Marshal(reply.Status)
+ if err != nil {
+ return model.InvalidPayload(), err
+ }
+ status, err := contract.DecodeExecutionStatus(raw)
+ if err != nil {
+ return model.InvalidPayload(), err
+ }
+ mu.Lock()
+ explanation = status.Explanation
+ mu.Unlock()
+ return model.ValidPayload(model.Value{Readiness: model.Readiness(status.Readiness), Isolation: model.Isolation(status.Isolation)}), nil
+ },
+ }
+ request := executionrequest.New(ctx, mb, q, model.Eligible, io, nil)
+ return request, b.AddonID, func() string { mu.Lock(); defer mu.Unlock(); return explanation }
+}
diff --git a/core/application/studio_service.go b/core/application/studio_service.go
index 4db79186c..ff41700b5 100644
--- a/core/application/studio_service.go
+++ b/core/application/studio_service.go
@@ -410,7 +410,7 @@ func (a *Application) studioRemoteModel(model string) bool {
// studioHostExecMode is chats_host_exec as it applies now. Empty is the
// default: off on a server with accounts (several people), ask without.
func (a *Application) studioHostExecMode() string {
- if m := a.applicationConfig.ChatsHostExec; m != "" {
+ if m := a.applicationConfig.ChatSelectionSnapshot().HostExec; m != "" {
return m
}
if a.authDB != nil {
diff --git a/core/application/studio_store_test.go b/core/application/studio_store_test.go
index 6dfdbec64..d14ba0d35 100644
--- a/core/application/studio_store_test.go
+++ b/core/application/studio_store_test.go
@@ -39,15 +39,15 @@ var _ = Describe("studio store at startup", func() {
Expect(app.studioStoreKind).To(Equal("gorm"))
})
- It("uses JSON files under the data path without a database, and keeps them across a restart", func() {
+ It("uses JSON files under the data path", func() {
dir := GinkgoT().TempDir()
first := newApp(config.WithDataPath(dir))
Expect(first.studioStoreKind).To(Equal("json"))
Expect(first.StudioStore().CreateChat(context.Background(),
session.ChatRecord{ID: "kept", Principal: "", Title: "Kept"})).To(Succeed())
- second := newApp(config.WithDataPath(dir))
- rec, err := second.StudioStore().GetChat(context.Background(), "", "kept")
+ Expect(first.Shutdown()).To(Succeed())
+ rec, err := first.StudioStore().GetChat(context.Background(), "", "kept")
Expect(err).NotTo(HaveOccurred())
Expect(rec.Title).To(Equal("Kept"))
})
diff --git a/core/cli/run.go b/core/cli/run.go
index 9221356d4..865aad5af 100644
--- a/core/cli/run.go
+++ b/core/cli/run.go
@@ -33,7 +33,8 @@ import (
// and document the deprecation in the help text.
type RunCMD struct {
- AddonHostOps string `name:"addon-host-ops" env:"LOCALAI_ADDON_HOST_OPS" default:"fine-tuning=finetune.,quantization=quantize.,sandbox=workspace.,sandbox=egress.,office=workspace.,web=workspace.,telegram=connector." help:"Add-ons that may call host operations, as a comma list of addon-id=operation-prefix. An add-on that is not listed gets no host operation channel. Empty gives none" group:"addons"`
+ AddonHostPolicy string `name:"addon-host-policy" env:"LOCALAI_ADDON_HOST_POLICY" help:"Absolute path to administrator host operation policy JSON, read once at startup. Empty disables environment policy" group:"addons"`
+ AddonHostOps string `name:"addon-host-ops" env:"LOCALAI_ADDON_HOST_OPS" help:"Add-ons that may call host operations, as a comma list of addon-id=operation-prefix. An add-on that is not listed gets no host operation channel. Empty gives none" group:"addons"`
AddonsEnabled bool `name:"addons-enabled" env:"LOCALAI_ADDONS_ENABLED" default:"true" help:"Run the add-on owner, which installs and supervises add-ons. Turn it off with --addons-enabled=false" group:"addons"`
AddonsOwnershipMode string `name:"addons-ownership-mode" env:"LOCALAI_ADDONS_OWNERSHIP_MODE" help:"Who may use add-ons: named-user (each account has its own access; needs authentication) or local-owner (every caller shares one owner). Default: local-owner when authentication is off, named-user when it is on" group:"addons"`
AddonsRole string `name:"addons-role" env:"LOCALAI_ADDONS_ROLE" default:"combined" help:"How this process takes part in the add-on owner: combined (this node is its own owner, the default), owner (only the owner, serves the carrier on the network) or frontend (no owner here; every add-on request goes to the owner). owner and frontend need --addons-owner-address and --addons-carrier-dir" group:"addons"`
@@ -52,6 +53,9 @@ type RunCMD struct {
PprofBlockProfileRate int `name:"pprof-block-profile-rate" env:"LOCALAI_PPROF_BLOCK_PROFILE_RATE" default:"0" help:"Block sampling rate in nanoseconds (0 disables sampling); requires pprof" group:"diagnostics"`
RequestPhaseTiming bool `name:"request-phase-timing" env:"LOCALAI_REQUEST_PHASE_TIMING" default:"false" help:"Log sanitized request and config-reload phase timings at Info" group:"diagnostics"`
+ addonHostOpsFlagPresent bool
+ addonHostPolicyFlagPresent bool
+
ModelArgs []string `arg:"" optional:"" name:"models" help:"Model configuration URLs to load"`
Color string `env:"COLOR" hidden:""`
NoColor string `env:"NO_COLOR" hidden:""`
@@ -308,6 +312,12 @@ func (r *RunCMD) Run(ctx *cliContext.Context) (result error) {
return nil
}
+ hostOps, err := r.resolveAddonHostOps()
+ if err != nil {
+ return err
+ }
+ logAddonHostOpsResolution(hostOps)
+
diagnosticOpts := r.diagnosticsOptions()
if err := diagnosticOpts.Validate(); err != nil {
return err
@@ -360,13 +370,8 @@ func (r *RunCMD) Run(ctx *cliContext.Context) (result error) {
if !r.PreloadBackendOnly {
defer cancelRun(nil)
}
- hostOps, err := config.ParseAddonHostOps(r.AddonHostOps)
- if err != nil {
- cancelRun(nil)
- return fmt.Errorf("--addon-host-ops: %w", err)
- }
opts := []config.AppOption{
- config.WithAddonHostOps(hostOps),
+ config.WithAddonHostOps(hostOps.Grants),
config.WithProxyAPIKeyEnvLookup(os.Getenv),
config.WithContext(runCtx),
config.WithArtifactDownloadConcurrency(r.ArtifactDownloadConcurrency),
diff --git a/core/cli/run_addon_host_ops_test.go b/core/cli/run_addon_host_ops_test.go
index bab4bffe6..278ce73ba 100644
--- a/core/cli/run_addon_host_ops_test.go
+++ b/core/cli/run_addon_host_ops_test.go
@@ -2,18 +2,127 @@
package cli
import (
+ "os"
+ "path/filepath"
"reflect"
+ "github.com/alecthomas/kong"
"github.com/mudler/LocalAI/core/config"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("the add-on host operations flag", func() {
- It("defaults to the documented setting", func() {
+ It("has no compiled grant default", func() {
f, ok := reflect.TypeOf(RunCMD{}).FieldByName("AddonHostOps")
Expect(ok).To(BeTrue(), "the flag is missing")
- Expect(f.Tag.Get("default")).To(Equal(config.DefaultAddonHostOps))
+ Expect(f.Tag.Get("default")).To(BeEmpty())
Expect(f.Tag.Get("env")).To(Equal("LOCALAI_ADDON_HOST_OPS"))
})
})
+
+var _ = Describe("addon host policy CLI precedence", Serial, func() {
+ parse := func(args ...string) *RunCMD {
+ command := struct {
+ Run RunCMD `cmd:"" default:"withargs"`
+ }{}
+ p, err := kong.New(&command, kong.Vars{
+ "basepath": kong.ExpandPath("."), "generatedcontentpath": DefaultGeneratedContentPath(), "uploadpath": DefaultUploadPath(),
+ "galleries": config.DefaultGalleriesJSON, "backends": config.DefaultBackendGalleriesJSON,
+ })
+ Expect(err).NotTo(HaveOccurred())
+ _, err = p.Parse(args)
+ Expect(err).NotTo(HaveOccurred())
+ return &command.Run
+ }
+ BeforeEach(func() {
+ for _, key := range []string{"LOCALAI_ADDON_HOST_OPS", "LOCALAI_ADDON_HOST_POLICY"} {
+ old, ok := os.LookupEnv(key)
+ DeferCleanup(func() {
+ if ok {
+ Expect(os.Setenv(key, old)).To(Succeed())
+ } else {
+ Expect(os.Unsetenv(key)).To(Succeed())
+ }
+ })
+ Expect(os.Unsetenv(key)).To(Succeed())
+ }
+ })
+ It("selects an explicitly configured policy", func() {
+ p := filepath.Join(GinkgoT().TempDir(), "policy.json")
+ Expect(os.WriteFile(p, []byte(`{"schemaVersion":1,"grants":{"custom":["workspace."]}}`), 0600)).To(Succeed())
+ GinkgoT().Setenv("LOCALAI_ADDON_HOST_POLICY", p)
+ got, err := parse().resolveAddonHostOps()
+ Expect(err).NotTo(HaveOccurred())
+ Expect(got.Source).To(Equal("policy:" + p))
+ Expect(got.Grants).To(Equal(map[string][]string{"custom": {"workspace."}}))
+ })
+ It("uses flag presence even when empty and ignores an invalid policy", func() {
+ GinkgoT().Setenv("LOCALAI_ADDON_HOST_OPS", "env=x.")
+ GinkgoT().Setenv("LOCALAI_ADDON_HOST_POLICY", "/missing/policy")
+ got, err := parse("--addon-host-ops=").resolveAddonHostOps()
+ Expect(err).NotTo(HaveOccurred())
+ Expect(got.Grants).To(BeEmpty())
+ Expect(got.Source).To(Equal("flag"))
+ Expect(got.PolicyIgnored).To(BeTrue())
+ })
+ It("uses explicit empty environment instead of policy", func() {
+ GinkgoT().Setenv("LOCALAI_ADDON_HOST_OPS", "")
+ GinkgoT().Setenv("LOCALAI_ADDON_HOST_POLICY", "/missing/policy")
+ got, err := parse().resolveAddonHostOps()
+ Expect(err).NotTo(HaveOccurred())
+ Expect(got.Grants).To(BeEmpty())
+ Expect(got.Source).To(Equal("environment"))
+ Expect(got.PolicyIgnored).To(BeTrue())
+ })
+ It("does not union a nonempty flag with environment or policy", func() {
+ GinkgoT().Setenv("LOCALAI_ADDON_HOST_OPS", "env=x.")
+ GinkgoT().Setenv("LOCALAI_ADDON_HOST_POLICY", "/missing/policy")
+ got, err := parse("--addon-host-ops=flag=y.").resolveAddonHostOps()
+ Expect(err).NotTo(HaveOccurred())
+ Expect(got.Grants).To(Equal(map[string][]string{"flag": {"y."}}))
+ })
+ It("lets an empty policy flag disable environment policy without restoring defaults", func() {
+ GinkgoT().Setenv("LOCALAI_ADDON_HOST_POLICY", "/missing/policy")
+ got, err := parse("--addon-host-policy=").resolveAddonHostOps()
+ Expect(err).NotTo(HaveOccurred())
+ Expect(got.Grants).To(BeEmpty())
+ Expect(got.Source).To(Equal("empty"))
+ })
+ It("lets a policy flag replace environment policy", func() {
+ p := filepath.Join(GinkgoT().TempDir(), "policy.json")
+ Expect(os.WriteFile(p, []byte(`{"schemaVersion":1,"grants":{}}`), 0600)).To(Succeed())
+ GinkgoT().Setenv("LOCALAI_ADDON_HOST_POLICY", "/missing/policy")
+ got, err := parse("--addon-host-policy", p).resolveAddonHostOps()
+ Expect(err).NotTo(HaveOccurred())
+ Expect(got.Source).To(Equal("policy:" + p))
+ Expect(got.Grants).To(BeEmpty())
+ })
+ It("fails selected invalid policy rather than falling back", func() {
+ GinkgoT().Setenv("LOCALAI_ADDON_HOST_POLICY", "/missing/policy")
+ _, err := parse().resolveAddonHostOps()
+ Expect(err).To(HaveOccurred())
+ })
+ It("fails startup before creating directories for a selected invalid policy", func() {
+ GinkgoT().Setenv("LOCALAI_ADDON_HOST_POLICY", "/missing/policy")
+ r := parse()
+ r.ModelsPath = filepath.Join(GinkgoT().TempDir(), "not-created")
+ Expect(r.Run(nil)).To(MatchError(ContainSubstring("addon host operations (policy)")))
+ _, err := os.Stat(r.ModelsPath)
+ Expect(os.IsNotExist(err)).To(BeTrue())
+ })
+ It("keeps a resolved policy snapshot when the file changes", func() {
+ p := filepath.Join(GinkgoT().TempDir(), "policy.json")
+ Expect(os.WriteFile(p, []byte(`{"schemaVersion":1,"grants":{"a":["workspace."]}}`), 0600)).To(Succeed())
+ got, err := parse("--addon-host-policy=" + p).resolveAddonHostOps()
+ Expect(err).NotTo(HaveOccurred())
+ Expect(os.WriteFile(p, []byte(`{"schemaVersion":1,"grants":{}}`), 0600)).To(Succeed())
+ Expect(got.Grants).To(Equal(map[string][]string{"a": {"workspace."}}))
+ })
+ It("grants nothing when all selectors are absent", func() {
+ got, err := parse().resolveAddonHostOps()
+ Expect(err).NotTo(HaveOccurred())
+ Expect(got.Grants).To(BeEmpty())
+ Expect(got.Source).To(Equal("empty"))
+ })
+})
diff --git a/core/cli/run_addon_host_policy.go b/core/cli/run_addon_host_policy.go
new file mode 100644
index 000000000..8ea389d6b
--- /dev/null
+++ b/core/cli/run_addon_host_policy.go
@@ -0,0 +1,64 @@
+// SPDX-License-Identifier: MIT
+package cli
+
+import (
+ "os"
+
+ "github.com/alecthomas/kong"
+ "github.com/mudler/LocalAI/core/config"
+ "github.com/mudler/xlog"
+)
+
+// AfterApply records actual command-line presence, not default or environment
+// values. Kong's Path trace distinguishes an explicit empty flag from omission.
+func (r *RunCMD) AfterApply(ctx *kong.Context) error {
+ r.addonHostOpsFlagPresent = false
+ r.addonHostPolicyFlagPresent = false
+ for _, path := range ctx.Path {
+ if path.Flag == nil || path.Resolved {
+ continue
+ }
+ switch path.Flag.Name {
+ case "addon-host-ops":
+ r.addonHostOpsFlagPresent = true
+ case "addon-host-policy":
+ r.addonHostPolicyFlagPresent = true
+ }
+ }
+ return nil
+}
+
+func (r *RunCMD) resolveAddonHostOps() (config.AddonHostOpsResolution, error) {
+ var flag, environment, policy *string
+ if r.addonHostOpsFlagPresent {
+ flag = &r.AddonHostOps
+ }
+ if value, present := os.LookupEnv("LOCALAI_ADDON_HOST_OPS"); present {
+ environment = &value
+ }
+ if r.addonHostPolicyFlagPresent {
+ policy = &r.AddonHostPolicy
+ } else if value, present := os.LookupEnv("LOCALAI_ADDON_HOST_POLICY"); present {
+ policy = &value
+ }
+ resolved, err := config.ResolveAddonHostOps(flag, environment, policy)
+ if err == nil && resolved.Source == "policy" {
+ resolved.Source = "policy:" + *policy
+ }
+ return resolved, err
+}
+
+func logAddonHostOpsResolution(resolved config.AddonHostOpsResolution) {
+ if resolved.Source == "empty" {
+ xlog.Warn("Addons receive no host operations: select --addon-host-policy or explicitly configure --addon-host-ops")
+ }
+ prefixes := 0
+ for _, grants := range resolved.Grants {
+ prefixes += len(grants)
+ }
+ xlog.Info("Addon host operation policy resolved", "source", resolved.Source, "addons", len(resolved.Grants), "prefixes", prefixes)
+ if resolved.PolicyIgnored {
+ xlog.Warn("Addon host policy ignored: explicit host operations override takes precedence")
+ }
+ xlog.Info("Addon manifests and libraries cannot grant host operations; policy changes require restart")
+}
diff --git a/core/config/addon_host_ops.go b/core/config/addon_host_ops.go
index 99fd8424d..239587131 100644
--- a/core/config/addon_host_ops.go
+++ b/core/config/addon_host_ops.go
@@ -7,11 +7,6 @@ import (
"strings"
)
-// DefaultAddonHostOps names the add-ons that may call host operations, and
-// the operation prefixes each may call. It is a host setting: neither a
-// manifest field nor a model argument can add to it.
-const DefaultAddonHostOps = "fine-tuning=finetune.,quantization=quantize.,sandbox=workspace.,sandbox=egress.,office=workspace.,web=workspace.,telegram=connector."
-
var (
addonIDForHostOps = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
hostOpPrefix = regexp.MustCompile(`^[a-z]+\.([a-z]+\.)*$`)
@@ -29,7 +24,7 @@ func ParseAddonHostOps(s string) (map[string][]string, error) {
id, prefix, ok := strings.Cut(item, "=")
id, prefix = strings.TrimSpace(id), strings.TrimSpace(prefix)
if !ok || !addonIDForHostOps.MatchString(id) || !hostOpPrefix.MatchString(prefix) {
- return nil, fmt.Errorf("invalid host operation entry %q: want addon-id=op-prefix, such as fine-tuning=finetune.", item)
+ return nil, fmt.Errorf("invalid host operation entry %q: want addon-id=op-prefix, such as custom-addon=workspace.", item)
}
out[id] = append(out[id], prefix)
}
@@ -37,8 +32,9 @@ func ParseAddonHostOps(s string) (map[string][]string, error) {
}
// WithAddonHostOps sets which add-ons may call which host operations.
-// Without this option no add-on gets a host operation endpoint; the run
-// command passes the default setting.
+// Nil and empty maps grant nothing. The run command resolves explicit host
+// policy before passing its startup snapshot here.
func WithAddonHostOps(ops map[string][]string) AppOption {
- return func(o *ApplicationConfig) { o.AddonHostOps = ops }
+ snapshot := CloneAddonHostOps(ops)
+ return func(o *ApplicationConfig) { o.AddonHostOps = CloneAddonHostOps(snapshot) }
}
diff --git a/core/config/addon_host_ops_test.go b/core/config/addon_host_ops_test.go
index 20ae1f7de..162cbd40e 100644
--- a/core/config/addon_host_ops_test.go
+++ b/core/config/addon_host_ops_test.go
@@ -7,8 +7,8 @@ import (
)
var _ = Describe("ParseAddonHostOps", func() {
- It("parses the default setting", func() {
- got, err := ParseAddonHostOps(DefaultAddonHostOps)
+ It("preserves the historical explicit grant string", func() {
+ got, err := ParseAddonHostOps("fine-tuning=finetune.,quantization=quantize.,sandbox=workspace.,sandbox=egress.,office=workspace.,web=workspace.,telegram=connector.")
Expect(err).ToNot(HaveOccurred())
Expect(got).To(Equal(map[string][]string{"fine-tuning": {"finetune."}, "quantization": {"quantize."}, "sandbox": {"workspace.", "egress."}, "office": {"workspace."}, "web": {"workspace."}, "telegram": {"connector."}}))
})
@@ -41,7 +41,16 @@ var _ = Describe("ParseAddonHostOps", func() {
})
var _ = Describe("WithAddonHostOps", func() {
- It("replaces the default", func() {
+ It("does not resurrect grants for nil or empty programmatic configuration", func() {
+ o := &ApplicationConfig{}
+ Expect(o.AddonHostOps).To(BeEmpty())
+ for _, grants := range []map[string][]string{nil, {}} {
+ WithAddonHostOps(map[string][]string{"custom": {"workspace."}})(o)
+ WithAddonHostOps(grants)(o)
+ Expect(o.AddonHostOps).To(BeEmpty())
+ }
+ })
+ It("sets only the supplied grants", func() {
o := &ApplicationConfig{}
Expect(o.AddonHostOps).To(BeNil(), "the field starts unset")
WithAddonHostOps(map[string][]string{"x": {"y."}})(o)
diff --git a/core/config/addon_host_policy.go b/core/config/addon_host_policy.go
new file mode 100644
index 000000000..881295009
--- /dev/null
+++ b/core/config/addon_host_policy.go
@@ -0,0 +1,201 @@
+// SPDX-License-Identifier: MIT
+package config
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+)
+
+const maxAddonHostPolicyBytes = 64 * 1024
+
+// AddonHostOpsResolution is a startup snapshot. Consumers must not mutate Grants.
+// Source and PolicyIgnored are bounded diagnostics, never policy file contents.
+type AddonHostOpsResolution struct {
+ Grants map[string][]string
+ Source string
+ PolicyIgnored bool
+}
+
+// ResolveAddonHostOps applies presence-based precedence. Nil means absent;
+// a pointer to an empty string is an explicit decision to grant nothing.
+// policyPath is already selected by the caller (flag, including empty, over env).
+func ResolveAddonHostOps(flag, environment, policyPath *string) (AddonHostOpsResolution, error) {
+ out := AddonHostOpsResolution{Grants: map[string][]string{}, Source: "empty"}
+ selected := flag
+ if selected != nil {
+ out.Source = "flag"
+ } else if environment != nil {
+ selected = environment
+ out.Source = "environment"
+ }
+ var err error
+ if selected != nil {
+ out.PolicyIgnored = policyPath != nil && *policyPath != ""
+ out.Grants, err = ParseAddonHostOps(*selected)
+ } else if policyPath != nil && *policyPath != "" {
+ out.Source = "policy"
+ out.Grants, err = LoadAddonHostPolicy(*policyPath)
+ }
+ if err != nil {
+ return out, fmt.Errorf("addon host operations (%s): %w", out.Source, err)
+ }
+ return out, nil
+}
+
+// LoadAddonHostPolicy reads only an explicitly selected administrator file.
+// Ownership and mount permissions are the deployment administrator's responsibility.
+// Symlinks to regular files are permitted; this is not a filesystem sandbox.
+func LoadAddonHostPolicy(path string) (map[string][]string, error) {
+ if !filepath.IsAbs(path) {
+ return nil, fmt.Errorf("policy path must be absolute")
+ }
+ // Stat before opening rejects directories and special files (including FIFOs)
+ // without a potentially blocking read. Recheck the opened descriptor as well.
+ info, err := os.Stat(path)
+ if err != nil {
+ return nil, fmt.Errorf("stat policy: %w", err)
+ }
+ if !info.Mode().IsRegular() {
+ return nil, fmt.Errorf("policy must be a regular file")
+ }
+ // #nosec G304 -- Only the administrator selects this startup policy path via CLI/config; regular-file and 64 KiB checks bound its read.
+ f, err := os.Open(path)
+ if err != nil {
+ return nil, fmt.Errorf("open policy: %w", err)
+ }
+ // Read-only cleanup: close errors cannot change the decoded result.
+ defer func() { _ = f.Close() }()
+ info, err = f.Stat()
+ if err != nil {
+ return nil, fmt.Errorf("stat opened policy: %w", err)
+ }
+ if !info.Mode().IsRegular() || info.Size() > maxAddonHostPolicyBytes {
+ return nil, fmt.Errorf("policy must be a regular file of at most 64 KiB")
+ }
+ data, err := io.ReadAll(io.LimitReader(f, maxAddonHostPolicyBytes+1))
+ if err != nil {
+ return nil, fmt.Errorf("read policy: %w", err)
+ }
+ if len(data) > maxAddonHostPolicyBytes {
+ return nil, fmt.Errorf("policy exceeds 64 KiB")
+ }
+ return parseAddonHostPolicy(data)
+}
+
+// Token decoding deliberately avoids encoding/json struct-field case folding
+// and map decoding's last-key-wins behavior.
+func parseAddonHostPolicy(data []byte) (map[string][]string, error) {
+ d := json.NewDecoder(bytes.NewReader(data))
+ d.UseNumber()
+ expect := func(want json.Delim) error {
+ t, err := d.Token()
+ if err != nil {
+ return err
+ }
+ if t != want {
+ return fmt.Errorf("expected %q", want)
+ }
+ return nil
+ }
+ if err := expect('{'); err != nil {
+ return nil, err
+ }
+ seen := map[string]bool{}
+ grants := map[string][]string{}
+ for d.More() {
+ key, err := d.Token()
+ if err != nil {
+ return nil, err
+ }
+ name, ok := key.(string)
+ if !ok || seen[name] {
+ return nil, fmt.Errorf("duplicate or invalid policy key")
+ }
+ seen[name] = true
+ switch name {
+ case "schemaVersion":
+ version, err := d.Token()
+ if err != nil {
+ return nil, err
+ }
+ if version != json.Number("1") {
+ return nil, fmt.Errorf("schemaVersion must be integer 1")
+ }
+ case "grants":
+ if err := expect('{'); err != nil {
+ return nil, err
+ }
+ for d.More() {
+ token, err := d.Token()
+ if err != nil {
+ return nil, err
+ }
+ id, ok := token.(string)
+ if !ok || !addonIDForHostOps.MatchString(id) {
+ return nil, fmt.Errorf("invalid policy addon ID")
+ }
+ if _, exists := grants[id]; exists {
+ return nil, fmt.Errorf("duplicate policy addon ID")
+ }
+ if len(grants) >= 128 {
+ return nil, fmt.Errorf("policy exceeds 128 addons")
+ }
+ if err := expect('['); err != nil {
+ return nil, err
+ }
+ prefixes := []string{}
+ seenPrefixes := map[string]bool{}
+ for d.More() {
+ token, err := d.Token()
+ if err != nil {
+ return nil, err
+ }
+ prefix, ok := token.(string)
+ if !ok || !hostOpPrefix.MatchString(prefix) || seenPrefixes[prefix] {
+ return nil, fmt.Errorf("invalid or duplicate policy prefix")
+ }
+ if len(prefixes) >= 32 {
+ return nil, fmt.Errorf("policy exceeds 32 prefixes per addon")
+ }
+ prefixes = append(prefixes, prefix)
+ seenPrefixes[prefix] = true
+ }
+ if err := expect(']'); err != nil {
+ return nil, err
+ }
+ grants[id] = prefixes
+ }
+ if err := expect('}'); err != nil {
+ return nil, err
+ }
+ default:
+ return nil, fmt.Errorf("unknown policy key")
+ }
+ }
+ if err := expect('}'); err != nil {
+ return nil, err
+ }
+ if !seen["schemaVersion"] || !seen["grants"] {
+ return nil, fmt.Errorf("policy requires schemaVersion and grants")
+ }
+ if _, err := d.Token(); err != io.EOF {
+ return nil, fmt.Errorf("unexpected trailing policy data")
+ }
+ return grants, nil
+}
+
+// CloneAddonHostOps isolates startup grants from caller-owned maps and slices.
+func CloneAddonHostOps(ops map[string][]string) map[string][]string {
+ if ops == nil {
+ return nil
+ }
+ out := make(map[string][]string, len(ops))
+ for id, prefixes := range ops {
+ out[id] = append([]string(nil), prefixes...)
+ }
+ return out
+}
diff --git a/core/config/addon_host_policy_test.go b/core/config/addon_host_policy_test.go
new file mode 100644
index 000000000..e548bbc86
--- /dev/null
+++ b/core/config/addon_host_policy_test.go
@@ -0,0 +1,141 @@
+// SPDX-License-Identifier: MIT
+package config
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("Addon host policy", func() {
+ write := func(body string) string {
+ p := filepath.Join(GinkgoT().TempDir(), "policy.json")
+ Expect(os.WriteFile(p, []byte(body), 0600)).To(Succeed())
+ return p
+ }
+ It("loads exact grants and accepts an empty mapping", func() {
+ got, err := LoadAddonHostPolicy(write(`{"schemaVersion":1,"grants":{"sandbox":["workspace.","egress."]}}`))
+ Expect(err).NotTo(HaveOccurred())
+ Expect(got).To(Equal(map[string][]string{"sandbox": {"workspace.", "egress."}}))
+ got, err = LoadAddonHostPolicy(write(`{"schemaVersion":1,"grants":{}}`))
+ Expect(err).NotTo(HaveOccurred())
+ Expect(got).To(BeEmpty())
+ })
+ DescribeTable("rejects strict schema violations", func(body string) {
+ _, err := LoadAddonHostPolicy(write(body))
+ Expect(err).To(HaveOccurred())
+ },
+ Entry("missing version", `{"grants":{}}`),
+ Entry("missing grants", `{"schemaVersion":1}`),
+ Entry("null grants", `{"schemaVersion":1,"grants":null}`),
+ Entry("null prefixes", `{"schemaVersion":1,"grants":{"a":null}}`),
+ Entry("wrong version", `{"schemaVersion":2,"grants":{}}`),
+ Entry("string version", `{"schemaVersion":"1","grants":{}}`),
+ Entry("fraction version", `{"schemaVersion":1.0,"grants":{}}`),
+ Entry("unknown key", `{"schemaVersion":1,"grants":{},"extra":true}`),
+ Entry("case variant", `{"SchemaVersion":1,"grants":{}}`),
+ Entry("duplicate key", `{"schemaVersion":1,"schemaVersion":1,"grants":{}}`),
+ Entry("duplicate addon", `{"schemaVersion":1,"grants":{"a":[],"a":[]}}`),
+ Entry("escaped duplicate", `{"schemaVersion":1,"grants":{"a":[],"\u0061":[]}}`),
+ Entry("duplicate prefix", `{"schemaVersion":1,"grants":{"a":["x.","x."]}}`),
+ Entry("invalid addon", `{"schemaVersion":1,"grants":{"A":["x."]}}`),
+ Entry("invalid prefix", `{"schemaVersion":1,"grants":{"a":["X."]}}`),
+ Entry("untrimmed prefix", `{"schemaVersion":1,"grants":{"a":[" x."]}}`),
+ Entry("exact operation", `{"schemaVersion":1,"grants":{"a":["workspace.read"]}}`),
+ Entry("non string", `{"schemaVersion":1,"grants":{"a":[1]}}`),
+ Entry("trailing JSON", `{"schemaVersion":1,"grants":{}} {}`),
+ Entry("malformed", `{"schemaVersion":1,`),
+ Entry("array", `[]`),
+ )
+ It("enforces file type, absolute paths, existence and size", func() {
+ for _, p := range []string{"relative.json", "https://example.com/policy.json", GinkgoT().TempDir(), filepath.Join(GinkgoT().TempDir(), "absent")} {
+ _, err := LoadAddonHostPolicy(p)
+ Expect(err).To(HaveOccurred(), p)
+ }
+ body := `{"schemaVersion":1,"grants":{}}`
+ _, err := LoadAddonHostPolicy(write(body + strings.Repeat(" ", 65536-len(body))))
+ Expect(err).NotTo(HaveOccurred())
+ _, err = LoadAddonHostPolicy(write(body + strings.Repeat(" ", 65537-len(body))))
+ Expect(err).To(HaveOccurred())
+ })
+ It("permits a symlink to an explicitly selected regular file", func() {
+ path := write(`{"schemaVersion":1,"grants":{}}`)
+ link := filepath.Join(GinkgoT().TempDir(), "selected.json")
+ Expect(os.Symlink(path, link)).To(Succeed())
+ got, err := LoadAddonHostPolicy(link)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(got).To(BeEmpty())
+ })
+ It("rejects an unreadable file when the operating system denies access", func() {
+ path := write(`{"schemaVersion":1,"grants":{}}`)
+ Expect(os.Chmod(path, 0000)).To(Succeed())
+ if os.Geteuid() == 0 {
+ Skip("root can read mode-0000 files")
+ }
+ _, err := LoadAddonHostPolicy(path)
+ Expect(err).To(HaveOccurred())
+ })
+ It("bounds addon and prefix counts", func() {
+ entries := []string{}
+ for i := 0; i < 129; i++ {
+ entries = append(entries, fmt.Sprintf(`"a-%d":[]`, i))
+ }
+ _, err := LoadAddonHostPolicy(write(`{"schemaVersion":1,"grants":{` + strings.Join(entries[:128], ",") + `}}`))
+ Expect(err).NotTo(HaveOccurred())
+ _, err = LoadAddonHostPolicy(write(`{"schemaVersion":1,"grants":{` + strings.Join(entries, ",") + `}}`))
+ Expect(err).To(HaveOccurred())
+ prefixes := []string{}
+ for i := 0; i < 33; i++ {
+ prefixes = append(prefixes, fmt.Sprintf(`"x.%s."`, string(rune('a'+i/26))+string(rune('a'+i%26))))
+ }
+ _, err = LoadAddonHostPolicy(write(`{"schemaVersion":1,"grants":{"a":[` + strings.Join(prefixes[:32], ",") + `]}}`))
+ Expect(err).NotTo(HaveOccurred())
+ _, err = LoadAddonHostPolicy(write(`{"schemaVersion":1,"grants":{"a":[` + strings.Join(prefixes, ",") + `]}}`))
+ Expect(err).To(HaveOccurred())
+ })
+ It("resolves presence without union or opening an ignored policy", func() {
+ empty, env, flag := "", "env=x.", "flag=y."
+ bad := "/not-present-policy.json"
+ policy := write(`{"schemaVersion":1,"grants":{"file":["z."]}}`)
+ for _, tc := range []struct {
+ flag, env, policy *string
+ want map[string][]string
+ source string
+ ignored bool
+ }{
+ {nil, nil, nil, map[string][]string{}, "empty", false},
+ {nil, nil, &policy, map[string][]string{"file": {"z."}}, "policy", false},
+ {nil, &env, &bad, map[string][]string{"env": {"x."}}, "environment", true},
+ {&flag, &env, &bad, map[string][]string{"flag": {"y."}}, "flag", true},
+ {&empty, &env, &bad, map[string][]string{}, "flag", true},
+ {nil, &empty, &bad, map[string][]string{}, "environment", true},
+ {nil, nil, &empty, map[string][]string{}, "empty", false},
+ } {
+ got, err := ResolveAddonHostOps(tc.flag, tc.env, tc.policy)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(got.Grants).To(Equal(tc.want))
+ Expect(got.Source).To(Equal(tc.source))
+ Expect(got.PolicyIgnored).To(Equal(tc.ignored))
+ }
+ _, err := ResolveAddonHostOps(nil, nil, &bad)
+ Expect(err).To(HaveOccurred())
+ })
+ It("snapshots programmatic grants including an exact empty decision", func() {
+ original := map[string][]string{"a": {"x."}}
+ option := WithAddonHostOps(original)
+ original["a"][0] = "changed."
+ c := &ApplicationConfig{}
+ option(c)
+ Expect(c.AddonHostOps).To(Equal(map[string][]string{"a": {"x."}}))
+ c.AddonHostOps["a"][0] = "other."
+ second := &ApplicationConfig{}
+ option(second)
+ Expect(second.AddonHostOps).To(Equal(map[string][]string{"a": {"x."}}))
+ WithAddonHostOps(map[string][]string{})(c)
+ Expect(c.AddonHostOps).To(BeEmpty())
+ })
+})
diff --git a/core/config/addon_model_actions_test.go b/core/config/addon_model_actions_test.go
new file mode 100644
index 000000000..75ae121b3
--- /dev/null
+++ b/core/config/addon_model_actions_test.go
@@ -0,0 +1,25 @@
+// SPDX-License-Identifier: MIT
+package config
+
+import (
+ "github.com/mudler/LocalAI/pkg/addons/contract"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ "slices"
+)
+
+var _ = Describe("Addon model capability vocabulary", func() {
+ It("matches every concrete non-native host model flag", func() {
+ var expected []string
+ for name := range GetAllModelConfigUsecases() {
+ if name != "FLAG_CHAT" && name != "FLAG_COMPLETION" && name != "FLAG_LLM" {
+ expected = append(expected, name)
+ }
+ }
+ Expect(contract.ModelActionCapabilities()).To(ConsistOf(expected))
+ Expect(contract.MaxModelActions).To(Equal(len(expected)))
+ flags := contract.ModelActionCapabilities()
+ slices.Sort(flags)
+ Expect(slices.Compact(flags)).To(HaveLen(len(expected)))
+ })
+})
diff --git a/core/config/application_config.go b/core/config/application_config.go
index b9d380a24..eb0576eba 100644
--- a/core/config/application_config.go
+++ b/core/config/application_config.go
@@ -89,6 +89,7 @@ func WithAddons(c AddonConfig) AppOption {
}
type ApplicationConfig struct {
+ chatSelection *chatSelectionState
// OfficialLibraries is the switch for the shipped default library sources
// of every kind. On by default; off keeps them listed but unread.
OfficialLibraries bool
@@ -561,6 +562,7 @@ func NewApplicationConfig(o ...AppOption) *ApplicationConfig {
oo(opt)
}
opt.SetMaxConcurrentBackendOperations(opt.MaxConcurrentBackendOperations)
+ opt.selectionState()
return opt
}
@@ -1409,7 +1411,7 @@ const (
// WithChatsHostExec sets who gets the chats' built-in bash: off, ask or on.
func WithChatsHostExec(mode string) AppOption {
return func(o *ApplicationConfig) {
- o.ChatsHostExec = mode
+ o.SetChatsHostExec(mode)
}
}
diff --git a/core/config/chat_selection.go b/core/config/chat_selection.go
new file mode 100644
index 000000000..2b19fc60f
--- /dev/null
+++ b/core/config/chat_selection.go
@@ -0,0 +1,62 @@
+// SPDX-License-Identifier: MIT
+package config
+
+import (
+ "maps"
+ "sync"
+)
+
+// ChatSelection is a detached current snapshot, not an execution permission.
+type ChatSelection struct {
+ HostExec string
+ ToolProviders map[string]string
+}
+
+type chatSelectionState struct {
+ mu sync.RWMutex
+ value ChatSelection
+}
+
+// ApplicationConfig is copied by value by embedders. Keep synchronization and
+// canonical selection state behind a pointer, never copy a live mutex. The
+// initialization lock also supports legacy zero-value configs. Public fields
+// remain startup inputs/serialization mirrors, not concurrent mutation APIs.
+var chatSelectionInit sync.Mutex
+
+func (o *ApplicationConfig) selectionState() *chatSelectionState {
+ chatSelectionInit.Lock()
+ defer chatSelectionInit.Unlock()
+ if o.chatSelection == nil {
+ o.chatSelection = &chatSelectionState{value: ChatSelection{HostExec: o.ChatsHostExec, ToolProviders: maps.Clone(o.ChatsToolProviders)}}
+ }
+ return o.chatSelection
+}
+
+func (o *ApplicationConfig) ChatSelectionSnapshot() ChatSelection {
+ s := o.selectionState()
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ v := s.value
+ v.ToolProviders = maps.Clone(v.ToolProviders)
+ return v
+}
+
+func (o *ApplicationConfig) SetChatsHostExec(v string) {
+ s := o.selectionState()
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.value.HostExec != v {
+ s.value.HostExec = v
+ }
+ o.ChatsHostExec = v
+}
+
+func (o *ApplicationConfig) SetChatsToolProviders(v map[string]string) {
+ s := o.selectionState()
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if !maps.Equal(s.value.ToolProviders, v) {
+ s.value.ToolProviders = maps.Clone(v)
+ }
+ o.ChatsToolProviders = maps.Clone(v)
+}
diff --git a/core/config/chat_selection_shared_test.go b/core/config/chat_selection_shared_test.go
new file mode 100644
index 000000000..a3d88ccf4
--- /dev/null
+++ b/core/config/chat_selection_shared_test.go
@@ -0,0 +1,33 @@
+// SPDX-License-Identifier: MIT
+package config
+
+import (
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ "sync"
+)
+
+var _ = Describe("shared selection ownership", func() {
+ It("shares synchronized selection ownership across config value copies", func() {
+ o := NewApplicationConfig()
+ _ = o.ChatSelectionSnapshot()
+ copied := ApplicationConfig{chatSelection: o.selectionState()}
+ var wg sync.WaitGroup
+ wg.Add(2)
+ go func() {
+ defer wg.Done()
+ for range 100 {
+ WithChatsHostExec("on")(&copied)
+ WithChatsHostExec("off")(&copied)
+ }
+ }()
+ go func() {
+ defer wg.Done()
+ for range 100 {
+ _ = o.ChatSelectionSnapshot()
+ }
+ }()
+ wg.Wait()
+ Expect(o.ChatSelectionSnapshot().HostExec).To(Equal("off"))
+ })
+})
diff --git a/core/config/chat_selection_test.go b/core/config/chat_selection_test.go
new file mode 100644
index 000000000..6ec6dfdb0
--- /dev/null
+++ b/core/config/chat_selection_test.go
@@ -0,0 +1,46 @@
+// SPDX-License-Identifier: MIT
+package config_test
+
+import (
+ "github.com/mudler/LocalAI/core/config"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("current chat selection", func() {
+ It("clones pins and applies current pin and host mode settings", func() {
+ o := config.NewApplicationConfig()
+ a := map[string]string{"bash": "alpha"}
+ o.ApplyRuntimeSettings(&config.RuntimeSettings{ChatsToolProviders: &a})
+ first := o.ChatSelectionSnapshot()
+ a["bash"] = "caller"
+ first.ToolProviders["bash"] = "consumer"
+ Expect(o.ChatSelectionSnapshot().ToolProviders["bash"]).To(Equal("alpha"))
+ b := map[string]string{"bash": "beta"}
+ o.ApplyRuntimeSettings(&config.RuntimeSettings{ChatsToolProviders: &b})
+ Expect(o.ChatSelectionSnapshot().ToolProviders["bash"]).To(Equal("beta"))
+ a["bash"] = "alpha"
+ o.ApplyRuntimeSettings(&config.RuntimeSettings{ChatsToolProviders: &a})
+ o.ApplyRuntimeSettings(&config.RuntimeSettings{ChatsToolProviders: &a})
+ mode := o.ChatSelectionSnapshot().HostExec
+ Expect(o.ChatSelectionSnapshot().ToolProviders["bash"]).To(Equal("alpha"))
+ config.WithChatsHostExec("different")(o)
+ Expect(o.ChatSelectionSnapshot().HostExec).To(Equal("different"))
+ config.WithChatsHostExec(mode)(o)
+ Expect(o.ChatSelectionSnapshot().HostExec).To(Equal(mode))
+ })
+ It("captures legacy startup values and routes startup settings through mutations", func() {
+ o := &config.ApplicationConfig{ChatsHostExec: "ask", ChatsToolProviders: map[string]string{"bash": "first"}}
+ initial := o.ChatSelectionSnapshot()
+ Expect(initial.HostExec).To(Equal("ask"))
+ Expect(initial.ToolProviders["bash"]).To(Equal("first"))
+ pins := map[string]string{"bash": "second"}
+ // Startup preserves explicit legacy inputs over persisted settings.
+ o.ApplyRuntimeSettingsAtStartup(&config.RuntimeSettings{ChatsToolProviders: &pins})
+ Expect(o.ChatSelectionSnapshot().ToolProviders["bash"]).To(Equal("first"))
+ o = config.NewApplicationConfig()
+ o.ApplyRuntimeSettingsAtStartup(&config.RuntimeSettings{ChatsToolProviders: &pins})
+ Expect(o.ChatSelectionSnapshot().ToolProviders["bash"]).To(Equal("second"))
+ })
+
+})
diff --git a/core/config/runtime_settings_registry.go b/core/config/runtime_settings_registry.go
index 112f922c5..c77da7112 100644
--- a/core/config/runtime_settings_registry.go
+++ b/core/config/runtime_settings_registry.go
@@ -747,12 +747,12 @@ var runtimeSettingsFields = []fieldSpec{
// Built-in chat tools: the host-commands mode and the add-on pins.
field("chats_host_exec",
func(s *RuntimeSettings) **string { return &s.ChatsHostExec },
- func(o *ApplicationConfig) string { return o.ChatsHostExec },
- func(o *ApplicationConfig, v string) { o.ChatsHostExec = v }),
+ func(o *ApplicationConfig) string { return o.ChatSelectionSnapshot().HostExec },
+ func(o *ApplicationConfig, v string) { o.SetChatsHostExec(v) }),
fieldEq("chats_tool_providers",
func(s *RuntimeSettings) **map[string]string { return &s.ChatsToolProviders },
- func(o *ApplicationConfig) map[string]string { return maps.Clone(o.ChatsToolProviders) },
- func(o *ApplicationConfig, v map[string]string) { o.ChatsToolProviders = maps.Clone(v) },
+ func(o *ApplicationConfig) map[string]string { return o.ChatSelectionSnapshot().ToolProviders },
+ func(o *ApplicationConfig, v map[string]string) { o.SetChatsToolProviders(v) },
func(a, b map[string]string) bool { return maps.Equal(a, b) }),
// Branding: the file is the only source (no env/CLI), so it is always
diff --git a/core/http/endpoints/addons/pins_test.go b/core/http/endpoints/addons/pins_test.go
index 0d8a5ef64..7434dd3ec 100644
--- a/core/http/endpoints/addons/pins_test.go
+++ b/core/http/endpoints/addons/pins_test.go
@@ -25,15 +25,12 @@ func madeUpPages() {
}))
}
-// pinsApp builds the two routes over a store. With seeded set, the upgrade seed
-// has run for the local owner, as it does at start in a deployment that
-// existed before this release.
-func pinsApp(seeded bool) *echo.Echo {
+func pinsApp(withPins bool) *echo.Echo {
madeUpPages()
store, err := pins.NewFileStore("")
Expect(err).ToNot(HaveOccurred())
- if seeded {
- _, err := store.SeedExisting(context.Background(), []string{auth.AddonLocalOwnerID}, []string{"guide", "help"})
+ if withPins {
+ err := store.Replace(context.Background(), auth.AddonLocalOwnerID, []string{"guide", "help"})
Expect(err).ToNot(HaveOccurred())
}
svc := &pins.Service{Store: store}
@@ -57,7 +54,7 @@ var _ = Describe("the pins routes", func() {
It("gives no pins to an account created after the upgrade", func() {
e := pinsApp(false)
code, got := call(e, http.MethodGet, "")
- Expect(code).To(Equal(200), "no seed has run for this account, so it has no pins")
+ Expect(code).To(Equal(200), "no pins were explicitly saved for this account")
Expect(got).To(BeEmpty())
})
@@ -65,7 +62,7 @@ var _ = Describe("the pins routes", func() {
e := pinsApp(true)
code, got := call(e, http.MethodGet, "")
Expect(code).To(Equal(200))
- Expect(got).To(Equal([]string{"guide", "help"}), "seed")
+ Expect(got).To(Equal([]string{"guide", "help"}), "explicit saved pins")
code, got = call(e, http.MethodPut, `["help","guide"]`)
Expect(code).To(Equal(200))
Expect(got).To(HaveLen(2))
diff --git a/core/http/endpoints/studio/addons.go b/core/http/endpoints/studio/addons.go
index 172b6cc40..362839ac3 100644
--- a/core/http/endpoints/studio/addons.go
+++ b/core/http/endpoints/studio/addons.go
@@ -24,6 +24,9 @@ func writeAddonList(c echo.Context, list []studiosvc.AddonInfo, cmds studiosvc.C
out := schema.ChatsAddonList{Addons: make([]schema.ChatsAddon, 0, len(list)), Commands: string(cmds.State), CommandsReason: cmds.Reason,
CommandsIsolation: cmds.Isolation, CommandsProvider: cmds.Provider, CommandsConsent: cmds.Consent,
CommandsFallback: cmds.Fallback, CommandsFallbackReason: cmds.FallbackReason}
+ if v := cmds.ExecutionStatus; v != nil {
+ out.ExecutionStatus = &schema.ChatsExecutionStatus{Readiness: v.Readiness, Isolation: v.Isolation, Explanation: v.Explanation, Generation: v.Generation, Incarnation: v.Incarnation}
+ }
for _, a := range list {
out.Addons = append(out.Addons, schema.ChatsAddon{ID: a.ID, Tools: a.Tools, Off: a.Off})
}
diff --git a/core/http/react-ui/e2e/addon-model-actions.spec.js b/core/http/react-ui/e2e/addon-model-actions.spec.js
new file mode 100644
index 000000000..30a096c92
--- /dev/null
+++ b/core/http/react-ui/e2e/addon-model-actions.spec.js
@@ -0,0 +1,66 @@
+import { test, expect } from './coverage-fixtures.js'
+import { mockLedger, GALLERY } from './ledger-fixtures.js'
+const model = 'org/model:雪?&'
+const provider = (id = 'creative') => ({ id, title: 'Creative', generation: 3, enabled: true, state: 'running', ui: { sdk: {min:2,max:2}, entry: `/api/addons/${id}/assets/${'a'.repeat(64)}/ui.js`, pages:['canvas'], modelActions:[{capability:'FLAG_IMAGE',page:'canvas',path:'images'}] } })
+async function setup(page, providers) {
+ await mockLedger(page, { installed: [{id:model,backend:'diffusers',capabilities:['FLAG_IMAGE']}], gallery: [{...GALLERY[0], name:model, tags:['image']}] })
+ await page.route('**/api/auth/status', r=>r.fulfill({json:{authEnabled:false}}))
+ await page.route('**/api/features', r=>r.fulfill({json:{addons:true}}))
+ const state = { providers, reads:0, assets:0 }
+ await page.route('**/api/addons/apps', r=>{state.reads++;return r.fulfill({json:{apps:state.providers}})})
+ await page.route('**/api/addons/*/assets/**', r=>{state.assets++;return r.fulfill({status:403,body:''})})
+ return state
+}
+async function open(page, surface) {
+ if (surface === 'detail') await page.goto(`/app/models/${encodeURIComponent(model)}`)
+ else {
+ await page.goto(surface === 'installed' ? '/app/models?view=installed' : '/app/models')
+ await page.getByTestId('models-search').fill(model)
+ await page.locator('[data-entity]').filter({hasText:model}).first().click()
+ }
+ return page.locator('[data-model-action="FLAG_IMAGE"]').first()
+}
+for (const surface of ['installed','gallery','detail']) {
+ test(`model actions ${surface}: arbitrary single provider one click encodes model without eager load`, async ({page})=>{
+ const state = await setup(page,[provider()]); const action = await open(page,surface)
+ await expect(action).toHaveAttribute('data-action-status','ready')
+ expect(state.assets).toBe(0); const reads = state.reads
+ await action.getByRole('button').click()
+ await expect(page).toHaveURL(/\/app\/addons\/creative\/canvas\/images\?model=/)
+ expect(new URL(page.url()).searchParams.get('model')).toBe(model)
+ expect(state.reads).toBeGreaterThan(reads)
+ })
+ test(`model actions ${surface}: named collision choices never choose first provider`, async ({page})=>{
+ const state = await setup(page,[provider('zebra'),provider('arbitrary')]); const action = await open(page,surface)
+ await expect(action).toHaveAttribute('data-action-status','ready')
+ await action.getByRole('button').click()
+ const menu = page.getByRole('menu')
+ await expect(menu.getByRole('menuitem')).toHaveText(['Creative (arbitrary)','Creative (zebra)'])
+ expect(state.assets).toBe(0)
+ await menu.getByRole('menuitem',{name:'Creative (zebra)',exact:true}).click()
+ await expect(page).toHaveURL(/\/app\/addons\/zebra\/canvas\/images\?model=/)
+ })
+ test(`model actions ${surface}: absence retains label and never promotes installation`, async ({page})=>{
+ await setup(page,[]); const action = await open(page,surface)
+ await expect(action).toHaveAttribute('data-action-status','absent')
+ await expect(action).toContainText(/image/i)
+ await expect(action.getByRole('button')).toHaveCount(0)
+ await expect(action.getByRole('link')).toHaveCount(0)
+ })
+ test(`model actions ${surface}: changed entry refuses captured choice without retarget`, async ({page})=>{
+ const state = await setup(page,[provider()]); const action = await open(page,surface)
+ await expect(action).toHaveAttribute('data-action-status','ready')
+ state.providers = [{...provider(),generation:4}]
+ await action.getByRole('button').click()
+ await expect(action).toHaveAttribute('data-action-status','stale')
+ expect(new URL(page.url()).pathname).not.toContain('/app/addons/')
+ })
+}
+test('removed addon addresses reach NotFound rather than addon or install promotion',async({page})=>{
+ await setup(page,[])
+ for (const path of ['studio','talk','image','video','3d','tts','sound','diarization','transform','face','voice','voice-library','fine-tune','quantize']) {
+ await page.goto(`/app/${path}`)
+ await expect(page.getByTestId('not-found')).toBeVisible()
+ expect(new URL(page.url()).pathname).toBe(`/app/${path}`)
+ }
+})
diff --git a/core/http/react-ui/e2e/addon-navigation.spec.js b/core/http/react-ui/e2e/addon-navigation.spec.js
index 419adb17e..346cc411d 100644
--- a/core/http/react-ui/e2e/addon-navigation.spec.js
+++ b/core/http/react-ui/e2e/addon-navigation.spec.js
@@ -4,6 +4,7 @@ import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { ADMIN, MEMBER, app, installedAddon, release, sessionFor, mockAddons } from './addons-fixtures.js'
import { buildAddonBundles, installStudioAddons, packagedApp } from './studio-addon.js'
+import { mockSettings } from './settings-fixtures.js'
import { FACE_MODEL, clipFile, faces, match, mockIdentity, people, photoFile } from './identity-fixtures.js'
// Studio and Talk are packaged add-ons, not built-in tiles: the specs install
@@ -223,6 +224,7 @@ test.describe('Add-on navigation', () => {
test('the Settings page lists the add-ons with settings in an Add-ons group, and only when there is one', async ({ page }) => {
await setup(page, { pins: [], studio: false, user: ADMIN, apps: [withSettings(), app({ id: 'notes', title: 'Notes' })] })
+ await mockSettings(page)
await page.goto('/app/settings')
const group = page.locator('.st-group[data-group="addons"]')
await expect(group).toBeVisible()
@@ -239,6 +241,7 @@ test.describe('Add-on navigation', () => {
test('the Settings page has no Add-ons group when no add-on has settings', async ({ page }) => {
await setup(page, { pins: [], studio: false, user: ADMIN, apps: [app({ id: 'notes', title: 'Notes' })] })
+ await mockSettings(page)
const listed = page.waitForResponse(r => /\/api\/addons\/apps$/.test(new URL(r.url()).pathname))
await page.goto('/app/settings')
await listed
@@ -492,23 +495,24 @@ test.describe('Add-on navigation: the legacy Add-ons hub tab and the route in bo
await expect(page.locator('.dk-hubtabs [data-hub-tab="addons"]')).toHaveAttribute('aria-current', 'page')
})
- test('an old generator link keeps its model, query and hash', async ({ page }) => {
+ test('a canonical generator link keeps its model, query and hash', async ({ page }) => {
await setup(page)
- await page.goto('/app/image/stable%2Fdiffusion?x=1#top')
+ await page.goto('/app/addons/studio/studio/images?x=1&model=stable%2Fdiffusion#top')
await expect(page).toHaveURL(/\/app\/addons\/studio\/studio\/images\?x=1&model=stable%2Fdiffusion#top$/)
})
- test('the legacy ?tab= link of Studio still works', async ({ page }) => {
+ test('the canonical Studio tab address stays on its declared page', async ({ page }) => {
await setup(page)
- await page.goto('/app/studio?tab=tts')
+ await page.goto('/app/addons/studio/studio/tts')
await expect(page).toHaveURL(/\/app\/addons\/studio\/studio\/tts$/)
})
- test('a viewer without the 3d feature still ends on the app home', async ({ page }) => {
+ test('a viewer without the native 3d feature stays in Studio overview without the gated tab', async ({ page }) => {
await setup(page)
await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(MEMBER, { addons: true, '3d': false }) }))
- await page.goto('/app/3d')
- await expect(page).toHaveURL(/\/app$/)
+ await page.goto('/app/addons/studio/studio/threed')
+ await expect(page.locator('.studio-tab-active[data-tab="overview"]')).toBeVisible()
+ await expect(page.locator('[data-tab="threed"]')).toHaveCount(0)
})
})
@@ -633,10 +637,10 @@ test.describe('Add-on navigation: the Voice recognition page', () => {
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Indexer')
})
- test('an old Voice link ends in the add-on shell, which says the add-on is not available', async ({ page }) => {
+ test('a canonical Voice link ends in the add-on shell, which says the add-on is not available', async ({ page }) => {
await setup(page, { apps: [] })
await asAdmin(page)
- await page.goto('/app/voice')
+ await page.goto('/app/addons/voice/voice/recognition')
await expect(page).toHaveURL(/\/app\/addons\/voice\//)
await expect(page.getByTestId('addon-unavailable')).toBeVisible()
})
@@ -646,7 +650,7 @@ test.describe('Add-on navigation: the Voice recognition page', () => {
await asAdmin(page)
await serveVoice(page)
await mockIdentity(page)
- await page.goto('/app/voice/org%2Fname')
+ await page.goto('/app/addons/voice/voice/recognition?model=org%2Fname')
await expect(page).toHaveURL(/\/app\/addons\/voice\/voice\/recognition\?model=org%2Fname$/)
await expect(page.getByTestId('voices-page')).toBeVisible()
})
@@ -886,10 +890,10 @@ test.describe('Add-on navigation: the Face recognition page', () => {
await expect(page.getByTestId('known-empty')).toContainText('Nobody enrolled in this browser yet')
})
- test('an old Face link ends in the add-on shell, which says the add-on is not available', async ({ page }) => {
+ test('a canonical Face link ends in the add-on shell, which says the add-on is not available', async ({ page }) => {
await setup(page, { apps: [] })
await asAdmin(page)
- await page.goto('/app/face')
+ await page.goto('/app/addons/face/recognition')
await expect(page).toHaveURL(/\/app\/addons\/face\//)
await expect(page.getByTestId('addon-unavailable')).toBeVisible()
})
@@ -899,7 +903,7 @@ test.describe('Add-on navigation: the Face recognition page', () => {
await asFaceAdmin(page)
await serveFace(page)
await mockIdentity(page)
- await page.goto('/app/face/org%2Fname')
+ await page.goto('/app/addons/face/recognition?model=org%2Fname')
await expect(page).toHaveURL(/\/app\/addons\/face\/recognition\?model=org%2Fname$/)
await expect(page.getByTestId('faces-page')).toBeVisible()
})
@@ -935,32 +939,32 @@ test.describe('Add-on navigation: the Face recognition page', () => {
await expect(page.locator('.bt-experimental')).toBeVisible()
})
- test('an old fine-tuning link ends in the add-on shell, which says the add-on is not available', async ({ page }) => {
+ test('a canonical fine-tuning link ends in the add-on shell, which says the add-on is not available', async ({ page }) => {
await setup(page, { apps: [] })
await asAdmin(page)
- await page.goto('/app/fine-tune')
+ await page.goto('/app/addons/fine-tuning/jobs')
await expect(page).toHaveURL(/\/app\/addons\/fine-tuning\//)
await expect(page.getByTestId('addon-unavailable')).toBeVisible()
})
- test('an old fine-tuning link opens the add-on when it is installed and running', async ({ page }) => {
+ test('a canonical fine-tuning link opens the add-on when it is installed and running', async ({ page }) => {
await setup(page, { apps: [ftApp()] })
await serveFineTuning(page)
- await page.goto('/app/fine-tune')
+ await page.goto('/app/addons/fine-tuning/jobs')
await expect(page).toHaveURL(/\/app\/addons\/fine-tuning\/jobs$/)
})
- test('an old quantization link ends in the add-on shell, which says the add-on is not available', async ({ page }) => {
+ test('a canonical quantization link ends in the add-on shell, which says the add-on is not available', async ({ page }) => {
await setup(page, { apps: [] })
await asAdmin(page)
- await page.goto('/app/quantize')
+ await page.goto('/app/addons/quantization/jobs')
await expect(page).toHaveURL(/\/app\/addons\/quantization\//)
await expect(page.getByTestId('addon-unavailable')).toBeVisible()
})
- test('an old quantization link opens the add-on when it is installed and running', async ({ page }) => {
+ test('a canonical quantization link opens the add-on when it is installed and running', async ({ page }) => {
await setup(page, { apps: [app({ id: 'quantization', name: 'quantization', title: 'Quantization', icon: undefined, mainPage: '/app/addons/quantization/jobs', ui: { entry: `/api/addons/quantization/assets/${'a'.repeat(64)}/ui/quantization.js`, css: [], sdk: { min: 2, max: 2 }, pages: ['jobs'], navigation: [], chatActions: [] } })] })
- await page.goto('/app/quantize')
+ await page.goto('/app/addons/quantization/jobs')
await expect(page).toHaveURL(/\/app\/addons\/quantization\/jobs$/)
})
@@ -1075,7 +1079,7 @@ test.describe('Add-on navigation: the addons feature gate', () => {
}
})
- for (const address of ['/app/addons', '/app/addons/studio/home', '/app/fine-tune', '/app/quantize']) {
+ for (const address of ['/app/addons', '/app/addons/studio/home', '/app/addons/fine-tuning/jobs', '/app/addons/quantization/jobs']) {
test(`with add-ons off ${address} ends at /app and calls no add-on route`, async ({ page }) => {
const calls = await watch(page)
await setup(page, { features: OFF, user: ADMIN })
diff --git a/core/http/react-ui/e2e/addons-gallery.spec.js b/core/http/react-ui/e2e/addons-gallery.spec.js
index c34c36cb4..f32aa4882 100644
--- a/core/http/react-ui/e2e/addons-gallery.spec.js
+++ b/core/http/react-ui/e2e/addons-gallery.spec.js
@@ -251,8 +251,8 @@ test.describe('Add-ons: apps', () => {
await page.clock.runFor(21_000)
await expect.poll(() => state.log.appsList).toBe(4)
await expect(page.getByText('may be out of date')).toHaveCount(0)
- // Leaving the page stops the requests.
- await page.evaluate(() => { window.history.pushState({}, '', '/app/chats'); window.dispatchEvent(new PopStateEvent('popstate')) })
+ // Leave for a route without a mounted add-on consumer (Home keeps commands current).
+ await page.evaluate(() => { window.history.pushState({}, '', '/app/polling-left'); window.dispatchEvent(new PopStateEvent('popstate')) })
await expect(page.locator('[data-app="notes"]')).toHaveCount(0)
const seen = state.log.appsList
await page.clock.runFor(180_000)
@@ -315,7 +315,7 @@ async function appsAnswered(page, state, n) {
}
test.describe('Add-ons: registry polling', () => {
- const leave = (page) => page.evaluate(() => { window.history.pushState({}, '', '/app/chats'); window.dispatchEvent(new PopStateEvent('popstate')) })
+ const leave = (page) => page.evaluate(() => { window.history.pushState({}, '', '/app/polling-left'); window.dispatchEvent(new PopStateEvent('popstate')) })
const contributing = (over = {}) => app({ ui: { entry: '/api/addons/notes/assets/' + 'c'.repeat(64) + '/ui.js', css: [], sdk: { min: 1, max: 2 }, pages: ['home'], navigation: ['home'] }, ...over })
test('apps: an add-on that contributes UI keeps the list current off the page and its disable stops the reads', async ({ page }) => {
@@ -333,7 +333,12 @@ test.describe('Add-ons: registry polling', () => {
const onPage = state.log.appsList
await expect.poll(() => page.evaluate(() => window.__appsAnswers)).toBe(onPage)
await leave(page)
+ // Finish the lazy route transition under the paused page clock before
+ // testing the registry alone; Home would intentionally retain a consumer.
+ await page.clock.runFor(500)
+ await settle(page)
await expect(page.locator('[data-app="notes"]')).toHaveCount(0)
+ await expect(page.getByRole('heading', { name: 'This page does not exist' })).toBeVisible()
// Off the page the registry reads the list at once. Each step waits until
// the page has read the answer, and so has set its next timer, before the
// clock moves again.
diff --git a/core/http/react-ui/e2e/audio-transform.spec.js b/core/http/react-ui/e2e/audio-transform.spec.js
index 8ec08d300..2b27df232 100644
--- a/core/http/react-ui/e2e/audio-transform.spec.js
+++ b/core/http/react-ui/e2e/audio-transform.spec.js
@@ -104,7 +104,7 @@ test.describe('Audio Transform', () => {
})
test('audio input has Upload + Record tabs', async ({ page }) => {
- await page.goto('/app/transform')
+ await page.goto('/app/addons/studio/studio/transform')
await expect(page.getByRole('button', { name: 'localvqe' })).toBeVisible({ timeout: 10_000 })
// The Audio (required) input should expose both tabs.
@@ -114,7 +114,7 @@ test.describe('Audio Transform', () => {
})
test('echo-test button only appears once a reference is loaded', async ({ page }) => {
- await page.goto('/app/transform')
+ await page.goto('/app/addons/studio/studio/transform')
await expect(page.getByRole('button', { name: 'localvqe' })).toBeVisible({ timeout: 10_000 })
// No reference yet → echo button hidden.
@@ -126,7 +126,7 @@ test.describe('Audio Transform', () => {
})
test('renders the AudioTransform page directly', async ({ page }) => {
- await page.goto('/app/transform')
+ await page.goto('/app/addons/studio/studio/transform')
await expect(page.getByRole('heading', { name: /Audio Transform/ })).toBeVisible({ timeout: 10_000 })
await expect(page.getByRole('button', { name: 'localvqe' })).toBeVisible()
// Audio (required) + Reference (optional) drop zones
@@ -137,7 +137,7 @@ test.describe('Audio Transform', () => {
test('uploads an audio file, posts multipart, renders enhanced waveform', async ({ page }) => {
const submitted = mockAudioTransform(page, 'enhanced.wav')
- await page.goto('/app/transform')
+ await page.goto('/app/addons/studio/studio/transform')
await expect(page.getByRole('button', { name: 'localvqe' })).toBeVisible({ timeout: 10_000 })
// Upload mic file via the hidden file input under "Audio (required)".
@@ -165,7 +165,7 @@ test.describe('Audio Transform', () => {
test('reference file is forwarded as a multipart field when provided', async ({ page }) => {
const submitted = mockAudioTransform(page)
- await page.goto('/app/transform')
+ await page.goto('/app/addons/studio/studio/transform')
await expect(page.getByRole('button', { name: 'localvqe' })).toBeVisible({ timeout: 10_000 })
const inputs = page.locator('input[type="file"]')
@@ -185,7 +185,7 @@ test.describe('Audio Transform', () => {
test('history entry appears after a successful transform and persists across navigation', async ({ page }) => {
mockAudioTransform(page, 'enhanced.wav')
- await page.goto('/app/transform')
+ await page.goto('/app/addons/studio/studio/transform')
await expect(page.getByRole('button', { name: 'localvqe' })).toBeVisible({ timeout: 10_000 })
await page.locator('input[type="file"]').first().setInputFiles(makeFakeWav('mic.wav'))
@@ -197,14 +197,14 @@ test.describe('Audio Transform', () => {
// Persist across page reloads via localStorage.
await page.waitForTimeout(600)
- await page.goto('/app/transform')
+ await page.goto('/app/addons/studio/studio/transform')
await expect(page.getByTestId('media-history-item')).toHaveCount(1)
})
test('renders an input spectrogram on upload and an output one after transform', async ({ page }) => {
mockAudioTransform(page, 'enhanced.wav')
- await page.goto('/app/transform')
+ await page.goto('/app/addons/studio/studio/transform')
await expect(page.getByRole('button', { name: 'localvqe' })).toBeVisible({ timeout: 10_000 })
// Choosing a clip should render its input spectrogram immediately — no
@@ -231,7 +231,7 @@ test.describe('Audio Transform', () => {
})
})
- await page.goto('/app/transform')
+ await page.goto('/app/addons/studio/studio/transform')
await expect(page.getByRole('button', { name: 'localvqe' })).toBeVisible({ timeout: 10_000 })
await page.locator('input[type="file"]').first().setInputFiles(makeFakeWav('mic.wav'))
diff --git a/core/http/react-ui/e2e/chats-commands.spec.js b/core/http/react-ui/e2e/chats-commands.spec.js
index afe16eb84..5bc68c4b1 100644
--- a/core/http/react-ui/e2e/chats-commands.spec.js
+++ b/core/http/react-ui/e2e/chats-commands.spec.js
@@ -40,7 +40,7 @@ const none = () => ({ addons: [], commands: 'none' })
test.describe('Chat that cannot run commands', () => {
test.beforeEach(async ({ page }) => { await mockChat(page) })
- test('an administrator sees the chip and a notice in the Tools menu with the way to install the Sandbox', async ({ page }) => {
+ test('an administrator sees command availability without promoting a provider', async ({ page }) => {
await stubChat(page, none)
await page.goto('/app/chats/c1')
const chip = page.getByTestId('chat-commands-chip')
@@ -50,8 +50,7 @@ test.describe('Chat that cannot run commands', () => {
const notice = page.getByTestId('chat-commands-notice')
await expect(notice).toContainText("Commands can't run here")
await expect(notice).toContainText('No add-on that runs code is installed, so the assistant can write files but cannot run scripts or commands.')
- await expect(notice.getByTestId('chat-commands-notice-link')).toHaveText('Install the Sandbox')
- await expect(notice.getByTestId('chat-commands-notice-link')).toHaveAttribute('href', '/app/addons?tab=browse&q=sandbox')
+ await expect(notice.getByTestId('chat-commands-notice-link')).toHaveCount(0)
await expect(notice).not.toContainText('Ask an administrator')
await shot(page, 'tools-menu-admin')
})
@@ -71,18 +70,19 @@ test.describe('Chat that cannot run commands', () => {
await expect(chip).toBeFocused()
})
- test('a person who is not an administrator is told to ask one, with no install link', async ({ page }) => {
+ test('a non-administrator sees command availability without an install link', async ({ page }) => {
await mockUser(page, { chats: true })
await stubChat(page, none)
await page.goto('/app/chats/c1')
await page.getByTestId('chat-tools-button').click()
const notice = page.getByTestId('chat-commands-notice')
- await expect(notice).toContainText('Ask an administrator to install the Sandbox add-on.')
+ await expect(notice).not.toContainText('Ask an administrator')
+ await expect(notice).toContainText('No add-on that runs code is installed')
await expect(notice.getByTestId('chat-commands-notice-link')).toHaveCount(0)
await shot(page, 'tools-menu-user')
})
- test('hiding the notice hides the chip and leaves one line in the Tools menu, also after a reload', async ({ page }) => {
+ test('hiding the notice hides the chip and leaves one line in the Tools menu, but returns after a reload', async ({ page }) => {
await stubChat(page, none)
await page.goto('/app/chats/c1')
await page.getByTestId('chat-tools-button').click()
@@ -91,22 +91,21 @@ test.describe('Chat that cannot run commands', () => {
await expect(page.getByTestId('chat-commands-notice')).toHaveCount(0)
await expect(page.getByTestId('chat-commands-notice-line')).toHaveText("Commands can't run here")
await page.reload()
- await expect(page.getByTestId('chat-tools-button')).toBeVisible()
- await expect(page.getByTestId('chat-commands-chip')).toHaveCount(0)
+ await expect(page.getByTestId('chat-commands-chip')).toBeVisible()
await page.getByTestId('chat-tools-button').click()
- await expect(page.getByTestId('chat-commands-notice-line')).toBeVisible()
+ await expect(page.getByTestId('chat-commands-notice')).toBeVisible()
await shot(page, 'tools-menu-hidden')
})
test('a Sandbox that is off for the chat points at its switch, and switching it on removes the notice', async ({ page }) => {
const calls = await stubChat(page, (off) => {
const isOff = off === null ? true : off.includes('sandbox')
- return { addons: [{ id: 'sandbox', tools: 7, off: isOff }], commands: isOff ? 'off' : 'available' }
+ return { addons: [{ id: 'sandbox', tools: 7, off: isOff }], commands: isOff ? 'off' : 'available', commands_provider: 'sandbox', commands_isolation: 'isolated' }
})
await page.goto('/app/chats/c1')
await page.getByTestId('chat-tools-button').click()
const notice = page.getByTestId('chat-commands-notice')
- await expect(notice).toContainText('The Sandbox add-on is off for this chat. Switch it on in the Tools menu to let the assistant run code.')
+ await expect(notice).toContainText('Running commands is switched off for this chat.')
await expect(notice.getByTestId('chat-commands-notice-link')).toHaveCount(0)
await page.getByRole('switch', { name: 'sandbox' }).check()
await expect.poll(() => calls.at(-1)).toEqual([])
@@ -114,17 +113,18 @@ test.describe('Chat that cannot run commands', () => {
await expect(page.getByTestId('chat-commands-chip')).toHaveCount(0)
})
- test('a Sandbox that cannot run here gives the reason and the add-on page', async ({ page }) => {
- await stubChat(page, () => ({ addons: [{ id: 'sandbox', tools: 1, off: false }], commands: 'unavailable', commands_reason: 'no sandbox backend works on this host, so commands cannot run' }))
+ test('an unavailable selected provider gives its typed reason without an install link', async ({ page }) => {
+ await stubChat(page, () => ({ addons: [{ id: 'sandbox', tools: 1, off: false }], commands: 'unavailable', commands_provider: 'sandbox', execution_status: { readiness: 'unavailable', explanation: 'no sandbox backend works on this host, so commands cannot run' }, commands_reason: 'no sandbox backend works on this host, so commands cannot run' }))
await page.goto('/app/chats/c1')
await page.getByTestId('chat-tools-button').click()
const notice = page.getByTestId('chat-commands-notice')
- await expect(notice).toContainText('The Sandbox add-on cannot run commands on this server: no sandbox backend works on this host, so commands cannot run')
- await expect(notice.getByTestId('chat-commands-notice-link')).toHaveAttribute('href', '/app/addons?detail=sandbox')
+ await expect(notice).toContainText('The selected execution provider is unavailable.')
+ await expect(notice).toContainText('no sandbox backend works on this host')
+ await expect(notice.getByTestId('chat-commands-notice-link')).toHaveCount(0)
})
test('a chat that runs commands shows neither the chip nor the notice', async ({ page }) => {
- await stubChat(page, () => ({ addons: [{ id: 'sandbox', tools: 7, off: false }], commands: 'available' }))
+ await stubChat(page, () => ({ addons: [{ id: 'sandbox', tools: 7, off: false }], commands: 'available', commands_provider: 'sandbox', commands_isolation: 'isolated' }))
await page.goto('/app/chats/c1')
await page.getByTestId('chat-tools-button').click()
await expect(page.getByRole('switch', { name: 'sandbox' })).toBeChecked()
@@ -158,7 +158,7 @@ test.describe('Chat that cannot run commands', () => {
test.describe('A chat that switched commands off', () => {
test.beforeEach(async ({ page }) => { await mockChat(page) })
- test('shows no notice and no chip: the switch in the Tools menu says it', async ({ page }) => {
+ test('shows the disabled notice and the off switch without an install link', async ({ page }) => {
await stubChat(page, () => ({ addons: [], commands: 'switched_off' }))
await page.route('**/api/chats/c1/tools', route => route.fulfill({
json: {
@@ -169,8 +169,9 @@ test.describe('A chat that switched commands off', () => {
await page.goto('/app/chats/c1')
await page.getByTestId('chat-tools-button').click()
await expect(page.getByRole('switch', { name: 'Run commands' })).toHaveAttribute('aria-checked', 'false')
- await expect(page.getByTestId('chat-commands-notice')).toHaveCount(0)
- await expect(page.getByTestId('chat-commands-chip')).toHaveCount(0)
+ await expect(page.getByTestId('chat-commands-notice')).toContainText('Running commands is switched off for this chat.')
+ await expect(page.getByTestId('chat-commands-chip')).toBeVisible()
+ await expect(page.getByTestId('chat-commands-notice-link')).toHaveCount(0)
})
})
diff --git a/core/http/react-ui/e2e/chats-egress.spec.js b/core/http/react-ui/e2e/chats-egress.spec.js
index ac2116761..394dea97c 100644
--- a/core/http/react-ui/e2e/chats-egress.spec.js
+++ b/core/http/react-ui/e2e/chats-egress.spec.js
@@ -150,7 +150,7 @@ test.describe('Network of the Sandbox commands', () => {
await stubChat(page, { egress: view({ applies: false, applies_reason: 'host' }) })
await page.goto('/app/chats/c1')
await page.getByTestId('chat-tools-button').click()
- await expect(page.getByTestId('chat-network-host')).toHaveText('Commands run on this machine and use its network. Network limits need the Sandbox add-on.')
+ await expect(page.getByTestId('chat-network-unenforced')).toContainText('Commands run on this machine and use its network.')
await expect(page.getByTestId('chat-network')).toHaveCount(0)
})
@@ -160,7 +160,7 @@ test.describe('Network of the Sandbox commands', () => {
await page.getByTestId('chat-tools-button').click()
await expect(page.getByText('Tools for this chat')).toBeVisible()
await expect(page.getByTestId('chat-network')).toHaveCount(0)
- await expect(page.getByTestId('chat-network-host')).toHaveCount(0)
+ await expect(page.getByTestId('chat-network-unenforced')).toHaveCount(0)
})
test('a second add-on with the same control draws the same row, and its link opens that add-on', async ({ page }) => {
diff --git a/core/http/react-ui/e2e/chats-host-exec.spec.js b/core/http/react-ui/e2e/chats-host-exec.spec.js
index d4fcadf7d..25bc17bbe 100644
--- a/core/http/react-ui/e2e/chats-host-exec.spec.js
+++ b/core/http/react-ui/e2e/chats-host-exec.spec.js
@@ -43,7 +43,7 @@ const host = { addons: [], commands: 'available', commands_provider: 'builtin',
test.describe('Commands on this machine', () => {
test.beforeEach(async ({ page }) => { await mockChat(page) })
- test('the Tools menu and the chip say that commands run here, not isolated, with the install link', async ({ page }) => {
+ test('the Tools menu and chip warn that commands run here without isolation or install advice', async ({ page }) => {
await stubChat(page, { addons: host })
await page.goto('/app/chats/c1')
const chip = page.getByTestId('chat-commands-chip')
@@ -51,8 +51,8 @@ test.describe('Commands on this machine', () => {
await expect(chip).toHaveAttribute('data-state', 'host')
await page.getByTestId('chat-tools-button').click()
const notice = page.getByTestId('chat-commands-notice')
- await expect(notice).toContainText('Commands run on this machine, with its files and network, not isolated. Install the Sandbox for isolation.')
- await expect(notice.getByTestId('chat-commands-notice-link')).toHaveAttribute('href', '/app/addons?tab=browse&q=sandbox')
+ await expect(notice).toContainText('Commands run on the server without isolation.')
+ await expect(notice.getByTestId('chat-commands-notice-link')).toHaveCount(0)
await shot(page, 'host-notice')
})
@@ -81,7 +81,7 @@ test.describe('Commands on this machine', () => {
await page.goto('/app/chats/c1')
await page.getByTestId('chat-tools-button').click()
const notice = page.getByTestId('chat-commands-notice')
- await expect(notice).toContainText('The Sandbox is off for this chat, so commands run on this machine')
+ await expect(notice).toContainText('Commands run on the server without isolation.')
await expect(notice.getByTestId('chat-commands-notice-link')).toHaveCount(0)
})
@@ -207,7 +207,7 @@ test.describe('Commands on this machine', () => {
await expect(page.getByText('There is one file.')).toBeVisible({ timeout: 15_000 })
await page.getByTestId('chat-activity').locator('.cx-fold__head').first().click()
const tags = page.getByTestId('chat-step-provider')
- await expect(tags).toHaveText(['This machine, allowed once', 'Sandbox'])
+ await expect(tags).toHaveText(['This machine, allowed once', 'Add-on sandbox'])
await shot(page, 'host-provider-tags')
})
})
diff --git a/core/http/react-ui/e2e/chats-switches-real.spec.js b/core/http/react-ui/e2e/chats-switches-real.spec.js
index 5e7a67fc8..b87d062e4 100644
--- a/core/http/react-ui/e2e/chats-switches-real.spec.js
+++ b/core/http/react-ui/e2e/chats-switches-real.spec.js
@@ -62,14 +62,14 @@ test('switching the files group off in a chat stores it, and Select all puts it
await expect.poll(async () => (await group('files')).off).toBe(false)
})
-test('an add-on address with add-ons off lands on Home with a notice, and /studio offers Install Studio', async ({ page, request }) => {
+test('an add-on address with add-ons off lands on Home with a notice, without package-specific promotion', async ({ page, request }) => {
const features = await (await request.get('/api/features')).json()
test.skip(features.addons !== false, 'needs a server with add-ons off')
- await page.goto('/app/image')
+ await page.goto('/app/addons/studio/studio/images')
await page.waitForURL(/\/app$/)
const note = page.getByTestId('home-addons-off')
await expect(note).toContainText('Add-ons are off on this server')
- await expect(note).toContainText('Studio is an add-on')
+ await expect(note).toContainText('That page is an add-on')
// The Home blocks fade in; let them settle before the picture.
await page.waitForTimeout(1000)
await page.screenshot({ path: 'test-results/wiring-addons-off.png' })
@@ -77,7 +77,6 @@ test('an add-on address with add-ons off lands on Home with a notice, and /studi
await expect(note).toHaveCount(0)
await page.getByRole('combobox', { name: 'Message' }).fill('/studio')
- await expect(page.getByRole('option', { name: /It is an add-on/ })).toBeVisible()
- await page.keyboard.press('Enter')
- await expect(page.getByTestId('home-addons-off')).toContainText('Studio is an add-on')
+ await expect(page.getByRole('option')).toHaveCount(0)
+ await expect(page.getByRole('button', { name: 'Install Studio' })).toHaveCount(0)
})
diff --git a/core/http/react-ui/e2e/chats-ui.spec.js b/core/http/react-ui/e2e/chats-ui.spec.js
index 4f6850604..6233b8721 100644
--- a/core/http/react-ui/e2e/chats-ui.spec.js
+++ b/core/http/react-ui/e2e/chats-ui.spec.js
@@ -531,28 +531,21 @@ test.describe('Chat tools menu: add-ons', () => {
await expect.poll(() => calls.at(-1)).toEqual(['office', 'sandbox'])
})
- test('says the Sandbox is starting while its service does not answer, and asks again later, not at once', async ({ page }) => {
+ test('uses the typed execution observation without private discovery or a retry loop', async ({ page }) => {
const api = newChatsApi(page, { running: false, pending: [] })
await api.install()
- await page.route('**/api/chats/c1/addons', route => route.fulfill({ json: { addons: [{ id: 'sandbox', tools: 2, off: false }] } }))
- await page.route('**/api/addons/apps', route => route.fulfill({ json: { apps: [{ id: 'sandbox', generation: 4 }] } }))
+ await page.route('**/api/chats/c1/addons', route => route.fulfill({ json: {
+ addons: [{ id: 'runner', tools: 2, off: false }], commands: 'available', commands_provider: 'runner',
+ execution_status: { readiness: 'unavailable', isolation: 'unknown', explanation: 'Provider is starting' },
+ } }))
const reads = []
- await page.route('**/api/addons/dispatch', route => {
- reads.push(Date.now())
- if (reads.length === 1) return route.fulfill({ status: 503, json: { error: { message: 'the add-on owner is unavailable' } } })
- return route.fulfill({ json: { payload: { ok: true, canRun: true, active: 'gvisor' } } })
- })
- const errors = []
- page.on('pageerror', e => errors.push(e.message))
+ await page.route('**/api/addons/dispatch', route => { reads.push(route.request().url()); return route.abort() })
await page.goto('/app/chats/c1')
- await expect.poll(() => reads.length).toBe(1)
await page.getByTestId('chat-tools-button').click()
- await expect(page.getByTestId('chat-tools-note')).toHaveText('The Sandbox is starting. LocalAI checks again in 2 s.')
- // The next read waits for the backoff, then the note says what the Sandbox does.
- await expect(page.getByTestId('chat-tools-note')).toHaveText('Runs commands isolated (gvisor)', { timeout: 10_000 })
- expect(reads.length).toBe(2)
- expect(reads[1] - reads[0]).toBeGreaterThanOrEqual(1800)
- expect(errors).toEqual([])
+ await expect(page.getByTestId('chat-commands-notice')).toContainText('Provider is starting')
+ await page.waitForTimeout(2200)
+ expect(reads).toEqual([])
+ await expect(page.getByTestId('chat-commands-notice')).toHaveAttribute('data-state', 'unknown')
})
test('shows no menu when the user has no add-on tools', async ({ page }) => {
@@ -773,7 +766,7 @@ test.describe('Chat tools menu: every group of built-in tools', () => {
await open(page)
const names = await page.getByTestId('chat-tools-group-builtins').locator('.swl-row__name').allTextContents()
expect(names).toEqual(['Run commands', 'Your files', 'Web', 'Images and audio', 'Collections', 'Sub-agents', 'Triggers'])
- await expect(row(page, 'Run commands').getByTestId('chat-tools-note')).toHaveText(['Sandbox', 'Commands run isolated'])
+ await expect(row(page, 'Run commands').getByTestId('chat-tools-note')).toHaveText(['Served by add-on sandbox', 'Commands run isolated'])
await expect(row(page, 'Your files')).toHaveAttribute('title', 'list_files, read_file, write_file · built-in')
await expect(sw(page, 'Collections')).toBeDisabled()
await expect(sw(page, 'Collections')).toHaveAccessibleDescription('collections are not enabled for this account')
@@ -790,7 +783,7 @@ test.describe('Chat tools menu: every group of built-in tools', () => {
await sw(page, 'Run commands').click()
await expect.poll(() => state.puts.at(-1)).toEqual({ off: ['files', 'shell'] })
await expect(sw(page, 'Run commands')).toHaveAttribute('aria-checked', 'false')
- await expect(row(page, 'Run commands').getByTestId('chat-tools-note').first()).toHaveText('Sandbox')
+ await expect(row(page, 'Run commands').getByTestId('chat-tools-note').first()).toHaveText('Served by add-on sandbox')
await shot(page, 'groups-shell-off')
await sw(page, 'Your files').click()
await expect.poll(() => state.puts.at(-1)).toEqual({ off: ['shell'] })
@@ -813,7 +806,7 @@ test.describe('Chat tools menu: every group of built-in tools', () => {
await open(page)
const shell = row(page, 'Run commands')
await expect(shell.getByTestId('chat-tools-note').first()).toHaveText('This machine')
- await expect(shell).toContainText('Commands run directly on this machine, with no isolation')
+ await expect(shell).toContainText('Commands run on the server without isolation.')
await expect(shell.locator('.swl-row__desc--warn')).toBeVisible()
})
diff --git a/core/http/react-ui/e2e/chrome-audit.spec.js b/core/http/react-ui/e2e/chrome-audit.spec.js
index 246f966fc..cc5ad84fa 100644
--- a/core/http/react-ui/e2e/chrome-audit.spec.js
+++ b/core/http/react-ui/e2e/chrome-audit.spec.js
@@ -6,7 +6,7 @@ import { installStudioAddons } from './studio-addon.js'
// whose sprite target is missing or that has no size), and controls left with
// the user agent's own chrome, which is a pale grey button on a dark ground.
const ROUTES = [
- '/app', '/app/chats/all', '/app/models', '/app/studio', '/app/talk',
+ '/app', '/app/chats/all', '/app/models', '/app/addons/studio/studio', '/app/addons/talk/talk',
'/app/agents', '/app/skills', '/app/collections', '/app/triggers', '/app/mcp-connectors',
'/app/models?view=installed', '/app/backends', '/app/activity', '/app/operate',
'/app/settings', '/app/traces', '/app/usage', '/app/nodes', '/app/p2p',
@@ -14,7 +14,7 @@ const ROUTES = [
]
test('no page renders a dead icon or a default-chrome control', async ({ page }) => {
- // Studio and Talk are add-ons; /app/studio and /app/talk open them.
+ // Studio and Talk are add-ons; /app/addons/studio/studio and /app/addons/talk/talk open them.
await installStudioAddons(page)
// One test walks every route, so its budget has to scale with the list rather
// than sit on Playwright's per-test default of 30s. At 25 routes that default
diff --git a/core/http/react-ui/e2e/diarization-profiles.spec.js b/core/http/react-ui/e2e/diarization-profiles.spec.js
index 3adc70fba..71724bc31 100644
--- a/core/http/react-ui/e2e/diarization-profiles.spec.js
+++ b/core/http/react-ui/e2e/diarization-profiles.spec.js
@@ -30,7 +30,7 @@ async function setup(page, permission = true, diarizationPermission = true) {
// Studio and Voice are add-ons. Installed after the catch-all above, so their routes win.
// The remembered-voices link opens the Voice add-on page.
await installStudioAddons(page, { ids: ['studio', 'voice'] })
- await page.goto('/app/diarization')
+ await page.goto('/app/addons/studio/studio/diarization')
if (!diarizationPermission) return
await expect(page.getByRole('button', { name: 'diarizer', exact: true })).toBeVisible()
await page.getByLabel('Recording', { exact: true }).setInputFiles(file)
@@ -174,8 +174,9 @@ test('missing requested profiles is an error and normal defaults remain opt-in',
test('diarization permission gates direct page and Studio tab', async ({ page }) => {
await setup(page, true, false)
- await expect(page).toHaveURL(/\/app$/)
- await page.goto('/app/studio')
+ await expect(page.locator('.studio-tab-active[data-tab="overview"]')).toBeVisible()
+ await expect(page.getByRole('button', { name: 'Diarize', exact: true })).toHaveCount(0)
+ await page.goto('/app/addons/studio/studio')
await expect(page.locator('[data-tab="diarization"]')).toHaveCount(0)
})
@@ -186,7 +187,7 @@ test('Studio exposes diarization and navigation revokes recording URL', async ({
URL.revokeObjectURL = url => { window.revoked.push(url); revoke(url) }
})
await setup(page)
- await page.goto('/app/studio/diarization')
+ await page.goto('/app/addons/studio/studio/diarization')
await expect(page.getByRole('heading', { name: 'Speaker diarization' })).toBeVisible()
await page.getByLabel('Recording', { exact: true }).setInputFiles(file)
await run(page)
diff --git a/core/http/react-ui/e2e/home-console.spec.js b/core/http/react-ui/e2e/home-console.spec.js
index f5e538796..f7496ab8b 100644
--- a/core/http/react-ui/e2e/home-console.spec.js
+++ b/core/http/react-ui/e2e/home-console.spec.js
@@ -33,22 +33,23 @@ test.describe('Home slash menu', () => {
await ready(page)
await page.locator('.home-textarea').fill('/')
const names = await page.locator('.dk-cmd-name').allTextContents()
- // The administrator gets /studio while Studio is not installed: it
- // opens the add-on in the library.
+ // Native Apps opens the generic installed list, never a promoted package.
// Home's own commands first; the commands it shares with a chat follow:
- // /attach, /skill, /skills and one per skill of the server, then /help.
+ // /attach, /skills and any declared skills, then /help.
expect(names.slice(0, 15)).toEqual([
'/model', '/new', '/chat', '/assistant', '/models',
'/gallery', '/installed', '/import', '/stop',
- // An admin without Studio is offered /studio to install it.
- '/agents', '/mcp', '/memory', '/studio', '/settings', '/docs',
+ // No installation advice for a particular provider.
+ '/agents', '/mcp', '/memory', '/apps', '/settings', '/docs',
])
- expect(names.slice(15, 18)).toEqual(['/attach', '/skill', '/skills'])
+ expect(names.slice(15, 18)).toEqual(['/attach', '/skills', '/help'])
+ expect(names).not.toContain('/skill') // No skills are declared by this fixture.
expect(names[names.length - 1]).toBe('/help')
// A chat that does not exist yet has nothing to compact or clear.
expect(names).not.toContain('/compact')
expect(names).not.toContain('/clear')
- await expect(page.getByTestId('home-slash-studioInstall')).toBeVisible()
+ await expect(page.getByTestId('home-slash-apps')).toBeVisible()
+ await expect(page.getByTestId('home-slash-studioInstall')).toHaveCount(0)
await expect(page.getByTestId('home-slash-studio')).toHaveCount(0)
})
@@ -73,8 +74,8 @@ test.describe('Home slash menu', () => {
await installStudioAddons(page, { ids: ['studio'] })
await ready(page)
await page.locator('.home-textarea').fill('/')
- await expect(page.getByTestId('home-slash-studio')).toBeVisible()
- await page.getByTestId('home-slash-studio').click()
+ await expect(page.getByTestId('home-slash-addon:studio:open:alias')).toBeVisible()
+ await page.getByTestId('home-slash-addon:studio:open:alias').click()
await expect(page).toHaveURL(/\/app\/addons\/studio\/studio/)
})
@@ -450,29 +451,26 @@ test.describe('Home first run', () => {
await expect(guide.getByRole('button', { name: /import model/i })).toBeVisible()
})
- test('offers to install the Sandbox, says what happens without it, and can be skipped', async ({ page }) => {
+ test('first run offers native model setup without promoting an uninstalled provider', async ({ page }) => {
await mockFirstRun(page)
await stubAddonRoutes(page)
await mockAddons(page, { apps: [] })
await ready(page)
- const step = page.getByTestId('home-first-run-sandbox')
- await expect(step).toContainText('Run code safely')
- await expect(step).toContainText('commands run directly on this machine')
- await expect(step).toContainText('full network access and no isolation')
- await step.getByRole('button', { name: 'Skip' }).click()
- await expect(step).toHaveCount(0)
- await page.reload()
await expect(page.getByTestId('home-first-run')).toBeVisible()
await expect(page.getByTestId('home-first-run-sandbox')).toHaveCount(0)
+ await expect(page.getByRole('button', { name: 'Install Sandbox' })).toHaveCount(0)
+ await page.reload()
+ await expect(page.getByTestId('home-first-run-sandbox')).toHaveCount(0)
})
- test('the Sandbox step opens the add-on browser', async ({ page }) => {
+ test('the generic apps command opens the add-on browser without an identity filter', async ({ page }) => {
await mockFirstRun(page)
await stubAddonRoutes(page)
await mockAddons(page, { apps: [] })
await ready(page)
- await page.getByTestId('home-first-run-sandbox').getByRole('button', { name: 'Install Sandbox' }).click()
- await expect(page).toHaveURL(/\/app\/addons\?tab=browse&q=sandbox/)
+ await page.locator('.home-textarea').fill('/apps')
+ await page.getByTestId('home-slash-apps').click()
+ await expect(page).toHaveURL(/\/app\/addons$/)
})
test('the Sandbox step is hidden once the Sandbox is installed', async ({ page }) => {
diff --git a/core/http/react-ui/e2e/legacy-redirects.spec.js b/core/http/react-ui/e2e/legacy-redirects.spec.js
index 1a50c2d93..d2afea778 100644
--- a/core/http/react-ui/e2e/legacy-redirects.spec.js
+++ b/core/http/react-ui/e2e/legacy-redirects.spec.js
@@ -1,40 +1,31 @@
import { test, expect } from '@playwright/test'
import { MEMBER, sessionFor } from './addons-fixtures.js'
import { installStudioAddons } from './studio-addon.js'
-import { MIGRATION_ROUTES } from '../src/utils/legacyRoutes.js'
+import { MIGRATION_ROUTES, chatsRedirect } from '../src/utils/legacyRoutes.js'
-// Every moved generator route, with and without a model: 14 old paths. For
-// each, in both flag states, the final location is the tab of the Studio add-on
-// and that generator is drawn (its tab is active), not the overview fallback.
-// A model travels as ?model=, because an add-on path segment refuses "/" and ":".
-const MOVED = MIGRATION_ROUTES.filter(r => r.kind === 'studio-tab')
-const CASES = MOVED.flatMap(r => [
- { from: r.from, to: r.to, tab: r.to.split('/').pop() },
- { from: `${r.from}/my-model`, to: `${r.to}?model=my-model`, tab: r.to.split('/').pop() },
-])
-
-async function setup(page, flag) {
- await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(MEMBER, { addons: true, '3d': true, audio_diarization: true, audio_transform: true }) }))
- await page.route('**/api/features', route => route.fulfill({ json: { addon_navigation: flag } }))
- await page.route('**/api/addons/builtin', route => route.fulfill({ json: { builtin: [] } }))
- await installStudioAddons(page, { ids: ['studio'] })
- await page.route('**/api/addons/pins', route => route.fulfill({ json: [] }))
-}
-
-test('the table covers all 14 old paths', () => {
- expect(CASES).toHaveLength(14)
+// Native migrations remain supported; removed addon aliases must not select
+// a privileged provider, even when the corresponding addon is installed.
+test('the migration table preserves native destinations only', () => {
+ expect(MIGRATION_ROUTES.map(r => r.from)).toEqual([
+ '/app/chat', '/app/chats/agents', '/app/chats/triggers', '/app/chats/skills',
+ '/app/agent-jobs', '/app/agents', '/app/skills', '/app/models', '/app/settings',
+ ])
+ for (const r of MIGRATION_ROUTES) {
+ expect(r.to).not.toContain('/addons/')
+ if (r.kind === 'chats') expect(chatsRedirect(r.from)).toBe(r.to)
+ else expect(r.to).toBe(r.from)
+ }
})
-for (const flag of [true, false]) {
- test.describe(`old generator links, addon_navigation=${flag}`, () => {
- for (const c of CASES) {
- test(`${c.from} opens ${c.to}`, async ({ page }) => {
- await setup(page, flag)
- await page.goto(c.from)
- await expect(page).toHaveURL(new RegExp(`${c.to.replace(/[/?]/g, '\\$&')}$`))
- await expect(page.locator(`.studio-tab-active[data-tab="${c.tab}"]`)).toBeVisible()
- await expect(page.getByTestId('studio-overview')).toHaveCount(0)
- })
- }
- })
+for (const from of ['image', 'video', 'threed', 'tts', 'sound', 'audio-transform', 'diarization']) {
+ for (const suffix of ['', '/my-model']) {
+ test(`removed generator /app/${from}${suffix} is NotFound, not an addon redirect`, async ({ page }) => {
+ await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(MEMBER, { addons: true }) }))
+ await installStudioAddons(page, { ids: ['studio'] })
+ await page.goto(`/app/${from}${suffix}`)
+ await expect(page.getByRole('heading', { name: 'This page does not exist' })).toBeVisible()
+ await expect(page).toHaveURL(new RegExp(`/app/${from}${suffix}$`))
+ await expect(page.getByTestId('studio-overview')).toHaveCount(0)
+ })
+ }
}
diff --git a/core/http/react-ui/e2e/media-history.spec.js b/core/http/react-ui/e2e/media-history.spec.js
index 3a7690567..ee4ebdc11 100644
--- a/core/http/react-ui/e2e/media-history.spec.js
+++ b/core/http/react-ui/e2e/media-history.spec.js
@@ -72,7 +72,7 @@ test.describe('Media History - Image Generation', () => {
test('history entry appears after image generation', async ({ page }) => {
await mockImageGeneration(page, [{ url: '/generated-images/test.png' }])
- await page.goto('/app/studio/images')
+ await page.goto('/app/addons/studio/studio/images')
await expect(page.getByRole('button', { name: 'test-image-model' })).toBeVisible({ timeout: 10_000 })
await page.locator('[data-testid="ws-compose"] textarea').first().fill('a beautiful sunset')
@@ -99,7 +99,7 @@ test.describe('Media History - Image Generation', () => {
})
})
- await page.goto('/app/studio/images')
+ await page.goto('/app/addons/studio/studio/images')
await expect(page.getByRole('button', { name: 'test-image-model' })).toBeVisible({ timeout: 10_000 })
// Generate first image
@@ -124,7 +124,7 @@ test.describe('Media History - Image Generation', () => {
test('history persists across navigation', async ({ page }) => {
await mockImageGeneration(page, [{ url: '/generated-images/persist.png' }])
- await page.goto('/app/studio/images')
+ await page.goto('/app/addons/studio/studio/images')
await expect(page.getByRole('button', { name: 'test-image-model' })).toBeVisible({ timeout: 10_000 })
await page.locator('[data-testid="ws-compose"] textarea').first().fill('persist test')
@@ -136,7 +136,7 @@ test.describe('Media History - Image Generation', () => {
// Navigate away and back
await page.goto('/app')
- await page.goto('/app/studio/images')
+ await page.goto('/app/addons/studio/studio/images')
// Re-register the mock for capabilities after navigation
await expect(page.getByTestId('media-history')).toBeVisible({ timeout: 10_000 })
@@ -147,7 +147,7 @@ test.describe('Media History - Image Generation', () => {
test('delete removes a history entry', async ({ page }) => {
await mockImageGeneration(page, [{ url: '/generated-images/del.png' }])
- await page.goto('/app/studio/images')
+ await page.goto('/app/addons/studio/studio/images')
await expect(page.getByRole('button', { name: 'test-image-model' })).toBeVisible({ timeout: 10_000 })
await page.locator('[data-testid="ws-compose"] textarea').first().fill('delete me')
@@ -174,7 +174,7 @@ test.describe('Media History - Image Generation', () => {
})
})
- await page.goto('/app/studio/images')
+ await page.goto('/app/addons/studio/studio/images')
await expect(page.getByRole('button', { name: 'test-image-model' })).toBeVisible({ timeout: 10_000 })
// Generate two images
@@ -200,7 +200,7 @@ test.describe('Media History - TTS', () => {
test('TTS history entry appears with server URL from Content-Disposition', async ({ page }) => {
await mockTTSGeneration(page, 'tts.wav')
- await page.goto('/app/studio/tts')
+ await page.goto('/app/addons/studio/studio/tts')
await expect(page.getByRole('button', { name: 'test-tts-model' })).toBeVisible({ timeout: 10_000 })
await page.getByPlaceholder('Enter text to synthesize...').fill('hello world')
@@ -226,7 +226,7 @@ test.describe('Media History - Sound Generation', () => {
test('Sound generation history entry appears', async ({ page }) => {
await mockSoundGeneration(page, 'sound.wav')
- await page.goto('/app/studio/sound')
+ await page.goto('/app/addons/studio/studio/sound')
await expect(page.getByRole('button', { name: 'test-sound-model' })).toBeVisible({ timeout: 10_000 })
await page.getByRole('button', { name: 'Prompt expansion model', exact: true }).click()
@@ -247,7 +247,7 @@ test.describe('Media History - Video Generation', () => {
test('Video history entry appears after generation', async ({ page }) => {
await mockVideoGeneration(page, [{ url: '/generated-videos/test.mp4' }])
- await page.goto('/app/studio/video')
+ await page.goto('/app/addons/studio/studio/video')
await expect(page.getByRole('button', { name: 'test-video-model' })).toBeVisible({ timeout: 10_000 })
await page.locator('[data-testid="ws-compose"] textarea').first().fill('a running cat')
@@ -261,7 +261,7 @@ test.describe('Media History - Video Generation', () => {
let requestBody
await mockVideoGeneration(page, [{ url: '/generated-videos/avatar.mp4' }], (body) => { requestBody = body })
- await page.goto('/app/studio/video')
+ await page.goto('/app/addons/studio/studio/video')
await expect(page.getByRole('button', { name: 'test-video-model' })).toBeVisible({ timeout: 10_000 })
await page.getByRole('button', { name: 'Avatar audio' }).click()
await page.getByLabel('Avatar audio', { exact: true }).setInputFiles({
diff --git a/core/http/react-ui/e2e/navigation.spec.js b/core/http/react-ui/e2e/navigation.spec.js
index d26bc248c..3828e7b13 100644
--- a/core/http/react-ui/e2e/navigation.spec.js
+++ b/core/http/react-ui/e2e/navigation.spec.js
@@ -42,7 +42,7 @@ test.describe('Navigation', () => {
await expect(page).toHaveURL(/\/app\/settings/)
await expect(page.locator('.dk-hubtabs [data-hub-tab="settings"]')).toHaveAttribute('aria-current', 'page')
// Studio and Talk are add-ons: the sidebar does not list them; they are in the drawer while installed.
- for (const href of ['/app/studio', '/app/talk']) {
+ for (const href of ['/app/addons/studio/studio', '/app/addons/talk/talk']) {
await expect(page.locator(`.sidebar-nav a.nav-item[href="${href}"]`)).toHaveCount(0)
}
})
diff --git a/core/http/react-ui/e2e/no-edge-rails.spec.js b/core/http/react-ui/e2e/no-edge-rails.spec.js
index 3c54e74c0..f71479ca4 100644
--- a/core/http/react-ui/e2e/no-edge-rails.spec.js
+++ b/core/http/react-ui/e2e/no-edge-rails.spec.js
@@ -9,15 +9,15 @@ import { installStudioAddons } from './studio-addon.js'
// 4. a narrow, tall child pinned to the left edge of its parent.
// Selection and status use a surface step, a check or a dot instead.
const ROUTES = [
- '/app', '/app/chats/all', '/app/models', '/app/models?view=installed', '/app/studio',
- '/app/talk', '/app/agents', '/app/skills', '/app/collections', '/app/triggers',
+ '/app', '/app/chats/all', '/app/models', '/app/models?view=installed', '/app/addons/studio/studio',
+ '/app/addons/talk/talk', '/app/agents', '/app/skills', '/app/collections', '/app/triggers',
'/app/backends', '/app/activity', '/app/operate', '/app/mcp-connectors', '/app/settings',
'/app/traces', '/app/usage', '/app/nodes', '/app/p2p',
'/app/account',
]
test('no element on the main routes carries a left accent rail', async ({ page }) => {
- // Studio and Talk are add-ons; /app/studio and /app/talk open them.
+ // Studio and Talk are add-ons; /app/addons/studio/studio and /app/addons/talk/talk open them.
await installStudioAddons(page)
test.setTimeout(ROUTES.length * 8_000)
const findings = []
diff --git a/core/http/react-ui/e2e/page-render-smoke.spec.js b/core/http/react-ui/e2e/page-render-smoke.spec.js
index 33e449d8d..95447ab48 100644
--- a/core/http/react-ui/e2e/page-render-smoke.spec.js
+++ b/core/http/react-ui/e2e/page-render-smoke.spec.js
@@ -53,8 +53,8 @@ const ADDON_PAGES = [
['/app/addons/talk/talk', 'Talk', '.talk-page'],
['/app/addons/studio/studio', 'Studio', '[data-testid="studio-overview"]'],
['/app/addons/studio/studio/threed', 'Studio 3D', '.ws-title'],
- ['/app/talk', 'Talk (old address)', '.talk-page'],
- ['/app/studio/threed', 'Studio 3D (old address)', '.ws-title'],
+ ['/app/addons/talk/talk', 'Talk (old address)', '.talk-page'],
+ ['/app/addons/studio/studio/threed', 'Studio 3D (old address)', '.ws-title'],
]
test.describe('Add-on page render smoke', () => {
diff --git a/core/http/react-ui/e2e/settings-tools.spec.js b/core/http/react-ui/e2e/settings-tools.spec.js
index c09571106..2d1fc66cf 100644
--- a/core/http/react-ui/e2e/settings-tools.spec.js
+++ b/core/http/react-ui/e2e/settings-tools.spec.js
@@ -31,7 +31,7 @@ test.describe('Settings > Tools', () => {
await expect(page.getByTestId('settings-tools-bash-provider')).toHaveText('Built-in')
await expect(page.getByTestId('settings-tools-bash-warning')).toContainText('reach the internet, your network and the LocalAI API')
await expect(row).toContainText('Not set: the default applies (ask each time).')
- await expect(page.getByTestId('settings-tools-install-sandbox')).toHaveAttribute('href', '/app/addons?tab=browse&q=sandbox')
+ await expect(page.getByTestId('settings-tools-install-sandbox')).toHaveCount(0)
await shot(page, 'settings-tools')
await row.getByRole('radio', { name: /Allow for everyone/ }).check()
await page.getByRole('button', { name: 'Apply' }).click()
diff --git a/core/http/react-ui/e2e/sound-expansion.spec.js b/core/http/react-ui/e2e/sound-expansion.spec.js
index cfbba10f7..a4aceab2c 100644
--- a/core/http/react-ui/e2e/sound-expansion.spec.js
+++ b/core/http/react-ui/e2e/sound-expansion.spec.js
@@ -28,7 +28,7 @@ async function setup(page, models = [audio, chat('writer'), chat('poet')], saved
requests.push(route.request().postDataJSON())
return route.fulfill({ contentType: 'audio/wav', body: silentWav() })
})
- await page.goto('/app/studio/sound')
+ await page.goto('/app/addons/studio/studio/sound')
await expect(page.getByRole('button', { name: 'music', exact: true })).toBeVisible()
return requests
}
diff --git a/core/http/react-ui/e2e/studio-addon.js b/core/http/react-ui/e2e/studio-addon.js
index d50e093f8..683f4c909 100644
--- a/core/http/react-ui/e2e/studio-addon.js
+++ b/core/http/react-ui/e2e/studio-addon.js
@@ -1,8 +1,7 @@
// Studio and Talk are packaged add-ons. A spec that draws one of them installs
// the real built bundle here: the app list names it, and its module and style
// sheet are served from the build output the way the add-on owner serves them.
-// The old /app/studio and /app/talk addresses redirect to the add-on page, so a
-// spec can still open those.
+// Specs open canonical declared pages, never historical standalone redirects.
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -46,7 +45,7 @@ export function packagedApp(id, over = {}) {
return app({
id, name: id, title: ui.title, description: ui.description, icon: undefined, generation: 1, version: manifest.version,
mainPage: `/app/addons/${id}/${ui.mainPage}`, tools: [], toolCount: 0,
- ui: { entry: `${base}/${ui.entry}`, css: ui.css.map(c => `${base}/${c}`), sdk: ui.sdk, pages: ui.pages, navigation: ui.navigation },
+ ui: { entry: `${base}/${ui.entry}`, css: ui.css.map(c => `${base}/${c}`), sdk: ui.sdk, pages: ui.pages, navigation: ui.navigation, modelActions: ui.modelActions, homeCommands: ui.homeCommands?.map(c => ({ ...c, page: c.page || ui.mainPage, label: c.label || ui.title, icon: c.icon || 'puzzle' })) },
...over,
})
}
diff --git a/core/http/react-ui/e2e/studio-addons.spec.js b/core/http/react-ui/e2e/studio-addons.spec.js
index 3e56c0424..e0e3deae0 100644
--- a/core/http/react-ui/e2e/studio-addons.spec.js
+++ b/core/http/react-ui/e2e/studio-addons.spec.js
@@ -5,7 +5,7 @@ import { mockStudio } from './studio-fixtures.js'
import { fakeRealtime } from './chat-fixtures.js'
// Studio and Talk are packaged add-ons. These specs run the real built bundles
-// inside the add-on shell: the pages draw, the old addresses open them, the
+// inside the add-on shell: the pages draw, canonical addresses open them, the
// links they hold reach the core pages, and nothing is left when they are not
// installed.
@@ -61,14 +61,14 @@ test.describe('Studio add-on', () => {
test('a model id with a slash and a colon travels as ?model= and reaches the page', async ({ page }) => {
await mockStudio(page)
- await page.goto('/app/studio/images/sd-1.5-lcm?size=768x768')
+ await page.goto('/app/addons/studio/studio/images?size=768x768&model=sd-1.5-lcm')
await expect(page).toHaveURL(/\/app\/addons\/studio\/studio\/images\?size=768x768&model=sd-1\.5-lcm$/)
await expect(page.locator('.studio-tab-active[data-tab="images"]')).toBeVisible({ timeout: 15_000 })
})
- test('the old Studio addresses say so plainly when the add-on is not installed', async ({ page }) => {
+ test('the canonical Studio addresses say so plainly when the add-on is not installed', async ({ page }) => {
await stubAddonRoutes(page)
- for (const address of ['/app/studio', '/app/studio/tts', '/app/image', '/app/talk']) {
+ for (const address of ['/app/addons/studio/studio', '/app/addons/studio/studio/tts', '/app/addons/studio/studio/images', '/app/addons/talk/talk']) {
await page.goto(address)
await expect(page.getByTestId('addon-unavailable')).toBeVisible({ timeout: 15_000 })
await expect(page).toHaveURL(/\/app\/addons\/(studio|talk)\//)
@@ -119,7 +119,7 @@ test.describe('Without the add-ons', () => {
test('the Add-ons drawer does not list Studio or Talk, and the sidebar has no Create group', async ({ page }) => {
await page.goto('/app/addons')
await expect(page.getByTestId('addon-tile').filter({ hasText: /Studio|Talk/ })).toHaveCount(0)
- await expect(page.locator('.sidebar-nav a[href="/app/studio"], .sidebar-nav a[href="/app/talk"]')).toHaveCount(0)
+ await expect(page.locator('.sidebar-nav a[href="/app/addons/studio/studio"], .sidebar-nav a[href="/app/addons/talk/talk"]')).toHaveCount(0)
await expect(page.locator('.sidebar-section-title', { hasText: 'Create' })).toHaveCount(0)
})
diff --git a/core/http/react-ui/e2e/studio-composer.spec.js b/core/http/react-ui/e2e/studio-composer.spec.js
index e14c92a81..f1b3f45d1 100644
--- a/core/http/react-ui/e2e/studio-composer.spec.js
+++ b/core/http/react-ui/e2e/studio-composer.spec.js
@@ -22,7 +22,7 @@ const generate = (page) => page.locator('[data-testid="studio-generate"]')
test.describe('Studio composer: choosing a type', () => {
test('opens on Images with a prompt box, starters and the model that will run', async ({ page }) => {
await mockStudio(page)
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
await expect(page.getByRole('heading', { name: 'What do you want to make?' })).toBeVisible()
await expect(composer(page)).toHaveAttribute('data-type', 'images')
await expect(prompt(page)).toHaveAttribute('placeholder', /Describe the image/)
@@ -32,7 +32,7 @@ test.describe('Studio composer: choosing a type', () => {
test('has no chip row: the tabs are the modes, and the composer names the one it will open', async ({ page }) => {
await mockStudio(page)
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
await expect(page.locator('.studio-types')).toHaveCount(0)
await expect(composer(page)).toHaveAttribute('data-types', 'images video threed tts sound transform diarization')
await expect(page.getByTestId('studio-current-type')).toHaveText('Images')
@@ -42,7 +42,7 @@ test.describe('Studio composer: choosing a type', () => {
test('a starter fills the prompt and the starters go away', async ({ page }) => {
await mockStudio(page)
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
await page.locator('[data-testid="studio-starters"] button', { hasText: 'Macro fern' }).click()
await expect(prompt(page)).toHaveValue(/dew-covered fern/)
await expect(page.locator('[data-testid="studio-starters"]')).toHaveCount(0)
@@ -50,7 +50,7 @@ test.describe('Studio composer: choosing a type', () => {
test('size and count appear only where the workspace has them', async ({ page }) => {
await mockStudio(page)
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
await expect(page.locator('[data-testid="studio-size"]')).toBeVisible()
await expect(page.locator('[data-testid="studio-count"]')).toBeVisible()
await pickType(page, 'video')
@@ -63,7 +63,7 @@ test.describe('Studio composer: choosing a type', () => {
test('types that start from a file say so instead of offering a prompt box', async ({ page }) => {
await mockStudio(page)
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
for (const key of ['threed', 'transform', 'diarization']) {
await pickType(page, key)
await expect(prompt(page)).toHaveCount(0)
@@ -76,7 +76,7 @@ test.describe('Studio composer: choosing a type', () => {
test.describe('Studio composer: suggesting a type', () => {
test('typing a video-sounding sentence suggests Video and offers to switch', async ({ page }) => {
await mockStudio(page)
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
await prompt(page).fill('Slow dolly through a misty pine forest, drone shot')
await expect(hint(page)).toContainText('Sounds like Video')
await page.locator('[data-testid="studio-switch"]').click()
@@ -87,7 +87,7 @@ test.describe('Studio composer: suggesting a type', () => {
test('the suggestion never switches the type on its own', async ({ page }) => {
await mockStudio(page)
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
await prompt(page).fill('Read this aloud in a warm voice')
await expect(hint(page)).toContainText('Sounds like TTS')
await expect(composer(page)).toHaveAttribute('data-type', 'images')
@@ -95,7 +95,7 @@ test.describe('Studio composer: suggesting a type', () => {
test('Alt+Enter takes the suggestion', async ({ page }) => {
await mockStudio(page)
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
await prompt(page).fill('Lo-fi piano with rain on glass')
await expect(hint(page)).toContainText('Sounds like Sound')
await page.keyboard.press('Alt+Enter')
@@ -104,7 +104,7 @@ test.describe('Studio composer: suggesting a type', () => {
test('a short or ordinary sentence suggests nothing', async ({ page }) => {
await mockStudio(page)
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
await prompt(page).fill('hello')
await expect(hint(page)).toHaveText('')
await prompt(page).fill('My cat sleeping on the sofa')
@@ -113,14 +113,14 @@ test.describe('Studio composer: suggesting a type', () => {
test('no suggestion when the sentence already fits the chosen type', async ({ page }) => {
await mockStudio(page)
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
await prompt(page).fill('A poster of a lighthouse at dusk')
await expect(hint(page)).toHaveText('')
})
test('a suggested type with no model says so and offers to show what it needs', async ({ page }) => {
await mockStudio(page, { types: ['images'] })
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
await prompt(page).fill('Slow dolly through a misty pine forest')
await expect(hint(page)).toContainText('no Video model is installed yet')
await page.locator('[data-testid="studio-switch"]').click()
@@ -133,7 +133,7 @@ test.describe('Studio composer: suggesting a type', () => {
test.describe('Studio composer: keys', () => {
test('/ focuses the prompt from anywhere on the page', async ({ page }) => {
await mockStudio(page)
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
// The add-on shell titles the page; click that title, away from every field.
await page.locator('h1.page-title').first().click()
await page.keyboard.press('/')
@@ -145,7 +145,7 @@ test.describe('Studio composer: keys', () => {
test('Alt+1 to Alt+7 pick a type', async ({ page }) => {
await mockStudio(page)
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
// The key handler is armed when the composer mounts: wait for it before the
// first press, and retry a press that lands during a re-render.
await expect(prompt(page)).toBeVisible()
@@ -160,7 +160,7 @@ test.describe('Studio composer: keys', () => {
test('Ctrl+Enter generates, and does nothing with an empty prompt', async ({ page }) => {
await mockStudio(page)
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
await prompt(page).focus()
await page.keyboard.press('Control+Enter')
await expect(page).toHaveURL(/\/app\/addons\/studio\/studio$/)
@@ -173,7 +173,7 @@ test.describe('Studio composer: keys', () => {
test.describe('Studio composer: Generate', () => {
test('is disabled with a reason until there is a prompt', async ({ page }) => {
await mockStudio(page)
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
await expect(generate(page)).toBeDisabled()
await expect(page.locator('[data-testid="studio-why"]')).toHaveText('Write a prompt first')
await prompt(page).fill('a brass orrery')
@@ -183,7 +183,7 @@ test.describe('Studio composer: Generate', () => {
test('is disabled with the install reason when the type has no model', async ({ page }) => {
await mockStudio(page, { types: ['images'] })
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
await pickType(page, 'video')
await prompt(page).fill('waves on black sand')
await expect(generate(page)).toBeDisabled()
@@ -192,13 +192,13 @@ test.describe('Studio composer: Generate', () => {
test('the model select offers the installed models of the chosen type', async ({ page }) => {
await mockStudio(page)
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
await expect(page.locator('[data-testid="studio-model"] option')).toHaveText(INSTALLED.images)
})
test('shows how the model fits this machine when the gallery can say', async ({ page }) => {
await mockStudio(page)
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
await expect(page.locator('[data-testid="studio-fit"]')).toContainText('Needs 13.0 GB')
await expect(page.locator('[data-testid="studio-fit"]')).toHaveAttribute('data-fit', 'fits')
})
@@ -206,7 +206,7 @@ test.describe('Studio composer: Generate', () => {
test('shows no fit at all when nothing is known about the model', async ({ page }) => {
await mockStudio(page)
await page.route(/\/api\/models\/estimate\//, route => route.fulfill({ json: {} }))
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
await expect(page.locator('[data-testid="studio-model"]')).toHaveValue('flux.1-schnell')
await expect(page.locator('[data-testid="studio-fit"]')).toHaveCount(0)
})
@@ -217,7 +217,7 @@ test.describe('Studio composer: Generate', () => {
test.describe('Studio composer: hand-off to the workspaces', () => {
test('Images opens with the prompt, model, size and count filled in', async ({ page }) => {
await mockStudio(page)
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
await prompt(page).fill('A brass orrery on a walnut desk')
await page.locator('[data-testid="studio-model"]').selectOption('sd-1.5-lcm')
await page.locator('[data-testid="studio-size"]').selectOption('768x768')
@@ -234,7 +234,7 @@ test.describe('Studio composer: hand-off to the workspaces', () => {
test('Video opens with the prompt, model and size', async ({ page }) => {
await mockStudio(page)
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
await pickType(page, 'video')
await prompt(page).fill('Waves rolling onto black sand, drone shot')
await page.locator('[data-testid="studio-size"]').selectOption('1280x720')
@@ -247,7 +247,7 @@ test.describe('Studio composer: hand-off to the workspaces', () => {
test('TTS opens with the words in the text box', async ({ page }) => {
await mockStudio(page)
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
await pickType(page, 'tts')
await prompt(page).fill('Welcome to the harbour tour.')
await generate(page).click()
@@ -258,7 +258,7 @@ test.describe('Studio composer: hand-off to the workspaces', () => {
test('Sound opens with the description in the simple prompt', async ({ page }) => {
await mockStudio(page)
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
await pickType(page, 'sound')
await prompt(page).fill('Wind through pines, no music')
await generate(page).click()
@@ -270,7 +270,7 @@ test.describe('Studio composer: hand-off to the workspaces', () => {
await mockStudio(page)
await stubMedia(page)
await seedHistory(page, sampleHistory())
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
await pickType(page, 'threed')
await pickSource(page, /ceramic bowls/)
await generate(page).click()
@@ -286,9 +286,9 @@ test.describe('Studio composer: hand-off to the workspaces', () => {
await mockStudio(page)
await stubMedia(page)
await seedHistory(page, sampleHistory())
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
for (const [key, edge] of [['transform', 'transform'], ['diarization', 'diarize']]) {
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
await pickType(page, key)
await pickSource(page, /Welcome to the harbour tour/)
await generate(page).click()
@@ -303,7 +303,7 @@ test.describe('Studio composer: hand-off to the workspaces', () => {
await mockStudio(page)
await page.route('**/generated-audio/**', route => route.fulfill({ status: 404, body: '' }))
await seedHistory(page, sampleHistory())
- await page.goto('/app/studio/diarization?from=tts-welcome&edge=diarize')
+ await page.goto('/app/addons/studio/studio/diarization?from=tts-welcome&edge=diarize')
const note = page.locator('[data-testid="studio-handoff"]')
await expect(note).toHaveAttribute('data-status', 'error')
await expect(note).toContainText('could not be loaded')
@@ -312,14 +312,14 @@ test.describe('Studio composer: hand-off to the workspaces', () => {
test('a source that is not in this browser is reported plainly', async ({ page }) => {
await mockStudio(page)
- await page.goto('/app/studio/video?from=gone&edge=animate')
+ await page.goto('/app/addons/studio/studio/video?from=gone&edge=animate')
await expect(page.locator('[data-testid="studio-handoff"]')).toHaveAttribute('data-status', 'missing')
await expect(page.locator('[data-testid="ws-compose"] textarea').first()).toBeVisible()
})
test('a workspace opened by hand has no hand-off note and an empty form', async ({ page }) => {
await mockStudio(page)
- await page.goto('/app/studio/images')
+ await page.goto('/app/addons/studio/studio/images')
await expect(page.locator('[data-testid="studio-handoff"]')).toHaveCount(0)
await expect(page.locator('[data-testid="ws-compose"] textarea').first()).toHaveValue('')
})
@@ -333,7 +333,7 @@ test.describe('Studio composer: hand-off to the workspaces', () => {
// shows "Loading" until its module has run. That takes about two seconds
// here and over seven on a loaded machine, so the first assertion after
// each load gets a longer budget than the default five seconds.
- await page.goto('/app/studio/video?prompt=Slow%20push-in&from=img-forest&edge=animate&model=wan2.1-t2v-1.3b')
+ await page.goto('/app/addons/studio/studio/video?prompt=Slow%20push-in&from=img-forest&edge=animate&model=wan2.1-t2v-1.3b')
await expect(page.locator('[data-testid="studio-handoff"]')).toHaveAttribute('data-status', 'ready', { timeout: 20_000 })
await page.getByRole('button', { name: /generate/i }).click()
await expect(page.locator('[data-testid="media-history-item"]').first()).toBeVisible()
@@ -342,7 +342,7 @@ test.describe('Studio composer: hand-off to the workspaces', () => {
expect(saved.edge).toBe('animate')
expect(saved.prompt).toBe('Slow push-in')
// And the front page now stacks the forest picture with its clip.
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
await expect(page.locator('[data-testid="work-project"][data-id="img-forest"]')).toHaveAttribute('data-count', '2', { timeout: 20_000 })
})
@@ -350,7 +350,7 @@ test.describe('Studio composer: hand-off to the workspaces', () => {
await mockStudio(page)
await page.route('**/v1/images/generations', route => route.fulfill({ json: { data: [{ url: '/generated-images/solo.png' }] } }))
await stubMedia(page)
- await page.goto('/app/studio/images')
+ await page.goto('/app/addons/studio/studio/images')
await page.locator('[data-testid="ws-compose"] textarea').first().fill('a lone picture')
await page.getByRole('button', { name: /generate/i }).click()
await expect.poll(async () => (await readStore(page, 'localai_image_history'))?.length).toBe(1)
@@ -363,7 +363,7 @@ test.describe('Studio composer: hand-off to the workspaces', () => {
await mockStudio(page)
await stubMedia(page)
await page.route('**/v1/images/generations', route => route.fulfill({ json: { data: [{ url: '/generated-images/long.png' }] } }))
- await page.goto('/app/studio/images')
+ await page.goto('/app/addons/studio/studio/images')
await page.locator('[data-testid="ws-compose"] textarea').first().fill('x'.repeat(5000))
await page.getByRole('button', { name: /generate/i }).click()
await expect.poll(async () => (await readStore(page, 'localai_image_history'))?.length).toBe(1)
@@ -375,7 +375,7 @@ test.describe('Studio composer: hand-off to the workspaces', () => {
await page.route('**/v1/audio/diarization', route => route.fulfill({
json: { segments: [{ speaker: 'SPEAKER_00', label: 0, start: 0, end: 4 }, { speaker: 'SPEAKER_01', label: 1, start: 4, end: 9 }], speakers: [{ label: 0, id: 'SPEAKER_00' }, { label: 1, id: 'SPEAKER_01' }] },
}))
- await page.goto('/app/studio/diarization')
+ await page.goto('/app/addons/studio/studio/diarization')
await page.locator('#diarization-file').setInputFiles({ name: 'meeting.wav', mimeType: 'audio/wav', buffer: Buffer.alloc(64) })
await page.getByRole('button', { name: 'Diarize' }).click()
await expect.poll(async () => (await readStore(page, 'localai_diarization_history'))?.length).toBe(1)
@@ -383,7 +383,7 @@ test.describe('Studio composer: hand-off to the workspaces', () => {
expect(saved.prompt).toBe('meeting.wav')
expect(saved.params).toEqual({ speakers: 2, seconds: 9 })
expect(saved.results).toEqual([])
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
await expect(page.locator('[data-testid="work-tile"][data-type="diarization"]')).toContainText('meeting.wav')
})
})
@@ -391,7 +391,7 @@ test.describe('Studio composer: hand-off to the workspaces', () => {
test.describe('Studio composer: a model that is missing', () => {
test('the missing type has a hollow tab dot and opens its note only when picked', async ({ page }) => {
await mockStudio(page, { types: ['images', 'tts'] })
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
await expect(tabDot(page, 'video', 'off')).toBeVisible()
await expect(tabDot(page, 'images', 'on')).toBeVisible()
await expect(page.locator('[data-testid="studio-install-note"]')).toHaveCount(0)
@@ -413,7 +413,7 @@ test.describe('Studio composer: a model that is missing', () => {
test('Install asks the gallery for that model, keeps the typed words, and the note goes when the model arrives', async ({ page }) => {
const state = await mockStudio(page, { types: ['images'], installOnPost: true })
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
await pickType(page, 'video')
await prompt(page).fill('Waves rolling onto black sand, drone shot')
await page.locator('[data-testid="studio-install"]').click()
@@ -429,7 +429,7 @@ test.describe('Studio composer: a model that is missing', () => {
test('shows the progress of the install from the operations list', async ({ page }) => {
await mockStudio(page, { types: ['images'], operations: [{ id: 'wan2.1-t2v-1.3b', name: 'wan2.1-t2v-1.3b', jobID: 'j1', progress: 42, isBackend: false, isDeletion: false, taskType: 'installation' }] })
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
await pickType(page, 'video')
const note = page.locator('[data-testid="studio-install-note"]')
await expect(note).toContainText('Installing wan2.1-t2v-1.3b 42%')
@@ -439,7 +439,7 @@ test.describe('Studio composer: a model that is missing', () => {
test('a failed install says why, in words, and offers the button again', async ({ page }) => {
await mockStudio(page, { types: ['images'], installStatus: 500 })
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
await pickType(page, 'video')
await page.locator('[data-testid="studio-install"]').click()
await expect(page.getByRole('alert')).toContainText('The install did not start')
@@ -449,7 +449,7 @@ test.describe('Studio composer: a model that is missing', () => {
test('when the gallery lists nothing for a type it says so and points to the models page', async ({ page }) => {
await mockStudio(page, { types: ['images'] })
await page.route(/\/api\/models(\?.*)?$/, route => route.fulfill({ json: { models: [], total_pages: 1 } }))
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
await pickType(page, 'video')
const note = page.locator('[data-testid="studio-install-note"]')
await expect(note).toContainText('The gallery lists no Video model')
@@ -459,7 +459,7 @@ test.describe('Studio composer: a model that is missing', () => {
test('when the gallery cannot be reached it says so', async ({ page }) => {
await mockStudio(page, { types: ['images'] })
await page.route(/\/api\/models(\?.*)?$/, route => route.fulfill({ status: 500, json: { error: 'down' } }))
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
await pickType(page, 'video')
await expect(page.locator('[data-testid="studio-install-note"]')).toContainText('could not be reached')
})
@@ -467,7 +467,7 @@ test.describe('Studio composer: a model that is missing', () => {
test('a download size the server does not know is not invented', async ({ page }) => {
await mockStudio(page, { types: ['images'] })
await page.route(/\/api\/models\/estimate\//, route => route.fulfill({ json: {} }))
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
await pickType(page, 'video')
const note = page.locator('[data-testid="studio-install-note"]')
await expect(note).toContainText('has no download size listed')
@@ -476,7 +476,7 @@ test.describe('Studio composer: a model that is missing', () => {
test('a new user with no models at all gets one honest path, not a wall', async ({ page }) => {
await mockStudio(page, { types: [] })
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
await expect(page.locator('.studio-tab__dot--off')).toHaveCount(7)
await expect(page.locator('[data-testid="studio-first-run"]')).toContainText('Nothing is installed yet')
await expect(page.locator('[data-testid="studio-install-note"]')).toContainText('No Images model installed')
@@ -486,7 +486,7 @@ test.describe('Studio composer: a model that is missing', () => {
test('a machine with one model has that tab filled and the rest hollow', async ({ page }) => {
await mockStudio(page, { types: ['tts'] })
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
await expect(page.locator('.studio-tab__dot--off')).toHaveCount(6)
// It opens on the first type that works, not on one that cannot.
await expect(composer(page)).toHaveAttribute('data-type', 'tts')
@@ -495,7 +495,7 @@ test.describe('Studio composer: a model that is missing', () => {
test('when the installed models cannot be read it says so and offers one action', async ({ page }) => {
const state = await mockStudio(page, { capabilitiesStatus: 500 })
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
const error = page.locator('[data-testid="studio-models-error"]')
await expect(error).toContainText('could not read the installed models')
await expect(error.getByRole('button')).toHaveCount(1)
@@ -511,7 +511,7 @@ test.describe('Studio composer: layout', () => {
test('on a phone the composer fits the width and Generate stays reachable', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockStudio(page)
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
await prompt(page).fill('a brass orrery')
await expect(generate(page)).toBeVisible()
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
@@ -527,7 +527,7 @@ test.describe('Studio composer: layout', () => {
await new Promise(r => setTimeout(r, 3_000))
route.fulfill({ json: { data: [] } })
})
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
await expect(page.locator('[data-testid="studio-composer-loading"]')).toBeVisible()
await expect(page.locator('[data-testid="studio-first-run"]')).toHaveCount(0)
await expect(page.locator('[data-testid="studio-composer"]')).toBeVisible()
@@ -538,7 +538,7 @@ test.describe('Studio composer: layout', () => {
await mockStudio(page)
await stubMedia(page)
await seedHistory(page, sampleHistory())
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
const name = await page.locator('[data-testid="work-tile"]').first().evaluate(el => getComputedStyle(el).animationName)
expect(name).toBe('none')
})
@@ -549,7 +549,7 @@ test.describe('Studio composer: draft survives', () => {
await mockStudio(page)
await stubMedia(page)
await seedHistory(page, sampleHistory())
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
await prompt(page).fill('half a thought')
await page.locator('[data-testid="work-tile"][data-id="img-forest"]').getByRole('button').first().click()
await expect(page.locator('[data-testid="studio-lineage"]')).toBeVisible()
@@ -560,7 +560,7 @@ test.describe('Studio composer: draft survives', () => {
test('3D history written by the 3D workspace shows on the front page', async ({ page }) => {
await mockStudio(page)
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
await seedThreeD(page, [{ id: 'm1', createdAt: Date.now() - 1000, name: 'vase.glb', label: 'Ribbed vase', model: 'trellis-image-to-3d', outputType: 'mesh', operation: 'generate' }])
await page.reload()
const tile = page.locator('[data-testid="work-tile"][data-type="threed"]')
diff --git a/core/http/react-ui/e2e/studio-lineage.spec.js b/core/http/react-ui/e2e/studio-lineage.spec.js
index 6c82fb494..c0838085f 100644
--- a/core/http/react-ui/e2e/studio-lineage.spec.js
+++ b/core/http/react-ui/e2e/studio-lineage.spec.js
@@ -11,7 +11,7 @@ async function open(page, work, { types } = {}) {
await mockStudio(page, { types })
await stubMedia(page)
await seedHistory(page, sampleHistory())
- await page.goto(`/app/studio?work=${work}`)
+ await page.goto(`/app/addons/studio/studio?work=${work}`)
await expect(page.locator('[data-testid="studio-lineage"]')).toBeVisible()
}
@@ -29,7 +29,7 @@ test.describe('Lineage view', () => {
await mockStudio(page)
await stubMedia(page)
await seedHistory(page, sampleHistory())
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
await page.locator('[data-testid="work-project"] button').first().click()
await expect(page).toHaveURL(/\/app\/addons\/studio\/studio\?work=vid-push/)
await expect(node(page, 'vid-push')).toHaveAttribute('data-selected', 'true')
@@ -204,10 +204,10 @@ test.describe('Lineage view: what a page cannot take yet', () => {
test('a 3D object cannot be repeated or branched, and the dock says so', async ({ page }) => {
await mockStudio(page)
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
const { seedThreeD } = await import('./studio-fixtures.js')
await seedThreeD(page, [{ id: 'm1', createdAt: Date.now() - 5000, name: 'vase.glb', label: 'Ribbed vase', model: 'trellis-image-to-3d' }])
- await page.goto('/app/studio?work=m1')
+ await page.goto('/app/addons/studio/studio?work=m1')
await expect(page.locator('[data-testid="studio-take"]')).toBeDisabled()
await expect(page.locator('[data-testid="studio-branch"]')).toBeDisabled()
await expect(page.locator('[data-testid="studio-dock-hint"]')).toContainText('original input was not kept')
@@ -276,7 +276,7 @@ test.describe('Inline upscale', () => {
: { data: [{ url: '/generated-images/upscaled.png', width: 99999, height: 99999 }] } })
})
if (failure === 'result-decode') await page.route('**/generated-images/upscaled.png', route => route.fulfill({ contentType: 'image/png', body: 'broken' }))
- await page.goto('/app/studio?work=img-bowls')
+ await page.goto('/app/addons/studio/studio?work=img-bowls')
await page.getByTestId('studio-dock-toggle').click()
return () => requests
}
diff --git a/core/http/react-ui/e2e/studio-overview.spec.js b/core/http/react-ui/e2e/studio-overview.spec.js
index da0fd2d7c..27ce5e8da 100644
--- a/core/http/react-ui/e2e/studio-overview.spec.js
+++ b/core/http/react-ui/e2e/studio-overview.spec.js
@@ -35,26 +35,26 @@ async function mockCapabilities(page, payload = SOME_MODELS) {
test.describe('Studio overview', () => {
test('Studio opens on the overview rather than dropping into Images', async ({ page }) => {
await mockCapabilities(page)
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
await expect(page.locator(OVERVIEW)).toBeVisible()
})
test('a generator path opens that generator', async ({ page }) => {
await mockCapabilities(page)
- await page.goto('/app/studio/images')
+ await page.goto('/app/addons/studio/studio/images')
await expect(page.locator(OVERVIEW)).toHaveCount(0)
await expect(page.locator('[data-testid="studio-workspace"]')).toBeVisible()
})
test('an unrecognised tab falls back to the overview, not to Images', async ({ page }) => {
await mockCapabilities(page)
- await page.goto('/app/studio/nonsense')
+ await page.goto('/app/addons/studio/studio/nonsense')
await expect(page.locator(OVERVIEW)).toBeVisible()
})
test('the tab strip reports which modalities have a model', async ({ page }) => {
await mockCapabilities(page)
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
// Filled: something installed advertises the capability.
await expect(tabFor(page, 'images').locator('.studio-tab__dot--on')).toBeVisible()
await expect(tabFor(page, 'tts').locator('.studio-tab__dot--on')).toBeVisible()
@@ -65,7 +65,7 @@ test.describe('Studio overview', () => {
test('the tab dots are quiet and the state is spoken, not only drawn', async ({ page }) => {
await mockCapabilities(page)
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
await expect(tabFor(page, 'images')).toContainText('(model installed)')
await expect(tabFor(page, 'video')).toContainText('(no model installed)')
await expect(page.getByRole('button', { name: /^Images \(model installed\)$/ })).toBeVisible()
@@ -88,7 +88,7 @@ test.describe('Studio overview', () => {
test('a modality with no model offers a way to install one', async ({ page }) => {
await mockCapabilities(page)
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
// A hollow dot on the tab, because nothing serves it. Picking it in the
// composer is how you learn what it needs.
await expect(tabFor(page, 'video').locator('.studio-tab__dot--off')).toBeVisible()
@@ -99,7 +99,7 @@ test.describe('Studio overview', () => {
test('a modality with a model names it instead of offering an install', async ({ page }) => {
await mockCapabilities(page)
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
await expect(tabFor(page, 'images').locator('.studio-tab__dot--on')).toBeVisible()
await expect(page.locator('[data-testid="studio-model"]')).toHaveValue('flux.1-schnell')
await expect(page.locator('[data-testid="studio-install-note"]')).toHaveCount(0)
@@ -118,7 +118,7 @@ test.describe('Studio overview', () => {
},
}))
await mockCapabilities(page)
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
await expect(page.locator(OVERVIEW)).toBeVisible()
await expect(tabFor(page, 'threed')).toHaveCount(0)
await expect(composer(page)).not.toHaveAttribute('data-types', /threed/)
@@ -131,7 +131,7 @@ test.describe('Studio overview', () => {
calls += 1
route.fulfill({ json: SOME_MODELS })
})
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
await expect(page.locator(OVERVIEW)).toBeVisible()
await page.waitForTimeout(500)
// useModels() fetches the whole list and filters in the browser, so one
@@ -141,7 +141,7 @@ test.describe('Studio overview', () => {
test('an installation with no models at all still renders every modality', async ({ page }) => {
await mockCapabilities(page, { data: [] })
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
await expect(page.locator(OVERVIEW)).toBeVisible()
await expect(composer(page)).toBeVisible()
await expect(page.locator('.studio-tab__dot--off')).toHaveCount(7)
@@ -157,7 +157,7 @@ test.describe('Studio overview', () => {
{ id: 'i1', createdAt: Date.now(), model: 'flux.1-schnell', prompt: 'a brass orrery', results: [] },
]))
})
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
const tile = page.locator('[data-testid="work-tile"]')
await expect(tile).toHaveCount(1)
await expect(tile).toContainText('flux.1-schnell')
@@ -166,14 +166,14 @@ test.describe('Studio overview', () => {
test('no history means no tiles, and an empty state that says what happens next', async ({ page }) => {
await mockCapabilities(page)
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
await expect(page.locator('[data-testid="work-tile"]')).toHaveCount(0)
await expect(page.locator('[data-testid="studio-work-empty"]')).toContainText('Nothing here yet')
})
test('the overview is reachable back from a generator tab', async ({ page }) => {
await mockCapabilities(page)
- await page.goto('/app/studio/images')
+ await page.goto('/app/addons/studio/studio/images')
await tabFor(page, 'overview').click()
await expect(page.locator(OVERVIEW)).toBeVisible()
})
@@ -182,7 +182,7 @@ test.describe('Studio overview', () => {
// Bookmarks and older docs still use the query form; they must keep working
// and must land on the canonical URL rather than a second spelling of it.
await mockCapabilities(page)
- await page.goto('/app/studio?tab=images')
+ await page.goto('/app/addons/studio/studio?tab=images')
await expect(page).toHaveURL(/\/app\/addons\/studio\/studio\/images$/)
await expect(page.locator('[data-testid="studio-workspace"]')).toBeVisible()
})
diff --git a/core/http/react-ui/e2e/studio-transform.spec.js b/core/http/react-ui/e2e/studio-transform.spec.js
index 26f1f0bf5..095c47687 100644
--- a/core/http/react-ui/e2e/studio-transform.spec.js
+++ b/core/http/react-ui/e2e/studio-transform.spec.js
@@ -5,7 +5,7 @@ test.describe('Studio - Transform', () => {
test.beforeEach(async ({ page }) => { await installStudioAddons(page, { ids: ['studio'] }) })
test('Studio exposes a Transform tab that renders Audio Transform', async ({ page }) => {
- await page.goto('/app/studio/transform')
+ await page.goto('/app/addons/studio/studio/transform')
await expect(page.locator('.studio-tab', { hasText: 'Transform' })).toBeVisible()
await expect(page.locator('h2.ws-title', { hasText: 'Audio Transform' })).toBeVisible({ timeout: 15_000 })
})
diff --git a/core/http/react-ui/e2e/studio-work.spec.js b/core/http/react-ui/e2e/studio-work.spec.js
index 4f1f31cc1..7731db7cb 100644
--- a/core/http/react-ui/e2e/studio-work.spec.js
+++ b/core/http/react-ui/e2e/studio-work.spec.js
@@ -11,7 +11,7 @@ async function open(page, opts = {}) {
await mockStudio(page)
await stubMedia(page)
await seedHistory(page, sampleHistory(), opts)
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
await expect(page.locator('[data-testid="studio-work"]')).toBeVisible()
}
@@ -127,7 +127,7 @@ test.describe('Your work', () => {
test('shows a skeleton while 3D history is read, not an empty state', async ({ page }) => {
await mockStudio(page)
- await page.goto('/app/studio')
+ await page.goto('/app/addons/studio/studio')
await expect(page.locator('[data-testid="studio-work"]')).toBeVisible()
// Once answered, the empty state is the truth.
await expect(page.locator('[data-testid="studio-work-empty"]')).toBeVisible()
diff --git a/core/http/react-ui/e2e/studio-workbench.spec.js b/core/http/react-ui/e2e/studio-workbench.spec.js
index 211ae2240..7362b5783 100644
--- a/core/http/react-ui/e2e/studio-workbench.spec.js
+++ b/core/http/react-ui/e2e/studio-workbench.spec.js
@@ -8,7 +8,7 @@ test.describe('Studio workbench', () => {
test.beforeEach(async ({ page }) => { await installStudioAddons(page, { ids: ['studio'] }) })
test('the compose card is a flat hairline card, not a shadowed one', async ({ page }) => {
- await page.goto('/app/studio/images')
+ await page.goto('/app/addons/studio/studio/images')
const card = page.locator('[data-testid="ws-compose"]')
await expect(card).toBeVisible()
const shadow = await card.evaluate(el => getComputedStyle(el).boxShadow)
@@ -18,7 +18,7 @@ test.describe('Studio workbench', () => {
})
test('fields are labelled in caps', async ({ page }) => {
- await page.goto('/app/studio/images')
+ await page.goto('/app/addons/studio/studio/images')
await page.getByRole('button', { name: /Advanced Settings/ }).click()
const label = page.locator('[data-testid="ws-compose"] .ws-label').first()
await expect(label).toBeVisible()
@@ -28,7 +28,7 @@ test.describe('Studio workbench', () => {
test('no request is shown before one has been made', async ({ page }) => {
// A panel describing a request nobody sent is a tutorial, not a record.
- await page.goto('/app/studio/images')
+ await page.goto('/app/addons/studio/studio/images')
await expect(page.locator('.request-panel')).toHaveCount(0)
})
@@ -38,7 +38,7 @@ test.describe('Studio workbench', () => {
await page.route('**/v1/images/generations', route =>
route.fulfill({ json: { data: [{ url: 'https://example.invalid/a.png' }] } }))
- await page.goto('/app/studio/images')
+ await page.goto('/app/addons/studio/studio/images')
await page.locator('[data-testid="ws-compose"] textarea').first().fill('a brass orrery')
await page.getByRole('button', { name: /generate/i }).click()
diff --git a/core/http/react-ui/e2e/studio-workspace.spec.js b/core/http/react-ui/e2e/studio-workspace.spec.js
index b2055acc6..78889cfd6 100644
--- a/core/http/react-ui/e2e/studio-workspace.spec.js
+++ b/core/http/react-ui/e2e/studio-workspace.spec.js
@@ -22,7 +22,7 @@ async function setup(page, opts = {}) {
test.describe('Workspace frame (Images)', () => {
test('the type tabs switch the workspace', async ({ page }) => {
await setup(page)
- await page.goto('/app/studio/images')
+ await page.goto('/app/addons/studio/studio/images')
await expect(page.locator('[data-testid="studio-workspace"]')).toHaveAttribute('data-type', 'images')
await expect(page.locator('.studio-tab')).toHaveCount(8)
await page.locator('.studio-tab[data-tab="tts"]').click()
@@ -32,7 +32,7 @@ test.describe('Workspace frame (Images)', () => {
test('the compose card has the model chip, the essentials, an estimate and one action', async ({ page }) => {
await setup(page)
- await page.goto('/app/studio/images')
+ await page.goto('/app/addons/studio/studio/images')
// The page title is Studio's own h1 above the tabs; the card heading is a section.
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Studio')
await expect(compose(page).getByRole('heading', { level: 2 })).toContainText('Image')
@@ -50,7 +50,7 @@ test.describe('Workspace frame (Images)', () => {
test('starters fill the prompt and go away once there are words', async ({ page }) => {
await setup(page)
- await page.goto('/app/studio/images')
+ await page.goto('/app/addons/studio/studio/images')
await page.locator('[data-testid="ws-starters"] button').first().click()
await expect(prompt(page)).not.toHaveValue('')
await expect(page.locator('[data-testid="ws-starters"]')).toHaveCount(0)
@@ -58,7 +58,7 @@ test.describe('Workspace frame (Images)', () => {
test('the Advanced fold names what is inside, opens on demand and keeps what is set in its name', async ({ page }) => {
await setup(page)
- await page.goto('/app/studio/images')
+ await page.goto('/app/addons/studio/studio/images')
const bar = compose(page).getByRole('button', { name: /Advanced Settings/ })
await expect(bar).toHaveAttribute('aria-expanded', 'false')
await expect(bar).toContainText('negative prompt, steps, seed')
@@ -74,7 +74,7 @@ test.describe('Workspace frame (Images)', () => {
await setup(page)
let release
await page.route('**/v1/images/generations', async route => { await new Promise(r => { release = r }); await route.fulfill({ json: { data: [{ url: '/generated-images/x.png' }] } }) })
- await page.goto('/app/studio/images')
+ await page.goto('/app/addons/studio/studio/images')
await prompt(page).fill('a lighthouse')
await generate(page).click()
const job = page.locator('[data-testid="ws-job"]')
@@ -95,7 +95,7 @@ test.describe('Workspace frame (Images)', () => {
if (calls === 1) return route.fulfill({ status: 500, json: { error: { message: 'out of memory while sampling' } } })
return route.fulfill({ json: { data: [{ url: '/generated-images/ok.png' }] } })
})
- await page.goto('/app/studio/images')
+ await page.goto('/app/addons/studio/studio/images')
await prompt(page).fill('a lighthouse')
await generate(page).click()
const failed = page.locator('[data-testid="ws-failed"]')
@@ -109,7 +109,7 @@ test.describe('Workspace frame (Images)', () => {
test('with no model for the type the install note shows and Generate says why it cannot run', async ({ page }) => {
await setup(page, { types: ['tts'] })
- await page.goto('/app/studio/images')
+ await page.goto('/app/addons/studio/studio/images')
await expect(page.locator('[data-testid="studio-install-note"]')).toBeVisible()
await prompt(page).fill('a lighthouse')
await expect(generate(page)).toBeDisabled()
@@ -121,7 +121,7 @@ test.describe('Workspace frame (Images)', () => {
test('the strip lists this type from the shared history, filters favourites and toggles them', async ({ page }) => {
await setup(page)
await seedHistory(page, sampleHistory(), { favourites: ['img-forest'] })
- await page.goto('/app/studio/images')
+ await page.goto('/app/addons/studio/studio/images')
await expect(tile(page)).toHaveCount(4)
await page.getByRole('tab', { name: /Favourites/ }).click()
await expect(tile(page)).toHaveCount(1)
@@ -141,7 +141,7 @@ test.describe('Workspace frame (Images)', () => {
test('Use in lists the next steps and opens the destination with the result as its source', async ({ page }) => {
await setup(page)
await seedHistory(page, sampleHistory())
- await page.goto('/app/studio/images')
+ await page.goto('/app/addons/studio/studio/images')
await tile(page).filter({ hasText: 'Misty pine forest' }).locator('.ws-tile__open').click()
await page.locator('[data-testid="ws-use-in"]').click()
await expect(page.locator('[data-testid="use-in-animate"]')).toBeEnabled()
@@ -155,7 +155,7 @@ test.describe('Workspace frame (Images)', () => {
test('a step the destination cannot start from is listed, disabled, with the reason', async ({ page }) => {
await setup(page)
await seedHistory(page, sampleHistory())
- await page.goto('/app/studio/video')
+ await page.goto('/app/addons/studio/studio/video')
await tile(page).locator('.ws-tile__open').click()
await page.locator('[data-testid="ws-use-in"]').click()
const item = page.locator('[data-testid="use-in-soundtrack"]')
@@ -166,7 +166,7 @@ test.describe('Workspace frame (Images)', () => {
test('a type with nothing to send on has Use in disabled with the reason', async ({ page }) => {
await setup(page)
await seedHistory(page, sampleHistory())
- await page.goto('/app/studio/diarization')
+ await page.goto('/app/addons/studio/studio/diarization')
await tile(page).locator('.ws-tile__open').click()
await expect(page.locator('[data-testid="ws-use-in"]')).toBeDisabled()
})
@@ -174,7 +174,7 @@ test.describe('Workspace frame (Images)', () => {
test('Lineage opens the front page lineage view for the result', async ({ page }) => {
await setup(page)
await seedHistory(page, sampleHistory())
- await page.goto('/app/studio/images')
+ await page.goto('/app/addons/studio/studio/images')
await tile(page).filter({ hasText: 'Misty pine forest' }).locator('.ws-tile__open').click()
await page.locator('[data-testid="ws-lineage"]').click()
await expect(page).toHaveURL(/\/app\/addons\/studio\/studio\?work=img-forest/)
@@ -184,7 +184,7 @@ test.describe('Workspace frame (Images)', () => {
test('a result made through a hand-off names where it came from and links to it', async ({ page }) => {
await setup(page)
await seedHistory(page, sampleHistory())
- await page.goto('/app/studio/video')
+ await page.goto('/app/addons/studio/studio/video')
await tile(page).locator('.ws-tile__open').click()
const from = page.locator('[data-testid="ws-from"]')
await expect(from).toContainText('animate')
@@ -199,7 +199,7 @@ test.describe('Workspace frame (Images)', () => {
await seedHistory(page, history)
let body
await page.route('**/v1/images/generations', route => { body = route.request().postDataJSON(); return route.fulfill({ json: { data: [{ url: '/generated-images/again.png' }] } }) })
- await page.goto('/app/studio/images')
+ await page.goto('/app/addons/studio/studio/images')
await tile(page).filter({ hasText: 'Misty pine forest' }).locator('.ws-tile__open').click()
await page.locator('[data-testid="ws-rerun"]').click()
await expect(prompt(page)).toHaveValue(/Misty pine forest/)
@@ -222,7 +222,7 @@ test.describe('Workspace frame (Images)', () => {
test('Re-run with edits is disabled, with the reason, when the input was not kept', async ({ page }) => {
await setup(page)
await seedHistory(page, sampleHistory())
- await page.goto('/app/studio/diarization')
+ await page.goto('/app/addons/studio/studio/diarization')
await tile(page).locator('.ws-tile__open').click()
await expect(page.locator('[data-testid="ws-rerun"]')).toBeDisabled()
await expect(page.locator('[data-testid="ws-rerun"]')).toHaveAttribute('title', /not kept/)
@@ -231,7 +231,7 @@ test.describe('Workspace frame (Images)', () => {
test('deleting and clearing from the strip remove entries', async ({ page }) => {
await setup(page)
await seedHistory(page, sampleHistory())
- await page.goto('/app/studio/images')
+ await page.goto('/app/addons/studio/studio/images')
await expect(tile(page)).toHaveCount(4)
await tile(page).first().hover()
await tile(page).first().getByTestId('media-history-delete').click()
@@ -243,7 +243,7 @@ test.describe('Workspace frame (Images)', () => {
test('the hand-off note and source chip show when the page starts from a result', async ({ page }) => {
await setup(page)
await seedHistory(page, sampleHistory())
- await page.goto('/app/studio/video?prompt=Slow%20push-in&from=img-forest&edge=animate')
+ await page.goto('/app/addons/studio/studio/video?prompt=Slow%20push-in&from=img-forest&edge=animate')
await expect(page.locator('[data-testid="studio-handoff"]')).toHaveAttribute('data-status', 'ready')
await expect(compose(page).locator('.ws-source--filled img')).toBeVisible()
await expect(prompt(page)).toHaveValue('Slow push-in')
@@ -255,7 +255,7 @@ test.describe('Workspace run-throughs', () => {
await setup(page)
let body
await page.route('**/video', route => { if (route.request().method() !== 'POST') return route.fallback(); body = route.request().postDataJSON(); return route.fulfill({ json: { data: [{ url: '/generated-videos/new.mp4' }] } }) })
- await page.goto('/app/studio/video')
+ await page.goto('/app/addons/studio/studio/video')
await prompt(page).fill('Boats drift on calm water')
await page.locator('[data-testid="ws-size"]').selectOption('832x480')
await page.locator('[data-testid="ws-duration"]').fill('3')
@@ -275,7 +275,7 @@ test.describe('Workspace run-throughs', () => {
body = route.request().postDataJSON()
return route.fulfill({ status: 200, headers: { 'Content-Type': 'audio/wav', 'Content-Disposition': 'attachment; filename="line.wav"' }, body: silentWav(2) })
})
- await page.goto('/app/studio/tts')
+ await page.goto('/app/addons/studio/studio/tts')
await prompt(page).fill('Welcome to the harbour.')
await page.locator('[data-testid="ws-voice"]').fill('af_bella')
await generate(page).click()
@@ -293,7 +293,7 @@ test.describe('Workspace run-throughs', () => {
body = route.request().postDataJSON()
return route.fulfill({ status: 200, headers: { 'Content-Type': 'audio/wav', 'Content-Disposition': 'attachment; filename="pads.wav"' }, body: silentWav(2) })
})
- await page.goto('/app/studio/sound')
+ await page.goto('/app/addons/studio/studio/sound')
await page.getByRole('tab', { name: 'Advanced' }).click()
await compose(page).getByLabel('Caption').fill('slow pads')
await compose(page).getByLabel('Lyrics').fill('la la')
@@ -312,7 +312,7 @@ test.describe('Workspace run-throughs', () => {
headers: { 'Content-Type': 'audio/wav', 'Content-Disposition': 'attachment; filename="clean.wav"', 'X-Audio-Input-Url': '/generated-audio/in.wav' },
body: silentWav(2),
}))
- await page.goto('/app/studio/transform')
+ await page.goto('/app/addons/studio/studio/transform')
await expect(page.locator('[data-testid="ws-why"]')).toContainText('Add an audio file first')
await page.locator('input[type="file"]').first().setInputFiles({ name: 'mic.wav', mimeType: 'audio/wav', buffer: silentWav(2) })
await expect(page.locator('[data-testid="ws-view"]')).toContainText('waiting to be transformed')
@@ -326,7 +326,7 @@ test.describe('Workspace run-throughs', () => {
await setup(page)
await page.route('**/3d/generations', route => route.fulfill({ json: { data: [{ url: '/generated-3d/t.glb' }] } }))
await page.route('**/generated-3d/t.glb', route => route.fulfill({ contentType: 'model/gltf-binary', body: tinyGlb() }))
- await page.goto('/app/studio/threed')
+ await page.goto('/app/addons/studio/studio/threed')
await expect(page.locator('[data-testid="ws-why"]')).toContainText('Add a picture first')
await page.locator('#threed-image-file').setInputFiles({ name: 'in.png', mimeType: 'image/png', buffer: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64') })
await generate(page).click()
@@ -344,7 +344,7 @@ test.describe('Workspace run-throughs', () => {
{ id: 1, speaker: 'SPEAKER_01', label: '1', start: 6, end: 10, text: 'Hello.' },
],
} }))
- await page.goto('/app/studio/diarization')
+ await page.goto('/app/addons/studio/studio/diarization')
await page.locator('#diarization-file').setInputFiles({ name: 'call.wav', mimeType: 'audio/wav', buffer: silentWav(2) })
await generate(page).click()
await expect(page.locator('[data-testid="ws-timeline"] .ws-timeline__lane')).toHaveCount(2)
@@ -365,7 +365,7 @@ test.describe('Workspace on a phone', () => {
test(`${type} fits the width and its action stays reachable`, async ({ page }) => {
await setup(page)
await seedHistory(page, sampleHistory())
- await page.goto(`/app/studio/${type}`)
+ await page.goto(`/app/addons/studio/studio/${type}`)
await expect(compose(page)).toBeVisible()
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
expect(overflow).toBeLessThanOrEqual(0)
@@ -378,7 +378,7 @@ test.describe('Workspace on a phone', () => {
test('the result toolbar and the strip fit after a run', async ({ page }) => {
await setup(page)
await seedHistory(page, sampleHistory())
- await page.goto('/app/studio/images')
+ await page.goto('/app/addons/studio/studio/images')
await tile(page).first().locator('.ws-tile__open').click()
await expect(page.locator('[data-testid="ws-result"]')).toBeVisible()
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
@@ -390,7 +390,7 @@ test('with reduced motion the job bar is still, not a moving stripe', async ({ p
await page.emulateMedia({ reducedMotion: 'reduce' })
await setup(page)
await page.route('**/v1/images/generations', () => new Promise(() => {}))
- await page.goto('/app/studio/images')
+ await page.goto('/app/addons/studio/studio/images')
await prompt(page).fill('a lighthouse')
await generate(page).click()
const bar = page.locator('[data-testid="ws-job"] .dk-progress-bar')
diff --git a/core/http/react-ui/e2e/talk.spec.js b/core/http/react-ui/e2e/talk.spec.js
index d051b03ff..603f5c5bb 100644
--- a/core/http/react-ui/e2e/talk.spec.js
+++ b/core/http/react-ui/e2e/talk.spec.js
@@ -11,7 +11,7 @@ const emit = (page, event) => page.evaluate((e) => window.__talk.emit(e), event)
const connected = (page) => page.waitForFunction(() => window.__talk.dc && window.__talk.pc?.connectionState === 'connected')
async function startSession(page) {
- await page.goto('/app/talk')
+ await page.goto('/app/addons/talk/talk')
await expect(page.getByTestId('talk-start')).toBeEnabled()
await page.getByTestId('talk-start').click()
await connected(page)
@@ -21,7 +21,7 @@ async function startSession(page) {
test.describe('Talk states', () => {
test('idle: ready, with one way to start and a note on how the microphone is used', async ({ page }) => {
await fakeRealtime(page)
- await page.goto('/app/talk')
+ await page.goto('/app/addons/talk/talk')
await expect(status(page)).toHaveAttribute('data-state', 'idle')
await expect(status(page).getByRole('heading')).toHaveText('Ready')
await expect(page.getByTestId('talk-start')).toHaveText('Start session')
@@ -38,7 +38,7 @@ test.describe('Talk states', () => {
test('connecting: the call is being set up and the button cannot be pressed twice', async ({ page }) => {
await fakeRealtime(page)
await page.addInitScript(() => { window.__talk.hold = true })
- await page.goto('/app/talk')
+ await page.goto('/app/addons/talk/talk')
await page.getByTestId('talk-start').click()
await expect(status(page)).toHaveAttribute('data-state', 'connecting')
await expect(status(page).getByRole('heading')).toHaveText('Connecting')
@@ -130,7 +130,7 @@ test.describe('Talk states', () => {
test('mic blocked: the browser refused the microphone', async ({ page }) => {
await fakeRealtime(page, { micError: 'NotAllowedError' })
- await page.goto('/app/talk')
+ await page.goto('/app/addons/talk/talk')
await page.getByTestId('talk-start').click()
await expect(status(page)).toHaveAttribute('data-state', 'blocked')
await expect(status(page).getByRole('heading')).toHaveText('Microphone blocked')
@@ -169,7 +169,7 @@ test.describe('Talk states', () => {
test('no pipeline: a card names the next step instead of an orb', async ({ page }) => {
await fakeRealtime(page, { pipelines: false })
- await page.goto('/app/talk')
+ await page.goto('/app/addons/talk/talk')
const card = page.getByTestId('talk-no-pipeline')
await expect(card.getByRole('heading')).toHaveText('Talk needs a pipeline model')
await expect(page.locator('.talk-orb')).toHaveCount(0)
@@ -212,7 +212,7 @@ test.describe('Talk controls', () => {
test('the pipeline can be changed before a session and not during one', async ({ page }) => {
await fakeRealtime(page)
- await page.goto('/app/talk')
+ await page.goto('/app/addons/talk/talk')
await page.getByTestId('home-model-chip').click()
await page.getByRole('option', { name: /voice-it/ }).click()
await expect(page.getByTestId('home-model-chip')).toContainText('voice-it')
@@ -225,7 +225,7 @@ test.describe('Talk controls', () => {
test('diagnostics are offered only during a session and show the audio panel', async ({ page }) => {
await fakeRealtime(page)
- await page.goto('/app/talk')
+ await page.goto('/app/addons/talk/talk')
await expect(page.getByTestId('talk-diag-toggle')).toHaveCount(0)
await page.getByTestId('talk-start').click()
await connected(page)
@@ -239,7 +239,7 @@ test.describe('Talk controls', () => {
test.describe('Talk session settings', () => {
test('the sheet holds voice, language and instructions, and they go out when the session starts', async ({ page }) => {
await fakeRealtime(page)
- await page.goto('/app/talk')
+ await page.goto('/app/addons/talk/talk')
await page.getByTestId('talk-settings-button').click()
const sheet = page.getByTestId('talk-settings')
await expect(sheet).toHaveClass(/dk-sheet/)
@@ -267,7 +267,7 @@ test.describe('Talk session settings', () => {
// onClose. Focus has to stay in the field rather than jump to Close.
test('typing in the sheet keeps focus in the field while the page re-renders', async ({ page }) => {
await fakeRealtime(page)
- await page.goto('/app/talk')
+ await page.goto('/app/addons/talk/talk')
await page.getByTestId('talk-settings-button').click()
const sheet = page.getByTestId('talk-settings')
const instructions = sheet.getByRole('textbox', { name: 'Instructions' })
@@ -293,7 +293,7 @@ test.describe('Talk session settings', () => {
test('Edit pipeline opens the model editor before a session', async ({ page }) => {
await fakeRealtime(page)
- await page.goto('/app/talk')
+ await page.goto('/app/addons/talk/talk')
await page.getByTestId('talk-settings-button').click()
await page.getByTestId('talk-pipeline').getByRole('button', { name: 'Edit pipeline' }).click()
await expect(page).toHaveURL(/\/app\/model-editor\/voice-assistant/)
@@ -316,7 +316,7 @@ test.describe('Talk with a speech-to-speech model', () => {
test('the sheet names one speech-to-speech model, with no slots and no voice detection', async ({ page }) => {
await fakeRealtime(page)
await s2sModel(page)
- await page.goto('/app/talk')
+ await page.goto('/app/addons/talk/talk')
await page.getByTestId('talk-settings-button').click()
const pipeline = page.getByTestId('talk-pipeline')
await expect(pipeline).toContainText('Speech-to-speech model')
@@ -353,7 +353,7 @@ test.describe('Talk with a speech-to-speech model', () => {
test('idle note says to press Done speaking when no voice detection model is set', async ({ page }) => {
await fakeRealtime(page)
await s2sModel(page)
- await page.goto('/app/talk')
+ await page.goto('/app/addons/talk/talk')
await expect(page.getByText('press Done speaking when you finish a turn')).toBeVisible()
await expect(page.getByText('The server notices when you stop talking')).toHaveCount(0)
})
@@ -398,7 +398,7 @@ test.describe('Talk on a phone', () => {
test('the settings sheet rises from the bottom', async ({ page }) => {
await fakeRealtime(page)
- await page.goto('/app/talk')
+ await page.goto('/app/addons/talk/talk')
await page.getByTestId('talk-settings-button').click()
const box = await page.getByTestId('talk-settings').boundingBox()
expect(box.width).toBeLessThanOrEqual(390)
diff --git a/core/http/react-ui/e2e/taste-polish.spec.js b/core/http/react-ui/e2e/taste-polish.spec.js
index 31e95bd59..d99aefb6f 100644
--- a/core/http/react-ui/e2e/taste-polish.spec.js
+++ b/core/http/react-ui/e2e/taste-polish.spec.js
@@ -92,7 +92,7 @@ test('the Studio voice field shows its whole placeholder and stays inside a phon
await stubMedia(page)
for (const width of [1440, 390]) {
await page.setViewportSize({ width, height: 900 })
- await page.goto('/app/studio/tts')
+ await page.goto('/app/addons/studio/studio/tts')
const voice = page.getByTestId('ws-voice')
await expect(voice).toBeVisible({ timeout: 15_000 })
const box = await voice.boundingBox()
diff --git a/core/http/react-ui/e2e/threed-animation.spec.js b/core/http/react-ui/e2e/threed-animation.spec.js
index 8f2ee2b05..1d8f048ee 100644
--- a/core/http/react-ui/e2e/threed-animation.spec.js
+++ b/core/http/react-ui/e2e/threed-animation.spec.js
@@ -48,7 +48,7 @@ test('switches inputs, generates animation, scrubs playback and restores history
request = route.request().postDataJSON()
return route.fulfill({ json: { data: [{ url: '/generated-3d/animation.glb' }] } })
})
- await page.goto('/app/studio/threed')
+ await page.goto('/app/addons/studio/studio/threed')
await page.getByRole('button', { name: 'trellis-test', exact: true }).click()
await page.getByRole('option', { name: 'kimodo-test', exact: true }).click()
await expect(page.locator('#threed-image-file')).toHaveCount(0)
@@ -87,7 +87,7 @@ test('switches inputs, generates animation, scrubs playback and restores history
test('plays in real time across delayed frames, looping, pause and seek', async ({ page }) => {
await page.route('**/3d/animate', route => route.fulfill({ json: { data: [{ url: '/generated-3d/animation.glb' }] } }))
- await page.goto('/app/studio/threed')
+ await page.goto('/app/addons/studio/studio/threed')
await page.getByRole('button', { name: 'trellis-test', exact: true }).click()
await page.getByRole('option', { name: 'kimodo-test', exact: true }).click()
await page.getByLabel('Motion prompt').fill('Walk.')
@@ -136,14 +136,14 @@ test('keeps the 3D Studio category available with only an animation model', asyn
await page.route('**/api/models/capabilities', route => route.fulfill({ json: { data: [
{ id: 'kimodo-test', capabilities: ['FLAG_3D_ANIMATION'], three_d_operations: [animationOperation] },
] } }))
- await page.goto('/app/studio/threed')
+ await page.goto('/app/addons/studio/studio/threed')
await expect(page.getByLabel('Motion prompt')).toBeVisible()
await expect(page.getByRole('button', { name: 'kimodo-test', exact: true })).toBeVisible()
})
test('validates the prompt byte limit before submitting', async ({ page }) => {
// The model query is now read as a hand-off, so pick the model in the page as a person would.
- await page.goto('/app/studio/threed')
+ await page.goto('/app/addons/studio/studio/threed')
await page.getByRole('button', { name: 'trellis-test', exact: true }).click()
await page.getByRole('option', { name: 'kimodo-test', exact: true }).click()
await page.getByLabel('Motion prompt').fill('🦊'.repeat(1025))
@@ -165,7 +165,7 @@ test('renders media conditioning and enumerated parameters from model capabiliti
request = route.request().postDataJSON()
return route.fulfill({ status: 400, json: { error: { message: 'Fixture stops after request validation' } } })
})
- await page.goto('/app/studio/threed')
+ await page.goto('/app/addons/studio/studio/threed')
await page.getByLabel('Motion prompt').fill('Wave.')
await page.getByLabel('Source mesh').setInputFiles({ name: 'character.glb', mimeType: 'model/gltf-binary', buffer: animationGlb() })
await page.getByLabel('Motion mode').selectOption('once')
@@ -179,7 +179,7 @@ test('renders media conditioning and enumerated parameters from model capabiliti
test('reports unsupported skeleton transforms without displaying a stale pose', async ({ page }) => {
await page.route('**/generated-3d/animation.glb', route => route.fulfill({ contentType: 'model/gltf-binary', body: animationGlb(document => { document.nodes[1].translation = [0, 1] }) }))
await page.route('**/3d/animate', route => route.fulfill({ json: { data: [{ url: '/generated-3d/animation.glb' }] } }))
- await page.goto('/app/studio/threed')
+ await page.goto('/app/addons/studio/studio/threed')
await page.getByRole('button', { name: 'trellis-test', exact: true }).click()
await page.getByRole('option', { name: 'kimodo-test', exact: true }).click()
await page.getByLabel('Motion prompt').fill('Walk.')
diff --git a/core/http/react-ui/e2e/threed-gen.spec.js b/core/http/react-ui/e2e/threed-gen.spec.js
index c5e4408f3..0ba1f25e4 100644
--- a/core/http/react-ui/e2e/threed-gen.spec.js
+++ b/core/http/react-ui/e2e/threed-gen.spec.js
@@ -117,7 +117,7 @@ function mockGlbDownload(page) {
}
async function generateOnce(page) {
- await page.goto('/app/3d')
+ await page.goto('/app/addons/studio/studio/threed')
await expect(page.getByRole('button', { name: 'trellis-test-model' })).toBeVisible({ timeout: 10_000 })
await page.locator('#threed-image-file').setInputFiles({
name: 'input.png',
@@ -213,7 +213,7 @@ test.describe('3D generation', () => {
let requestBody = null
await mockGeneration(page, (body) => { requestBody = body })
- await page.goto('/app/studio/threed')
+ await page.goto('/app/addons/studio/studio/threed')
await expect(page.getByRole('button', { name: 'trellis-test-model' })).toBeVisible({ timeout: 10_000 })
await pasteImage(page)
await page.locator('button[type="submit"]').click()
@@ -232,7 +232,7 @@ test.describe('3D generation', () => {
let requestBody = null
await mockGeneration(page, (body) => { requestBody = body })
- await page.goto('/app/3d')
+ await page.goto('/app/addons/studio/studio/threed')
await expect(page.getByRole('button', { name: 'trellis-test-model' })).toBeVisible({ timeout: 10_000 })
await page.locator('#threed-image-file').setInputFiles({ name: 'input.png', mimeType: 'image/png', buffer: TINY_PNG })
@@ -291,7 +291,7 @@ test.describe('3D generation', () => {
// IndexedDB persists within the browser context — navigate away and back.
await page.goto('/app')
- await page.goto('/app/3d')
+ await page.goto('/app/addons/studio/studio/threed')
await expect(page.getByTestId('media-history-item')).toHaveCount(1, { timeout: 15_000 })
// Selecting the entry loads the stored Blob back into the viewer.
@@ -302,7 +302,7 @@ test.describe('3D generation', () => {
test('new history is visible on the Studio overview without a reload', async ({ page }) => {
await mockGeneration(page)
- await page.goto('/app/studio/threed')
+ await page.goto('/app/addons/studio/studio/threed')
await expect(page.getByRole('button', { name: 'trellis-test-model' })).toBeVisible({ timeout: 10_000 })
await page.locator('#threed-image-file').setInputFiles({ name: 'input.png', mimeType: 'image/png', buffer: TINY_PNG })
await page.locator('button[type="submit"]').click()
@@ -337,7 +337,7 @@ test.describe('3D generation', () => {
test('rejects oversized conditioning images before making an API request', async ({ page }, testInfo) => {
let requested = false
await mockGeneration(page, () => { requested = true })
- await page.goto('/app/3d')
+ await page.goto('/app/addons/studio/studio/threed')
await expect(page.getByRole('button', { name: 'trellis-test-model' })).toBeVisible({ timeout: 10_000 })
// A file on disk, not an in-memory buffer: Playwright sends a buffer to
@@ -365,14 +365,15 @@ test.describe('3D generation', () => {
})
})
- await page.goto('/app/studio/threed')
+ await page.goto('/app/addons/studio/studio/threed')
await expect(page.getByRole('button', { name: '3D', exact: true })).toHaveCount(0)
// Falls back to the overview rather than Images. Landing on Images was
// never a decision, only the first entry in the tab array.
await expect(page.locator('.studio-tab[data-tab="overview"]')).toHaveClass(/studio-tab-active/)
- await page.goto('/app/3d')
- await expect(page).toHaveURL(/\/app\/?$/)
+ await page.goto('/app/addons/studio/studio/threed')
+ await expect(page.locator('.studio-tab-active[data-tab="overview"]')).toBeVisible()
+ await expect(page.locator('[data-tab="threed"]')).toHaveCount(0)
})
test.describe('touch input', () => {
diff --git a/core/http/react-ui/eslint.config.js b/core/http/react-ui/eslint.config.js
index 7f2d10f4d..639138bea 100644
--- a/core/http/react-ui/eslint.config.js
+++ b/core/http/react-ui/eslint.config.js
@@ -4,6 +4,7 @@ import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
export default [
+ { files: ['src/components/chat/ChatComposer.jsx'], rules: { 'no-undef': 'error' } },
{ ignores: ['dist'] },
{
files: ['**/*.{js,jsx}'],
diff --git a/core/http/react-ui/i18n-baseline.json b/core/http/react-ui/i18n-baseline.json
index ce10f8d8e..0004fa409 100644
--- a/core/http/react-ui/i18n-baseline.json
+++ b/core/http/react-ui/i18n-baseline.json
@@ -6,7 +6,7 @@
"de/chats": 272,
"de/collections": 73,
"de/common": 1,
- "de/home": 118,
+ "de/home": 114,
"de/importModel": 64,
"de/media": 490,
"de/modelEditor": 22,
@@ -24,7 +24,7 @@
"es/chats": 272,
"es/collections": 73,
"es/common": 1,
- "es/home": 118,
+ "es/home": 114,
"es/importModel": 64,
"es/media": 490,
"es/modelEditor": 22,
@@ -41,7 +41,7 @@
"fr/chat": 121,
"fr/chats": 272,
"fr/collections": 73,
- "fr/home": 113,
+ "fr/home": 109,
"fr/importModel": 62,
"fr/media": 263,
"fr/modelEditor": 22,
@@ -58,7 +58,7 @@
"id/chat": 126,
"id/chats": 272,
"id/collections": 73,
- "id/home": 118,
+ "id/home": 114,
"id/importModel": 64,
"id/media": 453,
"id/models": 407,
@@ -75,7 +75,7 @@
"it/chats": 272,
"it/collections": 73,
"it/common": 1,
- "it/home": 118,
+ "it/home": 114,
"it/importModel": 64,
"it/media": 490,
"it/modelEditor": 22,
@@ -93,7 +93,7 @@
"ko/chats": 272,
"ko/collections": 73,
"ko/common": 1,
- "ko/home": 118,
+ "ko/home": 114,
"ko/importModel": 64,
"ko/media": 490,
"ko/modelEditor": 22,
@@ -110,7 +110,7 @@
"pt-BR/chat": 123,
"pt-BR/chats": 272,
"pt-BR/collections": 73,
- "pt-BR/home": 107,
+ "pt-BR/home": 103,
"pt-BR/importModel": 64,
"pt-BR/media": 300,
"pt-BR/models": 403,
@@ -127,7 +127,7 @@
"zh-CN/chats": 272,
"zh-CN/collections": 73,
"zh-CN/common": 1,
- "zh-CN/home": 118,
+ "zh-CN/home": 114,
"zh-CN/importModel": 64,
"zh-CN/media": 490,
"zh-CN/modelEditor": 22,
diff --git a/core/http/react-ui/public/locales/de/chat.json b/core/http/react-ui/public/locales/de/chat.json
index 1febd9f27..c8d29a334 100644
--- a/core/http/react-ui/public/locales/de/chat.json
+++ b/core/http/react-ui/public/locales/de/chat.json
@@ -584,7 +584,8 @@
"context": "Kontext",
"contextOf": "{{used}} von {{limit}} Token",
"contextUsed": "{{used}} Token",
- "unknown": "Noch nicht bekannt"
+ "unknown": "Noch nicht bekannt",
+ "commandsUnknown": "Die Befehlsisolation ist unbekannt"
},
"agentFolder": {
"home": "Start",
diff --git a/core/http/react-ui/public/locales/de/chats.json b/core/http/react-ui/public/locales/de/chats.json
index 3ea576722..c3d37c025 100644
--- a/core/http/react-ui/public/locales/de/chats.json
+++ b/core/http/react-ui/public/locales/de/chats.json
@@ -285,11 +285,15 @@
"hostOff": "Die Sandbox ist für diesen Chat aus, daher laufen Befehle auf diesem Rechner, mit seinen Dateien und seinem Netzwerk, ohne Isolierung. Schalten Sie die Sandbox in diesem Menü ein, um sie zu isolieren.",
"hostUnavailable": "Die Sandbox kann hier nicht laufen, daher laufen Befehle auf diesem Rechner, mit seinen Dateien und seinem Netzwerk, ohne Isolierung.",
"hostUnavailableWhy": "Die Sandbox kann hier nicht laufen ({{reason}}), daher laufen Befehle auf diesem Rechner, mit seinen Dateien und seinem Netzwerk, ohne Isolierung.",
- "hostChipTitle": "Befehle in diesem Chat laufen auf diesem Rechner, ohne Isolierung. Für Details auswählen."
+ "hostChipTitle": "Befehle in diesem Chat laufen auf diesem Rechner, ohne Isolierung. Für Details auswählen.",
+ "providerHost": "Befehle werden ohne Isolation auf dem Server ausgeführt.",
+ "unknown": "Ausführungsbereitschaft oder Isolation ist unbekannt.",
+ "providerUnavailable": "Der ausgewählte Ausführungsanbieter ist nicht verfügbar.",
+ "disabled": "Die Befehlsausführung ist für diesen Chat ausgeschaltet."
},
"consent": {
"title": "Diesen Befehl auf diesem Rechner ausführen?",
- "body": "Befehle laufen auf diesem Rechner ohne Isolierung. Sie können Dateien lesen, die LocalAI lesen kann, und das Internet und Ihr Netzwerk erreichen. Installieren Sie die Sandbox für Isolierung.",
+ "body": "Befehle laufen auf diesem Rechner ohne Isolierung. Sie können Dateien lesen, die LocalAI lesen kann, und das Internet und Ihr Netzwerk erreichen.",
"once": "Einmal ausführen",
"always": "Immer auf dieser Instanz",
"deny": "Nicht ausführen",
@@ -395,7 +399,9 @@
"allowedHostsOff": "Sie gelten, wenn das Netzwerk eingeschaltet ist.",
"allowedHostsNote": "{{count}} Hosts sind von Ihrem Administrator immer erlaubt.",
"limitNote": "Netzwerk dieses Chats. Der Administrator legt fest, was ein Chat höchstens erreichen darf.",
- "asksAbove": "Dieser Chat verlangt {{choice}}; das Limit des Administrators ist {{limit}}."
+ "asksAbove": "Dieser Chat verlangt {{choice}}; das Limit des Administrators ist {{limit}}.",
+ "unverified": "Die Durchsetzung der Netzwerkrichtlinie ist für den tatsächlichen Anbieter nicht bestätigt. Diese Einstellungen gelten nicht für seine Befehle.",
+ "configuredPolicy": "Konfigurierte Netzwerkrichtlinie: {{preset}} (nicht durchgesetzt)."
},
"inbox": {
"unread": "{{count}} ungelesen",
diff --git a/core/http/react-ui/public/locales/de/home.json b/core/http/react-ui/public/locales/de/home.json
index ffd6957f2..02dab04ae 100644
--- a/core/http/react-ui/public/locales/de/home.json
+++ b/core/http/react-ui/public/locales/de/home.json
@@ -117,6 +117,14 @@
"allChats": "Alle Chats"
},
"slash": {
+ "commandDiagnostics": "Diagnose der App-Befehle",
+ "apps": {
+ "label": "Apps",
+ "desc": "Installierte Apps durchsuchen"
+ },
+ "group": {
+ "addons": "Apps"
+ },
"agents": {
"label": "Agenten",
"desc": "Deine gespeicherten Agenten, Auslöser und Skills"
@@ -130,5 +138,6 @@
"desc": "Sammlungen, in denen deine Chats suchen können"
},
"complete": "ergänzen"
- }
+ },
+ "notice": { "appUnavailable": "Die App ist nicht mehr verfügbar." }
}
diff --git a/core/http/react-ui/public/locales/en/chat.json b/core/http/react-ui/public/locales/en/chat.json
index 37853288b..b69a892a2 100644
--- a/core/http/react-ui/public/locales/en/chat.json
+++ b/core/http/react-ui/public/locales/en/chat.json
@@ -731,7 +731,8 @@
"context": "Context",
"contextOf": "{{used}} of {{limit}} tokens",
"contextUsed": "{{used}} tokens",
- "unknown": "Not known yet"
+ "unknown": "Not known yet",
+ "commandsUnknown": "Command isolation is unknown"
},
"agentFolder": {
"home": "Home",
diff --git a/core/http/react-ui/public/locales/en/chats.json b/core/http/react-ui/public/locales/en/chats.json
index aa8b6d46a..857ecf743 100644
--- a/core/http/react-ui/public/locales/en/chats.json
+++ b/core/http/react-ui/public/locales/en/chats.json
@@ -409,11 +409,15 @@
"hostOff": "The Sandbox is off for this chat, so commands run on this machine, with its files and network, not isolated. Switch the Sandbox on in this menu to isolate them.",
"hostUnavailable": "The Sandbox cannot run here, so commands run on this machine, with its files and network, not isolated.",
"hostUnavailableWhy": "The Sandbox cannot run here ({{reason}}), so commands run on this machine, with its files and network, not isolated.",
- "hostChipTitle": "Commands in this chat run on this machine, not isolated. Select for details."
+ "hostChipTitle": "Commands in this chat run on this machine, not isolated. Select for details.",
+ "providerHost": "Commands run on the server without isolation.",
+ "unknown": "Execution readiness or isolation is unknown.",
+ "providerUnavailable": "The selected execution provider is unavailable.",
+ "disabled": "Running commands is switched off for this chat."
},
"consent": {
"title": "Run this command on this machine?",
- "body": "Commands run on this machine with no isolation. They can read files that LocalAI can read and reach the internet and your network. Install the Sandbox for isolation.",
+ "body": "Commands run on this machine with no isolation. They can read files that LocalAI can read and reach the internet and your network.",
"once": "Run once",
"always": "Always on this instance",
"deny": "Don't run",
@@ -774,7 +778,9 @@
"allowedHostsOff": "They apply when the network is on.",
"allowedHostsNote": "{{count}} hosts are always allowed by your administrator.",
"limitNote": "This chat’s network. The administrator sets the most a chat can reach.",
- "asksAbove": "This chat asks for {{choice}}; the administrator limit is {{limit}}."
+ "asksAbove": "This chat asks for {{choice}}; the administrator limit is {{limit}}.",
+ "unverified": "Network-policy enforcement is not verified for the actual provider. These settings do not currently apply to its commands.",
+ "configuredPolicy": "Configured network policy: {{preset}} (not enforced)."
},
"change": {
"loading": "Reading the trigger change...",
diff --git a/core/http/react-ui/public/locales/en/home.json b/core/http/react-ui/public/locales/en/home.json
index f64f93983..a3a5e4f6e 100644
--- a/core/http/react-ui/public/locales/en/home.json
+++ b/core/http/react-ui/public/locales/en/home.json
@@ -177,7 +177,10 @@
"label": "Actions",
"move": "move",
"close": "close",
+ "commandDiagnostics": "App command diagnostics",
+ "apps": { "label": "Apps", "desc": "Browse installed apps" },
"group": {
+ "addons": "Apps",
"chat": "Chat",
"models": "Models",
"go": "Go to"
@@ -214,10 +217,6 @@
"label": "Stop a model",
"desc": "Free memory by unloading a model"
},
- "studio": {
- "label": "Open Studio",
- "desc": "Create images, audio and video"
- },
"settings": {
"label": "Settings",
"desc": "Change how LocalAI runs"
@@ -238,10 +237,6 @@
"label": "Memory",
"desc": "Collections your chats can search"
},
- "studioInstall": {
- "label": "Install Studio",
- "desc": "Studio makes images, audio and video. It is an add-on"
- },
"complete": "complete"
},
"strip": {
@@ -265,6 +260,7 @@
"firstRun": "No models running. Memory use shows here once one loads."
},
"notice": {
+ "appUnavailable": "App is no longer available.",
"loadFailedTitle": "LocalAI could not load your models",
"loadFailedBody": "The server did not answer. Check that it is running, then try again.",
"retry": "Try again",
diff --git a/core/http/react-ui/public/locales/es/chat.json b/core/http/react-ui/public/locales/es/chat.json
index 878c04a02..c013fa4ce 100644
--- a/core/http/react-ui/public/locales/es/chat.json
+++ b/core/http/react-ui/public/locales/es/chat.json
@@ -584,7 +584,8 @@
"context": "Contexto",
"contextOf": "{{used}} de {{limit}} tokens",
"contextUsed": "{{used}} tokens",
- "unknown": "Aún no se sabe"
+ "unknown": "Aún no se sabe",
+ "commandsUnknown": "Se desconoce el aislamiento de los comandos"
},
"agentFolder": {
"home": "Inicio",
diff --git a/core/http/react-ui/public/locales/es/chats.json b/core/http/react-ui/public/locales/es/chats.json
index 013a149e7..263e8a549 100644
--- a/core/http/react-ui/public/locales/es/chats.json
+++ b/core/http/react-ui/public/locales/es/chats.json
@@ -285,11 +285,15 @@
"hostOff": "El Sandbox está desactivado en este chat, así que los comandos se ejecutan en esta máquina, con sus archivos y su red, sin aislamiento. Activa el Sandbox en este menú para aislarlos.",
"hostUnavailable": "El Sandbox no puede ejecutarse aquí, así que los comandos se ejecutan en esta máquina, con sus archivos y su red, sin aislamiento.",
"hostUnavailableWhy": "El Sandbox no puede ejecutarse aquí ({{reason}}), así que los comandos se ejecutan en esta máquina, con sus archivos y su red, sin aislamiento.",
- "hostChipTitle": "Los comandos de este chat se ejecutan en esta máquina, sin aislamiento. Selecciona para ver detalles."
+ "hostChipTitle": "Los comandos de este chat se ejecutan en esta máquina, sin aislamiento. Selecciona para ver detalles.",
+ "providerHost": "Los comandos se ejecutan en el servidor sin aislamiento.",
+ "unknown": "Se desconoce la disponibilidad o el aislamiento de la ejecución.",
+ "providerUnavailable": "El proveedor de ejecución seleccionado no está disponible.",
+ "disabled": "La ejecución de comandos está desactivada para este chat."
},
"consent": {
"title": "¿Ejecutar este comando en esta máquina?",
- "body": "Los comandos se ejecutan en esta máquina sin aislamiento. Pueden leer los archivos que LocalAI puede leer y llegar a internet y a tu red. Instala el Sandbox para aislarlos.",
+ "body": "Los comandos se ejecutan en esta máquina sin aislamiento. Pueden leer los archivos que LocalAI puede leer y llegar a internet y a tu red.",
"once": "Ejecutar una vez",
"always": "Siempre en esta instancia",
"deny": "No ejecutar",
@@ -395,7 +399,9 @@
"allowedHostsOff": "Se aplican cuando la red está activada.",
"allowedHostsNote": "Tu administrador permite siempre {{count}} hosts.",
"limitNote": "Red de este chat. El administrador fija lo máximo que un chat puede alcanzar.",
- "asksAbove": "Este chat pide {{choice}}; el límite del administrador es {{limit}}."
+ "asksAbove": "Este chat pide {{choice}}; el límite del administrador es {{limit}}.",
+ "unverified": "No se ha verificado la aplicación de la política de red para el proveedor real. Esta configuración no se aplica a sus comandos.",
+ "configuredPolicy": "Política de red configurada: {{preset}} (no aplicada)."
},
"inbox": {
"unread": "{{count}} sin leer",
diff --git a/core/http/react-ui/public/locales/es/home.json b/core/http/react-ui/public/locales/es/home.json
index 5583a4677..8afe400e0 100644
--- a/core/http/react-ui/public/locales/es/home.json
+++ b/core/http/react-ui/public/locales/es/home.json
@@ -117,6 +117,14 @@
"allChats": "Todos los chats"
},
"slash": {
+ "commandDiagnostics": "Diagnóstico de comandos de aplicaciones",
+ "apps": {
+ "label": "Aplicaciones",
+ "desc": "Explorar aplicaciones instaladas"
+ },
+ "group": {
+ "addons": "Aplicaciones"
+ },
"agents": {
"label": "Agentes",
"desc": "Tus agentes guardados, disparadores y habilidades"
@@ -130,5 +138,6 @@
"desc": "Colecciones en las que pueden buscar tus chats"
},
"complete": "completar"
- }
+ },
+ "notice": { "appUnavailable": "La aplicación ya no está disponible." }
}
diff --git a/core/http/react-ui/public/locales/fr/chat.json b/core/http/react-ui/public/locales/fr/chat.json
index 96370cf17..5d7e37e72 100644
--- a/core/http/react-ui/public/locales/fr/chat.json
+++ b/core/http/react-ui/public/locales/fr/chat.json
@@ -582,7 +582,8 @@
"context": "Contexte",
"contextOf": "{{used}} sur {{limit}} tokens",
"contextUsed": "{{used}} tokens",
- "unknown": "Pas encore connu"
+ "unknown": "Pas encore connu",
+ "commandsUnknown": "L’isolation des commandes est inconnue"
},
"agentFolder": {
"home": "Accueil",
diff --git a/core/http/react-ui/public/locales/fr/chats.json b/core/http/react-ui/public/locales/fr/chats.json
index c7950bf7a..05b6c4799 100644
--- a/core/http/react-ui/public/locales/fr/chats.json
+++ b/core/http/react-ui/public/locales/fr/chats.json
@@ -285,11 +285,15 @@
"hostOff": "Le Sandbox est désactivé pour ce chat : les commandes s'exécutent donc sur cette machine, avec ses fichiers et son réseau, sans isolation. Activez le Sandbox dans ce menu pour les isoler.",
"hostUnavailable": "Le Sandbox ne peut pas s'exécuter ici : les commandes s'exécutent donc sur cette machine, avec ses fichiers et son réseau, sans isolation.",
"hostUnavailableWhy": "Le Sandbox ne peut pas s'exécuter ici ({{reason}}) : les commandes s'exécutent donc sur cette machine, avec ses fichiers et son réseau, sans isolation.",
- "hostChipTitle": "Les commandes de ce chat s'exécutent sur cette machine, sans isolation. Sélectionnez pour les détails."
+ "hostChipTitle": "Les commandes de ce chat s'exécutent sur cette machine, sans isolation. Sélectionnez pour les détails.",
+ "providerHost": "Les commandes s’exécutent sur le serveur sans isolation.",
+ "unknown": "La disponibilité ou l’isolation de l’exécution est inconnue.",
+ "providerUnavailable": "Le fournisseur d’exécution sélectionné est indisponible.",
+ "disabled": "L’exécution des commandes est désactivée pour cette discussion."
},
"consent": {
"title": "Exécuter cette commande sur cette machine ?",
- "body": "Les commandes s'exécutent sur cette machine sans isolation. Elles peuvent lire les fichiers que LocalAI peut lire et joindre Internet et votre réseau. Installez le Sandbox pour les isoler.",
+ "body": "Les commandes s'exécutent sur cette machine sans isolation. Elles peuvent lire les fichiers que LocalAI peut lire et joindre Internet et votre réseau.",
"once": "Exécuter une fois",
"always": "Toujours sur cette instance",
"deny": "Ne pas exécuter",
@@ -395,7 +399,9 @@
"allowedHostsOff": "Ils s’appliquent quand le réseau est activé.",
"allowedHostsNote": "{{count}} hôtes sont toujours autorisés par votre administrateur.",
"limitNote": "Réseau de cette conversation. L’administrateur fixe le maximum qu’une conversation peut atteindre.",
- "asksAbove": "Cette conversation demande {{choice}} ; la limite de l’administrateur est {{limit}}."
+ "asksAbove": "Cette conversation demande {{choice}} ; la limite de l’administrateur est {{limit}}.",
+ "unverified": "L’application de la politique réseau n’est pas vérifiée pour le fournisseur réel. Ces paramètres ne s’appliquent pas à ses commandes.",
+ "configuredPolicy": "Politique réseau configurée : {{preset}} (non appliquée)."
},
"inbox": {
"unread": "{{count}} non lues",
diff --git a/core/http/react-ui/public/locales/fr/home.json b/core/http/react-ui/public/locales/fr/home.json
index 0bd56ebc5..663711b09 100644
--- a/core/http/react-ui/public/locales/fr/home.json
+++ b/core/http/react-ui/public/locales/fr/home.json
@@ -120,6 +120,14 @@
"allChats": "Toutes les conversations"
},
"slash": {
+ "commandDiagnostics": "Diagnostic des commandes d’applications",
+ "apps": {
+ "label": "Applications",
+ "desc": "Parcourir les applications installées"
+ },
+ "group": {
+ "addons": "Applications"
+ },
"agents": {
"label": "Agents",
"desc": "Vos agents enregistrés, déclencheurs et compétences"
@@ -133,5 +141,6 @@
"desc": "Collections que vos chats peuvent consulter"
},
"complete": "compléter"
- }
+ },
+ "notice": { "appUnavailable": "L’application n’est plus disponible." }
}
diff --git a/core/http/react-ui/public/locales/id/chat.json b/core/http/react-ui/public/locales/id/chat.json
index 1f7e41041..6236f294f 100644
--- a/core/http/react-ui/public/locales/id/chat.json
+++ b/core/http/react-ui/public/locales/id/chat.json
@@ -584,7 +584,8 @@
"context": "Konteks",
"contextOf": "{{used}} dari {{limit}} token",
"contextUsed": "{{used}} token",
- "unknown": "Belum diketahui"
+ "unknown": "Belum diketahui",
+ "commandsUnknown": "Isolasi perintah tidak diketahui"
},
"agentFolder": {
"home": "Beranda",
diff --git a/core/http/react-ui/public/locales/id/chats.json b/core/http/react-ui/public/locales/id/chats.json
index 43fc4e396..0ca09fe66 100644
--- a/core/http/react-ui/public/locales/id/chats.json
+++ b/core/http/react-ui/public/locales/id/chats.json
@@ -285,11 +285,15 @@
"hostOff": "Sandbox mati untuk chat ini, jadi perintah berjalan di mesin ini, dengan berkas dan jaringannya, tanpa isolasi. Nyalakan Sandbox di menu ini untuk mengisolasinya.",
"hostUnavailable": "Sandbox tidak bisa berjalan di sini, jadi perintah berjalan di mesin ini, dengan berkas dan jaringannya, tanpa isolasi.",
"hostUnavailableWhy": "Sandbox tidak bisa berjalan di sini ({{reason}}), jadi perintah berjalan di mesin ini, dengan berkas dan jaringannya, tanpa isolasi.",
- "hostChipTitle": "Perintah di chat ini berjalan di mesin ini, tanpa isolasi. Pilih untuk detail."
+ "hostChipTitle": "Perintah di chat ini berjalan di mesin ini, tanpa isolasi. Pilih untuk detail.",
+ "providerHost": "Perintah dijalankan di server tanpa isolasi.",
+ "unknown": "Kesiapan atau isolasi eksekusi tidak diketahui.",
+ "providerUnavailable": "Penyedia eksekusi yang dipilih tidak tersedia.",
+ "disabled": "Eksekusi perintah dinonaktifkan untuk percakapan ini."
},
"consent": {
"title": "Jalankan perintah ini di mesin ini?",
- "body": "Perintah berjalan di mesin ini tanpa isolasi. Perintah dapat membaca berkas yang bisa dibaca LocalAI dan menjangkau internet serta jaringan Anda. Pasang Sandbox untuk isolasi.",
+ "body": "Perintah berjalan di mesin ini tanpa isolasi. Perintah dapat membaca berkas yang bisa dibaca LocalAI dan menjangkau internet serta jaringan Anda.",
"once": "Jalankan sekali",
"always": "Selalu di instans ini",
"deny": "Jangan jalankan",
@@ -395,7 +399,9 @@
"allowedHostsOff": "Berlaku saat jaringan aktif.",
"allowedHostsNote": "{{count}} host selalu diizinkan oleh administrator Anda.",
"limitNote": "Jaringan chat ini. Administrator menetapkan batas maksimum yang dapat dijangkau sebuah chat.",
- "asksAbove": "Chat ini meminta {{choice}}; batas administrator adalah {{limit}}."
+ "asksAbove": "Chat ini meminta {{choice}}; batas administrator adalah {{limit}}.",
+ "unverified": "Penerapan kebijakan jaringan untuk penyedia sebenarnya belum diverifikasi. Pengaturan ini tidak berlaku untuk perintahnya.",
+ "configuredPolicy": "Kebijakan jaringan terkonfigurasi: {{preset}} (tidak diterapkan)."
},
"inbox": {
"unread": "{{count}} belum dibaca",
diff --git a/core/http/react-ui/public/locales/id/home.json b/core/http/react-ui/public/locales/id/home.json
index e31c62632..b8573dbbc 100644
--- a/core/http/react-ui/public/locales/id/home.json
+++ b/core/http/react-ui/public/locales/id/home.json
@@ -117,6 +117,14 @@
"allChats": "Semua obrolan"
},
"slash": {
+ "commandDiagnostics": "Diagnostik perintah aplikasi",
+ "apps": {
+ "label": "Aplikasi",
+ "desc": "Jelajahi aplikasi terpasang"
+ },
+ "group": {
+ "addons": "Aplikasi"
+ },
"agents": {
"label": "Agen",
"desc": "Agen tersimpan, pemicu, dan keahlian Anda"
@@ -130,5 +138,6 @@
"desc": "Koleksi yang bisa dicari obrolan Anda"
},
"complete": "lengkapi"
- }
+ },
+ "notice": { "appUnavailable": "Aplikasi tidak lagi tersedia." }
}
diff --git a/core/http/react-ui/public/locales/it/chat.json b/core/http/react-ui/public/locales/it/chat.json
index 0c4b519a1..ce1b50c6c 100644
--- a/core/http/react-ui/public/locales/it/chat.json
+++ b/core/http/react-ui/public/locales/it/chat.json
@@ -584,7 +584,8 @@
"context": "Contesto",
"contextOf": "{{used}} di {{limit}} token",
"contextUsed": "{{used}} token",
- "unknown": "Non ancora noto"
+ "unknown": "Non ancora noto",
+ "commandsUnknown": "L’isolamento dei comandi è sconosciuto"
},
"agentFolder": {
"home": "Home",
diff --git a/core/http/react-ui/public/locales/it/chats.json b/core/http/react-ui/public/locales/it/chats.json
index 0881da92f..1fb1b4d77 100644
--- a/core/http/react-ui/public/locales/it/chats.json
+++ b/core/http/react-ui/public/locales/it/chats.json
@@ -285,11 +285,15 @@
"hostOff": "Il Sandbox è spento per questa chat, quindi i comandi vengono eseguiti su questa macchina, con i suoi file e la sua rete, senza isolamento. Accendi il Sandbox in questo menu per isolarli.",
"hostUnavailable": "Il Sandbox non può funzionare qui, quindi i comandi vengono eseguiti su questa macchina, con i suoi file e la sua rete, senza isolamento.",
"hostUnavailableWhy": "Il Sandbox non può funzionare qui ({{reason}}), quindi i comandi vengono eseguiti su questa macchina, con i suoi file e la sua rete, senza isolamento.",
- "hostChipTitle": "I comandi di questa chat vengono eseguiti su questa macchina, senza isolamento. Seleziona per i dettagli."
+ "hostChipTitle": "I comandi di questa chat vengono eseguiti su questa macchina, senza isolamento. Seleziona per i dettagli.",
+ "providerHost": "I comandi vengono eseguiti sul server senza isolamento.",
+ "unknown": "La disponibilità o l’isolamento dell’esecuzione non sono noti.",
+ "providerUnavailable": "Il provider di esecuzione selezionato non è disponibile.",
+ "disabled": "L’esecuzione dei comandi è disattivata per questa chat."
},
"consent": {
"title": "Eseguire questo comando su questa macchina?",
- "body": "I comandi vengono eseguiti su questa macchina senza isolamento. Possono leggere i file che LocalAI può leggere e raggiungere internet e la tua rete. Installa il Sandbox per isolarli.",
+ "body": "I comandi vengono eseguiti su questa macchina senza isolamento. Possono leggere i file che LocalAI può leggere e raggiungere internet e la tua rete.",
"once": "Esegui una volta",
"always": "Sempre su questa istanza",
"deny": "Non eseguire",
@@ -395,7 +399,9 @@
"allowedHostsOff": "Valgono quando la rete è attiva.",
"allowedHostsNote": "{{count}} host sono sempre consentiti dal tuo amministratore.",
"limitNote": "Rete di questa chat. L’amministratore imposta il massimo che una chat può raggiungere.",
- "asksAbove": "Questa chat chiede {{choice}}; il limite dell’amministratore è {{limit}}."
+ "asksAbove": "Questa chat chiede {{choice}}; il limite dell’amministratore è {{limit}}.",
+ "unverified": "L’applicazione dei criteri di rete non è verificata per il provider effettivo. Queste impostazioni non si applicano ai suoi comandi.",
+ "configuredPolicy": "Criterio di rete configurato: {{preset}} (non applicato)."
},
"inbox": {
"unread": "{{count}} non lette",
diff --git a/core/http/react-ui/public/locales/it/home.json b/core/http/react-ui/public/locales/it/home.json
index 086409f40..41c5e1fa5 100644
--- a/core/http/react-ui/public/locales/it/home.json
+++ b/core/http/react-ui/public/locales/it/home.json
@@ -117,6 +117,14 @@
"allChats": "Tutte le chat"
},
"slash": {
+ "commandDiagnostics": "Diagnostica dei comandi delle app",
+ "apps": {
+ "label": "App",
+ "desc": "Sfoglia le app installate"
+ },
+ "group": {
+ "addons": "App"
+ },
"agents": {
"label": "Agenti",
"desc": "I tuoi agenti salvati, trigger e skill"
@@ -130,5 +138,6 @@
"desc": "Raccolte in cui le tue chat possono cercare"
},
"complete": "completa"
- }
+ },
+ "notice": { "appUnavailable": "L’app non è più disponibile." }
}
diff --git a/core/http/react-ui/public/locales/ko/chat.json b/core/http/react-ui/public/locales/ko/chat.json
index 061b0f5b9..c782bf847 100644
--- a/core/http/react-ui/public/locales/ko/chat.json
+++ b/core/http/react-ui/public/locales/ko/chat.json
@@ -584,7 +584,8 @@
"context": "컨텍스트",
"contextOf": "{{limit}} 토큰 중 {{used}}",
"contextUsed": "{{used}} 토큰",
- "unknown": "아직 알 수 없음"
+ "unknown": "아직 알 수 없음",
+ "commandsUnknown": "명령 격리 상태를 알 수 없습니다"
},
"agentFolder": {
"home": "홈",
diff --git a/core/http/react-ui/public/locales/ko/chats.json b/core/http/react-ui/public/locales/ko/chats.json
index 784a72591..1686a73e3 100644
--- a/core/http/react-ui/public/locales/ko/chats.json
+++ b/core/http/react-ui/public/locales/ko/chats.json
@@ -285,11 +285,15 @@
"hostOff": "이 채팅에서 Sandbox가 꺼져 있어 명령이 이 컴퓨터의 파일과 네트워크를 쓰며 격리 없이 실행됩니다. 격리하려면 이 메뉴에서 Sandbox를 켜세요.",
"hostUnavailable": "여기서는 Sandbox를 실행할 수 없어 명령이 이 컴퓨터의 파일과 네트워크를 쓰며 격리 없이 실행됩니다.",
"hostUnavailableWhy": "여기서는 Sandbox를 실행할 수 없어({{reason}}) 명령이 이 컴퓨터의 파일과 네트워크를 쓰며 격리 없이 실행됩니다.",
- "hostChipTitle": "이 채팅의 명령은 격리 없이 이 컴퓨터에서 실행됩니다. 자세히 보려면 선택하세요."
+ "hostChipTitle": "이 채팅의 명령은 격리 없이 이 컴퓨터에서 실행됩니다. 자세히 보려면 선택하세요.",
+ "providerHost": "명령은 격리 없이 서버에서 실행됩니다.",
+ "unknown": "실행 준비 상태 또는 격리 여부를 알 수 없습니다.",
+ "providerUnavailable": "선택한 실행 공급자를 사용할 수 없습니다.",
+ "disabled": "이 채팅의 명령 실행이 꺼져 있습니다."
},
"consent": {
"title": "이 명령을 이 컴퓨터에서 실행할까요?",
- "body": "명령은 격리 없이 이 컴퓨터에서 실행됩니다. LocalAI가 읽을 수 있는 파일을 읽고 인터넷과 사용자 네트워크에 접속할 수 있습니다. 격리하려면 Sandbox를 설치하세요.",
+ "body": "명령은 격리 없이 이 컴퓨터에서 실행됩니다. LocalAI가 읽을 수 있는 파일을 읽고 인터넷과 사용자 네트워크에 접속할 수 있습니다.",
"once": "한 번 실행",
"always": "이 인스턴스에서 항상",
"deny": "실행 안 함",
@@ -395,7 +399,9 @@
"allowedHostsOff": "네트워크가 켜져 있을 때 적용됩니다.",
"allowedHostsNote": "관리자가 호스트 {{count}}개를 항상 허용합니다.",
"limitNote": "이 채팅의 네트워크입니다. 채팅이 접근할 수 있는 최대 범위는 관리자가 정합니다.",
- "asksAbove": "이 채팅은 {{choice}}을(를) 요청합니다. 관리자 제한은 {{limit}}입니다."
+ "asksAbove": "이 채팅은 {{choice}}을(를) 요청합니다. 관리자 제한은 {{limit}}입니다.",
+ "unverified": "실제 공급자에 대한 네트워크 정책 적용이 확인되지 않았습니다. 이 설정은 해당 명령에 적용되지 않습니다.",
+ "configuredPolicy": "설정된 네트워크 정책: {{preset}} (적용되지 않음)."
},
"inbox": {
"unread": "읽지 않음 {{count}}개",
diff --git a/core/http/react-ui/public/locales/ko/home.json b/core/http/react-ui/public/locales/ko/home.json
index 0b51eade6..1d256d61b 100644
--- a/core/http/react-ui/public/locales/ko/home.json
+++ b/core/http/react-ui/public/locales/ko/home.json
@@ -117,6 +117,14 @@
"allChats": "모든 채팅"
},
"slash": {
+ "commandDiagnostics": "앱 명령 진단",
+ "apps": {
+ "label": "앱",
+ "desc": "설치된 앱 찾아보기"
+ },
+ "group": {
+ "addons": "앱"
+ },
"agents": {
"label": "에이전트",
"desc": "저장된 에이전트, 트리거, 스킬"
@@ -130,5 +138,6 @@
"desc": "채팅이 검색할 수 있는 컬렉션"
},
"complete": "완성"
- }
+ },
+ "notice": { "appUnavailable": "앱을 더 이상 사용할 수 없습니다." }
}
diff --git a/core/http/react-ui/public/locales/pt-BR/chat.json b/core/http/react-ui/public/locales/pt-BR/chat.json
index c46d9b353..8fbe6ce52 100644
--- a/core/http/react-ui/public/locales/pt-BR/chat.json
+++ b/core/http/react-ui/public/locales/pt-BR/chat.json
@@ -587,7 +587,8 @@
"context": "Contexto",
"contextOf": "{{used}} de {{limit}} tokens",
"contextUsed": "{{used}} tokens",
- "unknown": "Ainda não se sabe"
+ "unknown": "Ainda não se sabe",
+ "commandsUnknown": "O isolamento dos comandos é desconhecido"
},
"agentFolder": {
"home": "Início",
diff --git a/core/http/react-ui/public/locales/pt-BR/chats.json b/core/http/react-ui/public/locales/pt-BR/chats.json
index 59d1808f9..455155e0e 100644
--- a/core/http/react-ui/public/locales/pt-BR/chats.json
+++ b/core/http/react-ui/public/locales/pt-BR/chats.json
@@ -285,11 +285,15 @@
"hostOff": "O Sandbox está desligado neste chat, então os comandos rodam nesta máquina, com os arquivos e a rede dela, sem isolamento. Ligue o Sandbox neste menu para isolá-los.",
"hostUnavailable": "O Sandbox não consegue rodar aqui, então os comandos rodam nesta máquina, com os arquivos e a rede dela, sem isolamento.",
"hostUnavailableWhy": "O Sandbox não consegue rodar aqui ({{reason}}), então os comandos rodam nesta máquina, com os arquivos e a rede dela, sem isolamento.",
- "hostChipTitle": "Os comandos deste chat rodam nesta máquina, sem isolamento. Selecione para ver detalhes."
+ "hostChipTitle": "Os comandos deste chat rodam nesta máquina, sem isolamento. Selecione para ver detalhes.",
+ "providerHost": "Os comandos são executados no servidor sem isolamento.",
+ "unknown": "A disponibilidade ou o isolamento da execução é desconhecido.",
+ "providerUnavailable": "O provedor de execução selecionado está indisponível.",
+ "disabled": "A execução de comandos está desativada para esta conversa."
},
"consent": {
"title": "Rodar este comando nesta máquina?",
- "body": "Os comandos rodam nesta máquina sem isolamento. Eles podem ler os arquivos que o LocalAI lê e acessar a internet e a sua rede. Instale o Sandbox para isolá-los.",
+ "body": "Os comandos rodam nesta máquina sem isolamento. Eles podem ler os arquivos que o LocalAI lê e acessar a internet e a sua rede.",
"once": "Rodar uma vez",
"always": "Sempre nesta instância",
"deny": "Não rodar",
@@ -395,7 +399,9 @@
"allowedHostsOff": "Eles valem quando a rede está ligada.",
"allowedHostsNote": "{{count}} hosts são sempre permitidos pelo seu administrador.",
"limitNote": "Rede deste chat. O administrador define o máximo que um chat pode alcançar.",
- "asksAbove": "Este chat pede {{choice}}; o limite do administrador é {{limit}}."
+ "asksAbove": "Este chat pede {{choice}}; o limite do administrador é {{limit}}.",
+ "unverified": "A aplicação da política de rede não foi verificada para o provedor real. Estas configurações não se aplicam aos comandos dele.",
+ "configuredPolicy": "Política de rede configurada: {{preset}} (não aplicada)."
},
"inbox": {
"unread": "{{count}} não lidas",
diff --git a/core/http/react-ui/public/locales/pt-BR/home.json b/core/http/react-ui/public/locales/pt-BR/home.json
index 19a21a43d..daf7e8ddf 100644
--- a/core/http/react-ui/public/locales/pt-BR/home.json
+++ b/core/http/react-ui/public/locales/pt-BR/home.json
@@ -132,6 +132,14 @@
"allChats": "Todos os chats"
},
"slash": {
+ "commandDiagnostics": "Diagnóstico de comandos de aplicativos",
+ "apps": {
+ "label": "Aplicativos",
+ "desc": "Explorar aplicativos instalados"
+ },
+ "group": {
+ "addons": "Aplicativos"
+ },
"agents": {
"label": "Agentes",
"desc": "Seus agentes salvos, gatilhos e habilidades"
@@ -145,5 +153,6 @@
"desc": "Coleções em que seus chats podem pesquisar"
},
"complete": "completar"
- }
+ },
+ "notice": { "appUnavailable": "O aplicativo não está mais disponível." }
}
diff --git a/core/http/react-ui/public/locales/zh-CN/chat.json b/core/http/react-ui/public/locales/zh-CN/chat.json
index 5ba9c9094..b3071578f 100644
--- a/core/http/react-ui/public/locales/zh-CN/chat.json
+++ b/core/http/react-ui/public/locales/zh-CN/chat.json
@@ -584,7 +584,8 @@
"context": "上下文",
"contextOf": "{{used}} / {{limit}} 个 token",
"contextUsed": "{{used}} 个 token",
- "unknown": "尚不清楚"
+ "unknown": "尚不清楚",
+ "commandsUnknown": "命令隔离状态未知"
},
"agentFolder": {
"home": "主页",
diff --git a/core/http/react-ui/public/locales/zh-CN/chats.json b/core/http/react-ui/public/locales/zh-CN/chats.json
index c4c7f1197..e9362528f 100644
--- a/core/http/react-ui/public/locales/zh-CN/chats.json
+++ b/core/http/react-ui/public/locales/zh-CN/chats.json
@@ -285,11 +285,15 @@
"hostOff": "此聊天已关闭 Sandbox,因此命令在这台机器上运行,可访问其文件和网络,没有隔离。在此菜单中打开 Sandbox 以隔离运行。",
"hostUnavailable": "Sandbox 无法在此运行,因此命令在这台机器上运行,可访问其文件和网络,没有隔离。",
"hostUnavailableWhy": "Sandbox 无法在此运行({{reason}}),因此命令在这台机器上运行,可访问其文件和网络,没有隔离。",
- "hostChipTitle": "此聊天中的命令在这台机器上运行,没有隔离。选择以查看详情。"
+ "hostChipTitle": "此聊天中的命令在这台机器上运行,没有隔离。选择以查看详情。",
+ "providerHost": "命令在服务器上运行,未隔离。",
+ "unknown": "执行就绪状态或隔离状态未知。",
+ "providerUnavailable": "所选执行提供程序不可用。",
+ "disabled": "此聊天已关闭命令执行。"
},
"consent": {
"title": "在这台机器上运行此命令?",
- "body": "命令在这台机器上运行,没有隔离。它们可以读取 LocalAI 能读取的文件,并访问互联网和你的网络。安装 Sandbox 以隔离运行。",
+ "body": "命令在这台机器上运行,没有隔离。它们可以读取 LocalAI 能读取的文件,并访问互联网和你的网络。",
"once": "运行一次",
"always": "在此实例上始终允许",
"deny": "不运行",
@@ -395,7 +399,9 @@
"allowedHostsOff": "网络开启时生效。",
"allowedHostsNote": "管理员始终允许 {{count}} 个主机。",
"limitNote": "此对话的网络。管理员决定对话最多能访问的范围。",
- "asksAbove": "此对话请求“{{choice}}”;管理员的限制是“{{limit}}”。"
+ "asksAbove": "此对话请求“{{choice}}”;管理员的限制是“{{limit}}”。",
+ "unverified": "尚未验证实际提供程序是否实施网络策略。这些设置目前不适用于其命令。",
+ "configuredPolicy": "已配置的网络策略:{{preset}}(未实施)。"
},
"inbox": {
"unread": "{{count}} 条未读",
diff --git a/core/http/react-ui/public/locales/zh-CN/home.json b/core/http/react-ui/public/locales/zh-CN/home.json
index 45f5fd8e0..6290fbf32 100644
--- a/core/http/react-ui/public/locales/zh-CN/home.json
+++ b/core/http/react-ui/public/locales/zh-CN/home.json
@@ -117,6 +117,14 @@
"allChats": "全部对话"
},
"slash": {
+ "commandDiagnostics": "应用命令诊断",
+ "apps": {
+ "label": "应用",
+ "desc": "浏览已安装的应用"
+ },
+ "group": {
+ "addons": "应用"
+ },
"agents": {
"label": "智能体",
"desc": "你保存的智能体、触发器和技能"
@@ -130,5 +138,6 @@
"desc": "你的对话可以搜索的集合"
},
"complete": "补全"
- }
+ },
+ "notice": { "appUnavailable": "应用已不可用。" }
}
diff --git a/core/http/react-ui/src/addons/ModelLaunch.jsx b/core/http/react-ui/src/addons/ModelLaunch.jsx
new file mode 100644
index 000000000..b7ba8bd87
--- /dev/null
+++ b/core/http/react-ui/src/addons/ModelLaunch.jsx
@@ -0,0 +1,29 @@
+// SPDX-License-Identifier: MIT
+/* eslint-disable no-unused-vars -- JSX components */
+import { useEffect, useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { useAddonRegistry } from './useAddonRegistry'
+import { projectModelActions } from './modelActions'
+import ActionMenu from '../components/ActionMenu'
+
+// Shared by the inspector, gallery Open and actual model detail renderer.
+// Acquiring the list does not hold or load any addon module.
+export default function ModelLaunch({ useCase, model, label, className = 'dk-chip' }) {
+ const registry = useAddonRegistry()
+ const navigate = useNavigate()
+ const { acquire, allowed } = registry
+ const [refused, setRefused] = useState(false)
+ useEffect(() => allowed ? acquire() : undefined, [acquire, allowed])
+ useEffect(() => setRefused(false), [model, useCase.cap])
+ if (useCase.route) return
+ const projection = projectModelActions(registry, useCase.cap, model)
+ const launch = async choice => { if (!await registry.navigateModelAction(choice)) setRefused(true) }
+ const status = refused ? 'stale' : projection.status
+ return
+ {projection.choices.length === 1
+ ?
+ : projection.choices.length > 1
+ ? <>{label}
{t('tools.consent.body')}
{consent.command}
{isAdmin
- ? {t('tools.consent.alwaysHint')} {t('tools.commands.install')}
+ ?{t('tools.consent.alwaysHint')}
:{t('tools.consent.askAdmin')}
} {error &&{error}
} diff --git a/core/http/react-ui/src/components/chat/CommandsNotice.jsx b/core/http/react-ui/src/components/chat/CommandsNotice.jsx index b58068eaa..b136bd265 100644 --- a/core/http/react-ui/src/components/chat/CommandsNotice.jsx +++ b/core/http/react-ui/src/components/chat/CommandsNotice.jsx @@ -36,6 +36,7 @@ export function CommandsNotice({ notice, testId = 'chat-commands-notice' }) { )} > {t(`tools.commands.${info.textKey}`, { reason: info.reason })} + {info.reason && ['provider-host', 'unknown', 'provider-unavailable'].includes(info.state) &&{info.reason}
} {info.askKey && <> {t(`tools.commands.${info.askKey}`)}>} ) diff --git a/core/http/react-ui/src/components/chat/NetworkRow.jsx b/core/http/react-ui/src/components/chat/NetworkRow.jsx index ebca78a28..fbf87b829 100644 --- a/core/http/react-ui/src/components/chat/NetworkRow.jsx +++ b/core/http/react-ui/src/components/chat/NetworkRow.jsx @@ -19,7 +19,12 @@ export default function NetworkRow({ egress }) { const [open, setOpen] = useState(false) const mode = networkMode(egress) if (mode === 'hidden') return null - if (mode === 'host') return{t('egress.hostNetwork')}
+ if (mode === 'host' || mode === 'unverified') return ( +{t(mode === 'host' ? 'egress.hostNetwork' : 'egress.unverified')}
+{t('egress.configuredPolicy', { preset: t(`egress.preset.${egress.preset || 'off'}`) })}
+