mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-04 11:33:22 -05:00
feat(addons): declare typed host capabilities (#13034)
* feat(addons): model execution status observations Keep observation lifetimes explicit before runtime integration. The pure reducer rejects retries and stale replies using exact binding identity. Cover all eleven trace families, the complete transition matrix and bounded histories with safety and liveness checks. Assisted-by: nib:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): visualize execution status traces Generate a self-contained viewer from the real reducer and shared traces. Check independent expectations, coverage and bounded invariants before exporting data. Detect stale artifacts and nondeterministic generation without maintaining a second reducer in JavaScript. Assisted-by: nib:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(addons): honor the viewer artifact root Keep default viewer output under SDD_LOCAL_ROOT when configured.\nLet go generate use the same default without forcing a source artifact.\nRetain the portable fallback and explicit output and check semantics. Assisted-by: nib:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): add typed extension contracts Validate optional home commands, execution bindings and requested host operations without changing manifest versions or granting authority. Decode bounded execution status payloads conservatively and document strict staging compatibility for older hosts. Assisted-by: nib:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(addons): reject noncanonical extension keys Reject case aliases before typed decoding can override canonical fields or bypass presence checks. Keep locale maps outside exact-key checks. Add regression coverage for status, declaration objects, and nested entries. All status errors return unknown readiness and isolation. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(addons): reject root enums for status tools Status tools must accept the empty object. The supported schema enums contain only strings, so a root enum excludes every object. Reject root enums only for execution status bindings. Cover incompatible root constraints and optional properties without changing shared validation. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): project typed home commands Resolve declared home destinations and display metadata in the installed Apps view. Pin packaged command icons to immutable asset URLs. Cover strict declaration transport, immutable receipts, exact health echo, and process restart. Keep legacy packages and disabled installed intent. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(addons): preserve typed lifecycle declarations Copy execution and host-operation declarations from installed receipts. The existing capability completeness test exposes the missing fields. Exercise real staged Manager upgrades, activation rollback and recovery. Check installed Apps metadata through pinned mTLS frontends for each actor. Verify mutation isolation and name receipt replay coverage accurately. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(addons): stabilize fixture archive digests Map iteration changes tar entry order between identical publishes. Sort fixture filenames so unchanged packages retain their digest. Check repeated publish bytes, digests and lexical archive entry order with and without a manifest transform. Register the regression case. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): resolve explicit host policy grants Load strict administrator policy files once before application startup. Honor explicit empty overrides without reading ignored policy files. Copy programmatic and manager grants to isolate startup authority. Keep the legacy CLI fallback until the official deployment policy ships. The generic resolver already defaults to empty grants. Verify config, CLI, hostops and execution-status checks with GOMAXPROCS=2, -p=2 and -ldflags="-s -w" to fit the available build disk. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): select explicit deployment grants Move official host grants out of the generic executable into reviewed host deployment policy. Stock Docker explicitly selects the shipped file; unconfigured binaries and nil or empty programmatic grants grant nothing. Document the standalone upgrade break and deliberate operator selection. Do not infer consent from installed addons or merge explicit overrides. Guard policy packaging and generic configuration boundaries with tests. Verify executionstatus, viewer -check, config, CLI, packaging and root architecture tests with auth, GOMAXPROCS=2 and bounded build parallelism. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): gate activation on required host ops Refuse unmet required requests before package publication or runtime withdrawal. Keep installed intent unavailable when startup grants change. Optional requests permit degraded operation and never create authority. Carry bounded admin diagnostics through Apps and the owner carrier. Keep startup policy immutable and broker authorization authoritative. Verify executionstatus, viewer -check, lifecycle, packages, updates, hostops, Apps and gateway offline with auth and bounded parallelism. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(addons): guard host operation registry parity Compare startup admission names against real builtin registrations so registry drift cannot silently change request satisfaction. Cover dynamic progress and stop operations and connector inbound. Reject missing, extra, duplicate and case-changed names with negative controls. Record both regression cases in the suite inventory. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): fence status observation effects Keep observation ownership in admitted runtime operations. Deny all correlated callbacks before dispatch, including inbound-class callbacks. Reject status outputs before callers can open or adopt artifact handles. Retain bounded denial correlations until the runtime endpoint closes so late callbacks cannot recover ordinary authority. Preserve independent ordinary operations and document the dedicated read API for T9. Verify the status model, viewer, and race-enabled gateway, hostops and runtime suites with the pinned offline environment. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(addons): drain callbacks before retirement Server.Close does not wait for active callback requests. Clearing retired observation correlations first can restore inbound authority. Stop endpoint admission before closing connections. Drain admitted requests before releasing correlations, and refuse dispatch after Stop. Keep locks out of handlers and cleanup waits so cancellation cannot deadlock teardown. Cover pending inbound callbacks, decoded authorization paused across Stop, and handlers that ignore cancellation. Preserve ordinary authorized work on other endpoints. Document delayed reclamation under the existing bound. Verify the status model and viewer, then race-test gateway, hostops, runtime, the model and root architecture checks with auth enabled. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): bind owner status to exact clients Add authenticated, versioned status reads over the owner carrier. Revalidate actor permissions and provider selection before dispatch and before returning an observation. Keep status separate from authority. Admit the captured client against its immutable identity under the withdrawal lock. Never dispatch an old binding through a replacement. Generate runtime operation IDs on the owner and retain the T8 effect fence. Cover forged identities, strict DTOs, stale replies, restart ordering, wire discriminators, and unchanged model exclusion of uiOnly tools. Document the T10 callback and request-shell integration requirements. Verify the model and viewer, then race-test gateway, runtime, tools, and the model with auth enabled. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(addons): compose owner status with runtime Exercise installed addon processes through the authenticated mTLS carrier. Keep runtime clients real while channel barriers control lookup ordering. Reject captured clients after same-generation replacement before dispatch. Reject completed status reads when replacement precedes owner consumption. Prove fresh bindings succeed and record named tools and host operation IDs. Verify the model and viewer, then race-test gateway, runtime, tools, the model, and the protocol fixture package with auth enabled. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(studio): scope execution status observations Run the execution status reducer within one explicit request lifetime. Coalesce consumers without retries and withdraw stale or canceled reads. Keep owner admission and production lifecycle integration separate for review before wiring the shell into requests and turns. Assisted-by: OpenAI:ChatGPT Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(studio): isolate consumer cancellation Consumer cancellation during validation must not withdraw a valid observation from other consumers. Return unknown locally before shared invalidation, while preserving parent closure and freshness failures. Cover cancellation and deterministic deadline expiry with one-read reducer traces and live-consumer checks. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): track selection mutation epochs Detect selection ABA at config and owner mutation boundaries. Clone provider pins for Studio readers and share synchronized selection state across config value copies. Track lifecycle publication, candidate rollback and recovery transitions. Keep reads and unchanged publications stable. These lifetime epochs do not replace current-incarnation checks or grant execution authority. Verify race tests for config and the add-on Manager, plus application compilation with auth tags. Request binding remains separate work. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): expose owner binding transport Add a host-only binding API with an optional owner resolver. Forward conversation claims over authenticated mTLS with strict versioned DTOs. Compare full bindings during validation and deny missing resolvers. Keep ordinary tool execution unchanged through an optional backend. Test trusted resolver fixtures, stale bindings, denial and compatibility. Application authority and store revisions remain a separate milestone. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(studio): fence selection snapshots with epochs Read actor-scoped records and opaque mutation tokens under store locks. Invalidate tokens after successful eligibility changes, including ABA. Keep reads, rejected writes and turn history from invalidating tokens. Clone mutable records so callers cannot bypass mutation tracking. Support authoritative revisions only for memory-only JSON stores. File stores lack exclusive ownership; GORM lacks persisted revisions. Fail closed for both rather than trust frontend-local state. Ordinary chats remain unchanged; application integration stays separate. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(studio): enforce persistent JSON ownership Acquire lifetime sidecar locks before loading or repairing JSON data. Enable selection revisions only under cooperating exclusive ownership. Close drains operations before releasing ownership and rejects later use. Clean up unpublished stores when application initialization fails. Retain published locks through Shutdown because HTTP users may remain live. Document coordinated upgrades and the advisory local-filesystem boundary. Keep GORM revisions unsupported pending a separate approved design. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Codex:gpt-5 * test(application): isolate warning log capture Run warning assertions in isolated test subprocesses. The suite leaves background log producers running, which race with global logger changes. Keep the parent logger unchanged and check child exits and completion markers. Preserve pin persistence and ownership warning assertions. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(studio): resolve local owner execution bindings Resolve execution providers from owner-held chat and agent snapshots. Fence bindings with selection epochs, mutation tokens and live runtime identity. Recompute every component during status authorization. Keep named-user and SQL observations unavailable until authoritative access and shared selection revisions can detect ABA changes. Drain restart test producers before releasing JSON store ownership. Register six product resolver cases in the gateway suite inventory. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(studio): bound product scopes to chat turns Keep request-local product state alive through tool calls, checkpoints and persistence. Give chat priming its own scope instead of seeding the next turn with a closed observation. Exercise one reducer read per scope across prompt and tool consumers. Check closure of pending reads on normal completion and interruption. Provider wiring and safety projection remain separate work. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(studio): consume typed execution providers Replace the Sandbox-specific status heuristic with owner-bound typed reads. Share one reducer observation across turn consumers and close it at the outer scope boundary. Keep priming and standalone requests independent. Sandbox exposes a separate callback-free typed read while preserving its private Settings payload. Unknown backends never imply isolation. Provider isolation does not prove host network-policy enforcement. Local-owner JSON bindings are supported. Named-user and SQL bindings remain explicitly unavailable and fail closed. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(application): isolate auth startup lifetime Shutdown does not join every background producer. Auth startup tests can therefore delete their temporary directory while a writer remains. Run each case in a bounded child process. The parent waits for child exit before deleting the fixture, preserving every startup assertion. Production shutdown and JSON ownership retention remain unchanged. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ui): resolve installed Home app commands Project Home navigation from installed addon declarations, not product IDs. Reserve native and skill names and suppress every colliding addon alias. Re-read the authenticated registry before navigation and reject stale actors, generations, immutable entries and destinations. Keep mounted consumers polling through disabled and failed states. Abort pending reads on actor changes and ignore replies from obsolete request epochs. Studio declares its own alias. Preserve legacy redirects and pin migration. Add unit coverage, localized Home copy and command lifecycle documentation. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(ui): accept resolved Home command metadata Apps resolves inherited titles beyond the explicit manifest label cap. Bound resolved labels to 147 Unicode characters, including secondary pages. Keep the backend's 60-character limit for explicit manifest labels. Match packaged icon paths to the contract's ASCII alphabet and image types. Accept dotted names while rejecting traversal, encodings and foreign assets. Verify 68 focused cases, 657 total cases, lint, i18n checks and the UI build. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(ui): bind safety notices to actual providers Warn for addon host execution and unknown isolation without changing tool permissions or built-in host consent. Reset dismissal when the admitted binding, observation, actor or chat context changes. Expose presentation-only status identity from the admitted request. Keep configured network policy visible when enforcement is unverified. Render bounded provider explanations as escaped React text. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(ui): isolate command status across sessions Invalidate command observations when actors or authentication sessions change, before effects can expose a previous actor’s reply. Keep actual host execution warnings generic instead of recommending Sandbox installation or repair. Cover both regressions with source-hook lifecycle tests and provider notice assertions. Assisted-by: Codex:gpt-5 [functions] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): declare audited host requests Declare actual official hostclient operations without inferring requests from deployment grants. Keep optional settings and egress checks separate from required workspace and job operations. Verify all twelve manifests through strict package fixtures and assert that requests never self-grant. Guard generic provider and Home dispatch without banning historical routes, native Studio, or deployment policy. Document the audited operations and unresolved named-user and SQL gaps. Refresh Sandbox catalog metadata for its existing typed status tool. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(addons): wire exact extension inventories and real provider selection Run contract, official packaging and root architecture gates through actual report inventories. Exercise two installed execution providers through the real owner resolver and distinguish callback pass-through coverage. Share strict report validation with focused-scope handling and missing/skipped artifact self-tests; document direct evidence and remaining harness limits. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(ui): expire registry reads on auth refresh Bind registry authority to the actual authentication session epoch. Same-user refresh now rejects old polls and pending Home navigation before effect cleanup, even when permissions and metadata stay unchanged. Exercise both races through the production registry provider harness. Verify 59 focused tests, 669 total tests, lint, i18n, and the UI build. Assisted-by: Codex:gpt-5 [functions] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(studio): resolve current execution selection Use existing principal-scoped chat and agent reads for advisory status. Revalidate current named credentials instead of requiring historical SQL revisions. Local-owner mode also works with the existing GORM store. Remove resolver nonces and historical revision prerequisites. Keep exact runtime binding and existing status admission hooks. Document current-read consistency without promising revoke-and-restore detection. Verify the SQLite regression fails before the fix. Two separate full race runs pass: application 262 specs (3 skips), auth 257, gateway 115, tools 91. Cover named chat, API key and session credentials and current-state denial. Assisted-by: Codex:gpt-5 [functions] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactor(addons): drop selection revision tracking Use current selection comparisons for advisory status instead of mutation history. Keep actor, credential and conversation identity explicit in the model. Hash current inputs at the gateway without exposing raw store data. Remove unused epochs, revision interfaces and JSON ownership lifetimes. Preserve current authorization, runtime identity, mutexes and atomic writes. Document current-read semantics without historical ABA guarantees. Verify all affected packages with the race detector and auth tag. Check the trace viewer, prerequisite self-tests and 18 chat-command unit tests. Assisted-by: Codex:gpt-5 [functions] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(ui): scope Apps reads and provider status Bind Apps replies and registry ingestion to their originating auth session. Reject late replies before effect cleanup on refresh or account switch. Use selected-provider command DTOs in the composer instead of private Sandbox discovery. Keep unknown isolation unknown and remove specific add-on installation recommendations from the affected native surfaces. Test production Apps, registry, switches and composer paths with controlled cleanup and two arbitrary providers. Update the matching feature docs. Assisted-by: Codex:gpt-5 [functions] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(ui): guard composer and session publication Remove the deleted Sandbox variable from the full composer render. Report unknown command isolation explicitly in the about card. Bind Apps records and admin action results to their originating actor and actual authentication epoch before cleanup can run. Reject stale request admission and verification without changing server authorization. Add full-component render and pending session-transition regressions. Enable no-undef for the production composer in normal UI lint. Assisted-by: Codex:gpt-5 [functions] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(ui): expire cancelled upgrade reconciliation Bind cancellation reconciliation to the initiating session authority. Discard status replies after expiry without publishing old outcomes. Match the exact operation identity before clearing a held action. Cover pending cancellation reads, expiry before reads, and preservation of a newer attempt with the same action key. Assisted-by: Codex:gpt-5 [functions] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(ui): bind background upgrades to auth lifetime Keep background upgrade authority in AuthProvider, independent of views. Expire pending observations before auth consumers render or logout waits. Preserve same-session navigation and exact-operation cleanup. Cover cancellation, completion, and verification after the origin unmounts with actual provider transitions and the real upgrade store. Thread captured authority through Activity retries and document expiry. Assisted-by: Codex:gpt-5 [functions] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactor(addons): remove implicit sidebar pins Start every account without implicit Studio or Talk pins. Preserve saved user selections and durable explicit pins across reopen and frontends. Remove startup seeding, seed metadata APIs, and first-install exceptions. Apply equal retention to all absent IDs without dropping database tables. Keep store initialization, cached errors, and the leader sweep unchanged. Verify genuine RED regressions before removal. Race checks pass for all 42 pin specs, 10 focused application specs, and 10 pin endpoint specs. Update pin documentation; renamed specs have no exact inventory entries. Assisted-by: Codex:gpt-5 [functions] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): declare typed model destinations Let installed addons own model launch destinations without teaching core about addon identities or private routes. Keep native chat and completion outside the declaration vocabulary. Validate strict keys, unique capabilities, declared pages and SDK route bounds. Carry cloned typed metadata through Apps using the shared contract type, and add owning Studio, Talk, Face and Voice declarations. Verify strict contract RED before implementation, then 1276 passing specs across contract, Apps, packages, runtime, protocol, config and manager. Verify 178 packaging and architecture specs and exact carrier inventories. Frontend dispatch and route cleanup remain a separate milestone. Assisted-by: Codex:gpt-5 [functions] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactor(ui): resolve declared model launch actions Resolve installed model actions through the shared addon registry. Keep native chat and completion launches, and never promote absent providers. Refresh actor-filtered Apps before navigation and reject stale authority, provider metadata, generation, immutable entry and destination claims. Use named menu choices for collisions without loading addon modules. Remove unreleased addon-specific redirects and migrate browser fixtures to canonical declared pages. Preserve native chat handoff and addon-local navigation adapters. Update user documentation and historical plans. Verify three production UI consumers fail against their previous code, then pass all 13 mocked-HTTP browser regressions. Verify 696 host unit cases, full quiet lint, targeted no-undef, locales, inline styles and build. Full backend/browser integration remains outside these mocked-HTTP checks. Assisted-by: Codex:gpt-5 [functions] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(addons): validate Ginkgo hooks before focus Ginkgo emits null container hierarchies for suite hooks. Require hooks\nto pass before applying focus only to It cases. Normalize null\nhierarchies without weakening exact required-case comparisons.\n\nRun reporter regressions in prerequisites. Register the declared uiOnly\ntool case and remove an update fixture case excluded by the existing\nmanager focus. Both inventories match the complete CI case output.\n\nVerify 12 reporter regressions, 39 prerequisite cases, three existing\nreports, and fresh runtime, tools and focused update manager suites. Assisted-by: Codex:gpt-5 [functions] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(addons): restore Go CI contract checks Align route fixtures with explicit pins and declared network controls. Keep network enforcement unknown instead of inferring it from isolation. Propagate viewer output errors and select output paths with CLI arguments. Check test writes and use Ginkgo assertions without copying atomic state. Register the three migrated binding cases in the exact suite inventory. Document two narrow security false positives for administrator file access and symbolic credentials in reducer traces. Focused race tests pass. The available golangci-lint reports zero issues in affected packages. Assisted-by: OpenAI ChatGPT [functions] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(ui): align browser coverage with addon contracts The generic provider refactor removes implicit install advice and private Sandbox discovery. Update browser fixtures to use the installed command metadata and typed execution observations without restoring those paths. Keep command warnings, consent, disabled switches and privacy assertions. Home now holds a live command consumer. Exercise polling shutdown on a route without that consumer, after its lazy transition finishes. Preserve the bounded cadence, error backoff and disable-stop assertions. Restore the remembered-voices link through the addon's compatibility router. Cover native migrations and NotFound for removed generator aliases. Validation: 293 affected browser cases and three native service cases pass. The gallery command passes all 74 inventoried cases. Full UI units, quiet lint, locale parity, production build and diff checks pass. Assisted-by: OpenAI ChatGPT [functions] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(addons): bind status to owner provider policy Resolve typed status with the same current owner pins as execution. Fail closed on owner policy outages and fingerprint authoritative pins. Exercise CAS updates, conflicts, stale bindings, and policy outages. Retain host settings when the named-account fixture replaces its gateway. Refresh exact gateway and Apps inventories from passing reports. Validation: the new regression fails before the fix. All 16 focused binding and policy cases pass. Gateway passes 361 exact cases and Apps passes 265 exact cases across their configured package reports. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactor(addons): share qualified tool identities Use one formatter and contract-component parser for add-on identities. Keep callable names bounded to 64 characters while recognizing long unavailable stand-ins without granting dispatch authority. Validate selected execution names and their provider ID at the owner binding boundary. The real adapter already emits canonical identities. Update exact importer and case inventories from passing reports. Migrate the binding backend test to Ginkgo and check shutdown errors reported by lint in the affected packages. Validation: behavioral RED proves malformed stand-in acceptance and plain selected-name acceptance. Affected full race suites pass: tools 94 and Studio 638, with zero skips. Application passes 275 of 278; three existing PostgreSQL cases skip without their configured URL. All 12 owner-binding cases pass and match the exact inventory. Scoped golangci-lint reports no issues. Assisted-by: Codex:gpt-5 golangci-lint Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactor(addons): share execution declarations Share pure declaration predicates across manifest validation, gateway observations, and owner bindings. Keep authorization and exact-runtime admission checks local. Reuse the strict manifest schema validator so raw status definitions fail closed without changing manifest acceptance. Share the workspace-operation prefix scan without changing grant sources. Add declaration parity and workspace-prefix tables and update exact inventories from passing reports. Validation: two new gateway cases fail before the fix; all 14 pass after. Race tests pass: contract 245, gateway 136, tools 94, and focused application binding/workspace cases 22. Scoped golangci-lint: 0 issues. Assisted-by: Codex:gpt-5 golangci-lint Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(addons): retain document skill cases Match the contract inventory to the passing report after rebasing.\nKeep the new document-provider case and updated reserved-name cases. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: OpenAI:gpt-6.1-sol * test(addons): follow owner-managed upgrades Accepted upgrades belong to the owner and cannot be cancelled by UI. Exercise observer expiry during reads, completion and verification. Supply the real upgrade store to source-executed auth harnesses so logout reaches its synchronous authority invalidation. Match whole-package inventories to fresh Manager and gateway reports. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
1 parent
10722d8e3b
commit
7904b3d0ab
278 files changed
+14536
-2351
No files matched your search
@@ -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 .
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,12 @@
|
||||
},
|
||||
"arch": "amd64",
|
||||
"capabilities": {
|
||||
"hostOperations": {
|
||||
"required": [
|
||||
"workspace.export",
|
||||
"workspace.import"
|
||||
]
|
||||
},
|
||||
"actions": true,
|
||||
"skillDefinitions": [
|
||||
{
|
||||
|
||||
@@ -22,6 +22,15 @@
|
||||
"max": 1
|
||||
},
|
||||
"capabilities": {
|
||||
"hostOperations": {
|
||||
"required": [
|
||||
"quantize.get",
|
||||
"quantize.import",
|
||||
"quantize.list",
|
||||
"quantize.start",
|
||||
"quantize.stop"
|
||||
]
|
||||
},
|
||||
"tools": [
|
||||
"resource-exists",
|
||||
"quantize-list",
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
},
|
||||
"arch": "amd64",
|
||||
"capabilities": {
|
||||
"hostOperations": {
|
||||
"optional": [
|
||||
"workspace.admin"
|
||||
]
|
||||
},
|
||||
"toolDefinitions": [
|
||||
{
|
||||
"name": "search",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
})
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"))
|
||||
})
|
||||
|
||||
+12
-7
@@ -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),
|
||||
|
||||
@@ -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"))
|
||||
})
|
||||
})
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
@@ -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)))
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"))
|
||||
})
|
||||
})
|
||||
@@ -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"))
|
||||
})
|
||||
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
})
|
||||
@@ -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 })
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+2
-3
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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"]')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.')
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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}'],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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." }
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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." }
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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." }
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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." }
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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." }
|
||||
}
|
||||
@@ -584,7 +584,8 @@
|
||||
"context": "컨텍스트",
|
||||
"contextOf": "{{limit}} 토큰 중 {{used}}",
|
||||
"contextUsed": "{{used}} 토큰",
|
||||
"unknown": "아직 알 수 없음"
|
||||
"unknown": "아직 알 수 없음",
|
||||
"commandsUnknown": "명령 격리 상태를 알 수 없습니다"
|
||||
},
|
||||
"agentFolder": {
|
||||
"home": "홈",
|
||||
|
||||
@@ -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}}개",
|
||||
|
||||
@@ -117,6 +117,14 @@
|
||||
"allChats": "모든 채팅"
|
||||
},
|
||||
"slash": {
|
||||
"commandDiagnostics": "앱 명령 진단",
|
||||
"apps": {
|
||||
"label": "앱",
|
||||
"desc": "설치된 앱 찾아보기"
|
||||
},
|
||||
"group": {
|
||||
"addons": "앱"
|
||||
},
|
||||
"agents": {
|
||||
"label": "에이전트",
|
||||
"desc": "저장된 에이전트, 트리거, 스킬"
|
||||
@@ -130,5 +138,6 @@
|
||||
"desc": "채팅이 검색할 수 있는 컬렉션"
|
||||
},
|
||||
"complete": "완성"
|
||||
}
|
||||
},
|
||||
"notice": { "appUnavailable": "앱을 더 이상 사용할 수 없습니다." }
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Loaded 100 of 278 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user