From 7904b3d0abeb49b882c7132fcaef7505e94dac29 Mon Sep 17 00:00:00 2001 From: mudler-agent Date: Tue, 3 Nov 2026 00:50:40 +0100 Subject: [PATCH] feat(addons): declare typed host capabilities (#13034) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 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 --------- Signed-off-by: Ettore Di Giacinto Co-authored-by: Ettore Di Giacinto --- Dockerfile | 3 + addon_host_policy_architecture_test.go | 102 + addons/face/manifest.json | 1 + addons/fine-tuning/manifest.json | 11 + addons/internal/host-compat/src/route.js | 1 + .../internal/host-compat/test/route.test.mjs | 5 + addons/internal/protocol/extensions_test.go | 66 + addons/office/manifest.json | 6 + addons/quantization/manifest.json | 9 + .../internal/tools/executionstatus_test.go | 39 + addons/sandbox/internal/tools/tools.go | 29 +- addons/sandbox/internal/tools/tools_test.go | 4 +- addons/sandbox/manifest.json | 32 +- addons/studio/manifest.json | 2 + addons/talk/manifest.json | 1 + addons/voice/manifest.json | 1 + addons/web/manifest.json | 5 + core/application/addon_gateway.go | 77 +- .../addon_pin_store_internal_test.go | 72 +- core/application/addon_pins.go | 36 +- core/application/addon_pins_test.go | 74 +- core/application/addons_auth_test.go | 99 +- core/application/application.go | 1 - core/application/execution_binding.go | 136 + core/application/execution_binding_test.go | 441 +++ core/application/startup_auth_test.go | 57 +- core/application/studio_execution.go | 81 + core/application/studio_service.go | 2 +- core/application/studio_store_test.go | 6 +- core/cli/run.go | 19 +- core/cli/run_addon_host_ops_test.go | 113 +- core/cli/run_addon_host_policy.go | 64 + core/config/addon_host_ops.go | 14 +- core/config/addon_host_ops_test.go | 15 +- core/config/addon_host_policy.go | 201 ++ core/config/addon_host_policy_test.go | 141 + core/config/addon_model_actions_test.go | 25 + core/config/application_config.go | 4 +- core/config/chat_selection.go | 62 + core/config/chat_selection_shared_test.go | 33 + core/config/chat_selection_test.go | 46 + core/config/runtime_settings_registry.go | 8 +- core/http/endpoints/addons/pins_test.go | 13 +- core/http/endpoints/studio/addons.go | 3 + .../react-ui/e2e/addon-model-actions.spec.js | 66 + .../react-ui/e2e/addon-navigation.spec.js | 48 +- core/http/react-ui/e2e/addons-gallery.spec.js | 11 +- .../http/react-ui/e2e/audio-transform.spec.js | 18 +- core/http/react-ui/e2e/chats-commands.spec.js | 39 +- core/http/react-ui/e2e/chats-egress.spec.js | 4 +- .../http/react-ui/e2e/chats-host-exec.spec.js | 10 +- .../react-ui/e2e/chats-switches-real.spec.js | 11 +- core/http/react-ui/e2e/chats-ui.spec.js | 33 +- core/http/react-ui/e2e/chrome-audit.spec.js | 4 +- .../react-ui/e2e/diarization-profiles.spec.js | 9 +- core/http/react-ui/e2e/home-console.spec.js | 38 +- .../react-ui/e2e/legacy-redirects.spec.js | 57 +- core/http/react-ui/e2e/media-history.spec.js | 20 +- core/http/react-ui/e2e/navigation.spec.js | 2 +- core/http/react-ui/e2e/no-edge-rails.spec.js | 6 +- .../react-ui/e2e/page-render-smoke.spec.js | 4 +- core/http/react-ui/e2e/settings-tools.spec.js | 2 +- .../http/react-ui/e2e/sound-expansion.spec.js | 2 +- core/http/react-ui/e2e/studio-addon.js | 5 +- core/http/react-ui/e2e/studio-addons.spec.js | 10 +- .../http/react-ui/e2e/studio-composer.spec.js | 100 +- core/http/react-ui/e2e/studio-lineage.spec.js | 10 +- .../http/react-ui/e2e/studio-overview.spec.js | 28 +- .../react-ui/e2e/studio-transform.spec.js | 2 +- core/http/react-ui/e2e/studio-work.spec.js | 4 +- .../react-ui/e2e/studio-workbench.spec.js | 8 +- .../react-ui/e2e/studio-workspace.spec.js | 52 +- core/http/react-ui/e2e/talk.spec.js | 26 +- core/http/react-ui/e2e/taste-polish.spec.js | 2 +- .../react-ui/e2e/threed-animation.spec.js | 12 +- core/http/react-ui/e2e/threed-gen.spec.js | 19 +- core/http/react-ui/eslint.config.js | 1 + core/http/react-ui/i18n-baseline.json | 16 +- .../http/react-ui/public/locales/de/chat.json | 3 +- .../react-ui/public/locales/de/chats.json | 12 +- .../http/react-ui/public/locales/de/home.json | 11 +- .../http/react-ui/public/locales/en/chat.json | 3 +- .../react-ui/public/locales/en/chats.json | 12 +- .../http/react-ui/public/locales/en/home.json | 12 +- .../http/react-ui/public/locales/es/chat.json | 3 +- .../react-ui/public/locales/es/chats.json | 12 +- .../http/react-ui/public/locales/es/home.json | 11 +- .../http/react-ui/public/locales/fr/chat.json | 3 +- .../react-ui/public/locales/fr/chats.json | 12 +- .../http/react-ui/public/locales/fr/home.json | 11 +- .../http/react-ui/public/locales/id/chat.json | 3 +- .../react-ui/public/locales/id/chats.json | 12 +- .../http/react-ui/public/locales/id/home.json | 11 +- .../http/react-ui/public/locales/it/chat.json | 3 +- .../react-ui/public/locales/it/chats.json | 12 +- .../http/react-ui/public/locales/it/home.json | 11 +- .../http/react-ui/public/locales/ko/chat.json | 3 +- .../react-ui/public/locales/ko/chats.json | 12 +- .../http/react-ui/public/locales/ko/home.json | 11 +- .../react-ui/public/locales/pt-BR/chat.json | 3 +- .../react-ui/public/locales/pt-BR/chats.json | 12 +- .../react-ui/public/locales/pt-BR/home.json | 11 +- .../react-ui/public/locales/zh-CN/chat.json | 3 +- .../react-ui/public/locales/zh-CN/chats.json | 12 +- .../react-ui/public/locales/zh-CN/home.json | 11 +- core/http/react-ui/src/addons/ModelLaunch.jsx | 29 + core/http/react-ui/src/addons/addresses.js | 9 + core/http/react-ui/src/addons/homeCommands.js | 120 + .../src/addons/hostControlComponents.jsx | 2 +- core/http/react-ui/src/addons/modelActions.js | 62 + core/http/react-ui/src/addons/registry.jsx | 90 +- .../react-ui/src/addons/registryRequests.js | 37 + .../http/react-ui/src/addons/registryState.js | 10 +- .../react-ui/src/addons/useAddonRegistry.js | 9 + .../src/components/addons/AppsView.jsx | 51 +- .../src/components/addons/useAddonActions.js | 85 +- .../src/components/addons/useAddonUpgrade.js | 45 +- .../src/components/chat/ChatComposer.jsx | 46 +- .../src/components/chat/CommandCards.jsx | 2 +- .../components/chat/CommandsConsentCard.jsx | 5 +- .../src/components/chat/CommandsNotice.jsx | 1 + .../src/components/chat/NetworkRow.jsx | 7 +- .../src/components/home/HomeComposer.jsx | 26 +- .../src/components/home/HomeFirstRun.jsx | 45 +- .../src/components/home/homeActions.js | 13 +- .../components/models/page/OverviewTab.jsx | 6 +- .../settings/BuiltinToolsSection.jsx | 5 +- .../http/react-ui/src/context/AuthContext.jsx | 29 +- .../react-ui/src/hooks/useChatComposer.js | 5 +- .../react-ui/src/hooks/useChatSwitches.js | 37 +- .../react-ui/src/hooks/useCommandsNotice.js | 26 +- .../react-ui/src/hooks/useOperationActions.js | 8 +- .../react-ui/src/hooks/useSandboxStatus.js | 60 - core/http/react-ui/src/pages/Home.jsx | 51 +- .../react-ui/src/pages/InstalledModels.jsx | 28 +- core/http/react-ui/src/pages/Models.jsx | 10 +- core/http/react-ui/src/router.jsx | 78 +- core/http/react-ui/src/utils/addonUpgrades.js | 43 +- core/http/react-ui/src/utils/chatCommands.js | 75 +- .../http/react-ui/src/utils/chatConnectors.js | 2 +- core/http/react-ui/src/utils/chatEgress.js | 2 +- core/http/react-ui/src/utils/legacyRoutes.js | 65 +- core/http/react-ui/src/utils/sandboxStatus.js | 26 - core/http/react-ui/src/utils/slashCommands.js | 9 +- .../tests/unit/addon-session.test.mjs | 244 ++ .../tests/unit/addonhomecommands.test.mjs | 277 ++ .../tests/unit/addonmodelactions.test.mjs | 49 + .../tests/unit/chatcommands-lifecycle.mjs | 44 + .../react-ui/tests/unit/chatcommands.test.mjs | 254 +- .../react-ui/tests/unit/chategress.test.mjs | 6 +- .../react-ui/tests/unit/chatfeatures.test.mjs | 26 +- .../tests/unit/composer-render.test.mjs | 42 + core/http/react-ui/tests/unit/esm.test.mjs | 4 +- .../tests/unit/homeassistant.test.mjs | 2 +- .../react-ui/tests/unit/legacyroutes.test.mjs | 123 +- core/http/react-ui/tests/unit/slots.test.mjs | 4 +- core/http/routes/addon_navigation_test.go | 5 +- core/http/routes/studio_egress_test.go | 17 +- core/schema/chats.go | 11 + core/services/addons/admission.go | 55 + core/services/addons/apps/apps.go | 68 +- core/services/addons/apps/extensions_test.go | 91 + core/services/addons/apps/installed.go | 10 +- .../services/addons/apps/modelactions_test.go | 41 + .../addons/extensions_lifecycle_test.go | 277 ++ .../services/addons/gateway/admission_test.go | 44 + core/services/addons/gateway/binding.go | 148 + core/services/addons/gateway/binding_test.go | 161 + core/services/addons/gateway/carrier.go | 74 +- .../addons/gateway/extensions_test.go | 141 + core/services/addons/gateway/gateway.go | 18 +- core/services/addons/gateway/library.go | 2 + core/services/addons/gateway/observation.go | 286 ++ .../gateway/observation_runtime_test.go | 220 ++ .../addons/gateway/observation_test.go | 278 ++ .../addons/gateway/ownerfixture_test.go | 27 +- core/services/addons/hostops/admission.go | 58 + .../services/addons/hostops/admission_test.go | 93 + core/services/addons/hostops/broker.go | 57 + core/services/addons/hostops/inflight.go | 94 +- .../addons/hostops/observation_test.go | 267 ++ core/services/addons/management.go | 5 + core/services/addons/manager.go | 72 +- core/services/addons/manager_host_ops_test.go | 26 + core/services/addons/packages/discard_test.go | 28 + .../addons/packages/extensions_test.go | 93 + core/services/addons/packages/receipt.go | 19 + core/services/addons/pins/pins.go | 27 - core/services/addons/pins/service.go | 4 +- .../services/addons/pins/service_gorm_test.go | 23 +- core/services/addons/pins/service_test.go | 23 +- .../addons/pins/store_contract_test.go | 58 +- core/services/addons/pins/store_file.go | 61 +- core/services/addons/pins/store_file_test.go | 9 +- core/services/addons/pins/store_gorm.go | 66 +- core/services/addons/pins/sweep.go | 22 - core/services/addons/pins/sweep_test.go | 91 +- core/services/addons/recovery.go | 6 + core/services/addons/runtime/OBSERVATIONS.md | 105 + core/services/addons/runtime/client.go | 19 +- .../addons/runtime/extensions_test.go | 134 + core/services/addons/runtime/observation.go | 88 + .../runtime/observation_admission_test.go | 67 + .../addons/runtime/observation_test.go | 182 ++ core/services/addons/runtime/supervisor.go | 15 +- .../addons/runtime/supervisor_test.go | 2 +- core/services/addons/state.go | 12 +- core/services/addons/tools/adapter.go | 30 +- core/services/addons/tools/binding.go | 45 + core/services/addons/tools/binding_test.go | 53 + .../addons/tools/consumer_matrix_test.go | 3 + .../services/addons/tools/definitions_test.go | 15 + core/services/addons/tools/toolnames_test.go | 44 + core/services/addons/tools_test.go | 3 + core/services/addons/updates_test.go | 65 +- core/services/studio/addontools.go | 12 +- core/services/studio/agents/json.go | 3 +- core/services/studio/capabilities.go | 15 +- core/services/studio/capabilities_test.go | 45 + core/services/studio/egress_test.go | 8 +- core/services/studio/egressapi.go | 5 +- core/services/studio/executionobservation.go | 96 + .../studio/executionobservation_test.go | 161 + .../studio/executionrequest/README.md | 43 + .../studio/executionrequest/request.go | 217 ++ .../studio/executionrequest/request_test.go | 376 +++ core/services/studio/executionselection.go | 47 + .../studio/executionselection_test.go | 27 + core/services/studio/service.go | 2 +- core/services/studio/session/chat.go | 12 + core/services/studio/session/chattools.go | 5 + core/services/studio/session/manager.go | 8 + .../studio/session/productscope_test.go | 192 ++ core/services/studio/store/json.go | 34 +- .../studio/toolnames_internal_test.go | 22 + core/services/studio/triggersrestart_test.go | 12 +- deploy/addons/host-policy.json | 11 + docs/content/development/addon-testing.md | 100 + docs/content/features/addons.md | 563 +++- docs/content/features/chats-agents.md | 8 + docs/content/features/chats.md | 11 +- docs/content/features/face-recognition.md | 2 +- docs/content/features/fine-tuning.md | 4 +- docs/content/features/quantization.md | 4 +- docs/content/features/studio.md | 4 +- docs/content/features/voice-recognition.md | 2 +- .../plans/2026-10-16-addons-navigation.md | 12 + .../2026-10-16-addons-navigation-design.md | 31 +- gallery/addons/index.yaml | 6 +- pkg/addons/contract/README.md | 62 + pkg/addons/contract/exactkeys.go | 94 + pkg/addons/contract/exactkeys_test.go | 59 + pkg/addons/contract/executionstatus.go | 52 + pkg/addons/contract/executionstatus_test.go | 74 + pkg/addons/contract/extensions.go | 159 + pkg/addons/contract/extensions_test.go | 183 ++ pkg/addons/contract/manifest.go | 21 +- pkg/addons/contract/modelactions.go | 86 + pkg/addons/contract/modelactions_test.go | 108 + pkg/addons/model/executionstatus/README.md | 65 + .../model/executionstatus/cmd/viewer/main.go | 233 ++ .../executionstatus/cmd/viewer/main_test.go | 148 + .../cmd/viewer/viewer.tmpl.html | 38 + pkg/addons/model/executionstatus/explore.go | 201 ++ pkg/addons/model/executionstatus/gen.go | 7 + .../model/executionstatus/model_test.go | 173 ++ pkg/addons/model/executionstatus/reducer.go | 83 + pkg/addons/model/executionstatus/traces.go | 258 ++ pkg/addons/model/executionstatus/types.go | 192 ++ pkg/addons/packaging/host_policy_test.go | 35 + .../packaging/official_contract_test.go | 96 + pkg/addons/packaging/packaging_test.go | 4 +- scripts/addons/ginkgo_report.py | 34 + scripts/addons/ginkgo_report_test.py | 85 + scripts/addons/prerequisites_test.sh | 18 +- scripts/addons/suites.json | 2715 +++++++++++++---- scripts/addons/verify.sh | 20 +- tests/addons/fixture/cmd/main.go | 18 +- 278 files changed, 14536 insertions(+), 2351 deletions(-) create mode 100644 addon_host_policy_architecture_test.go create mode 100644 addons/internal/protocol/extensions_test.go create mode 100644 addons/sandbox/internal/tools/executionstatus_test.go create mode 100644 core/application/execution_binding.go create mode 100644 core/application/execution_binding_test.go create mode 100644 core/application/studio_execution.go create mode 100644 core/cli/run_addon_host_policy.go create mode 100644 core/config/addon_host_policy.go create mode 100644 core/config/addon_host_policy_test.go create mode 100644 core/config/addon_model_actions_test.go create mode 100644 core/config/chat_selection.go create mode 100644 core/config/chat_selection_shared_test.go create mode 100644 core/config/chat_selection_test.go create mode 100644 core/http/react-ui/e2e/addon-model-actions.spec.js create mode 100644 core/http/react-ui/src/addons/ModelLaunch.jsx create mode 100644 core/http/react-ui/src/addons/addresses.js create mode 100644 core/http/react-ui/src/addons/homeCommands.js create mode 100644 core/http/react-ui/src/addons/modelActions.js create mode 100644 core/http/react-ui/src/addons/registryRequests.js delete mode 100644 core/http/react-ui/src/hooks/useSandboxStatus.js delete mode 100644 core/http/react-ui/src/utils/sandboxStatus.js create mode 100644 core/http/react-ui/tests/unit/addon-session.test.mjs create mode 100644 core/http/react-ui/tests/unit/addonhomecommands.test.mjs create mode 100644 core/http/react-ui/tests/unit/addonmodelactions.test.mjs create mode 100644 core/http/react-ui/tests/unit/chatcommands-lifecycle.mjs create mode 100644 core/http/react-ui/tests/unit/composer-render.test.mjs create mode 100644 core/services/addons/admission.go create mode 100644 core/services/addons/apps/extensions_test.go create mode 100644 core/services/addons/apps/modelactions_test.go create mode 100644 core/services/addons/extensions_lifecycle_test.go create mode 100644 core/services/addons/gateway/admission_test.go create mode 100644 core/services/addons/gateway/binding.go create mode 100644 core/services/addons/gateway/binding_test.go create mode 100644 core/services/addons/gateway/extensions_test.go create mode 100644 core/services/addons/gateway/observation.go create mode 100644 core/services/addons/gateway/observation_runtime_test.go create mode 100644 core/services/addons/gateway/observation_test.go create mode 100644 core/services/addons/hostops/admission.go create mode 100644 core/services/addons/hostops/admission_test.go create mode 100644 core/services/addons/hostops/observation_test.go create mode 100644 core/services/addons/manager_host_ops_test.go create mode 100644 core/services/addons/packages/discard_test.go create mode 100644 core/services/addons/packages/extensions_test.go create mode 100644 core/services/addons/runtime/OBSERVATIONS.md create mode 100644 core/services/addons/runtime/extensions_test.go create mode 100644 core/services/addons/runtime/observation.go create mode 100644 core/services/addons/runtime/observation_admission_test.go create mode 100644 core/services/addons/runtime/observation_test.go create mode 100644 core/services/addons/tools/binding.go create mode 100644 core/services/addons/tools/binding_test.go create mode 100644 core/services/addons/tools/toolnames_test.go create mode 100644 core/services/studio/executionobservation.go create mode 100644 core/services/studio/executionobservation_test.go create mode 100644 core/services/studio/executionrequest/README.md create mode 100644 core/services/studio/executionrequest/request.go create mode 100644 core/services/studio/executionrequest/request_test.go create mode 100644 core/services/studio/executionselection.go create mode 100644 core/services/studio/executionselection_test.go create mode 100644 core/services/studio/session/productscope_test.go create mode 100644 core/services/studio/toolnames_internal_test.go create mode 100644 deploy/addons/host-policy.json create mode 100644 pkg/addons/contract/README.md create mode 100644 pkg/addons/contract/exactkeys.go create mode 100644 pkg/addons/contract/exactkeys_test.go create mode 100644 pkg/addons/contract/executionstatus.go create mode 100644 pkg/addons/contract/executionstatus_test.go create mode 100644 pkg/addons/contract/extensions.go create mode 100644 pkg/addons/contract/extensions_test.go create mode 100644 pkg/addons/contract/modelactions.go create mode 100644 pkg/addons/contract/modelactions_test.go create mode 100644 pkg/addons/model/executionstatus/README.md create mode 100644 pkg/addons/model/executionstatus/cmd/viewer/main.go create mode 100644 pkg/addons/model/executionstatus/cmd/viewer/main_test.go create mode 100644 pkg/addons/model/executionstatus/cmd/viewer/viewer.tmpl.html create mode 100644 pkg/addons/model/executionstatus/explore.go create mode 100644 pkg/addons/model/executionstatus/gen.go create mode 100644 pkg/addons/model/executionstatus/model_test.go create mode 100644 pkg/addons/model/executionstatus/reducer.go create mode 100644 pkg/addons/model/executionstatus/traces.go create mode 100644 pkg/addons/model/executionstatus/types.go create mode 100644 pkg/addons/packaging/host_policy_test.go create mode 100644 pkg/addons/packaging/official_contract_test.go create mode 100644 scripts/addons/ginkgo_report.py create mode 100644 scripts/addons/ginkgo_report_test.py diff --git a/Dockerfile b/Dockerfile index e92ba1f01..935bc0e6c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -416,6 +416,9 @@ ENV NVIDIA_VISIBLE_DEVICES=all WORKDIR / +COPY deploy/addons/host-policy.json /etc/localai/addons/host-policy.json +ENV LOCALAI_ADDON_HOST_POLICY=/etc/localai/addons/host-policy.json + COPY ./entrypoint.sh . COPY ./scripts/build/healthcheck.sh . diff --git a/addon_host_policy_architecture_test.go b/addon_host_policy_architecture_test.go new file mode 100644 index 000000000..40a9aa3d8 --- /dev/null +++ b/addon_host_policy_architecture_test.go @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: MIT +package localai_test + +import ( + "go/ast" + "go/parser" + "go/token" + "io/fs" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestHostPolicyArchitecture(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Host policy architecture") +} + +var _ = Describe("generic host policy architecture", func() { + It("keeps official addon identities out of generic CLI and config policy code", func() { + // Tests may retain historical fixtures. Deployment policy is deliberately + // outside these packages; unrelated model fields (e.g. quantization) are not IDs. + ids := []string{"fine-tuning", "quantization", "sandbox", "office", "web", "telegram"} + for _, root := range []string{"core/cli", "core/config"} { + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + file, err := parser.ParseFile(token.NewFileSet(), path, nil, 0) + if err != nil { + return err + } + policyCode := strings.Contains(filepath.Base(path), "addon_host_") || path == "core/cli/run.go" + ast.Inspect(file, func(node ast.Node) bool { + literal, ok := node.(*ast.BasicLit) + if !ok || literal.Kind != token.STRING { + return true + } + value, err := strconv.Unquote(literal.Value) + Expect(err).NotTo(HaveOccurred()) + for _, id := range ids { + Expect(value).NotTo(ContainSubstring(id+"="), "%s embeds an official grant", path) + if policyCode { + Expect(value).NotTo(Equal(id), "%s embeds an official addon identity", path) + } + } + Expect(value).NotTo(ContainSubstring("default:\"fine-tuning="), "%s embeds a CLI default", path) + return true + }) + return nil + }) + Expect(err).NotTo(HaveOccurred()) + } + }) +}) + +var _ = Describe("typed extension architecture", func() { + It("keeps provider and Home dispatch independent of official addon IDs", func() { + // Only generic live dispatch surfaces are guarded. Legacy URL redirects, + // pin migration, native Studio code, deployment policy, and tests are not + // live addon dispatch and deliberately do not belong to this list. + paths := []string{"core/services/addons/apps", "core/services/addons/gateway/binding.go", "core/services/studio/executionselection.go"} + for _, root := range paths { + Expect(filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + f, err := parser.ParseFile(token.NewFileSet(), path, nil, 0) + if err != nil { + return err + } + ast.Inspect(f, func(n ast.Node) bool { + lit, ok := n.(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + return true + } + value, err := strconv.Unquote(lit.Value) + Expect(err).NotTo(HaveOccurred()) + Expect(value).NotTo(BeElementOf("studio", "sandbox"), "%s hardcodes a product ID in generic dispatch", path) + return true + }) + return nil + })).To(Succeed()) + } + data, err := os.ReadFile("core/http/react-ui/src/addons/homeCommands.js") + Expect(err).NotTo(HaveOccurred()) + for _, literal := range []string{"'studio'", `"studio"`, "'sandbox'", `"sandbox"`} { + Expect(string(data)).NotTo(ContainSubstring(literal)) + } + }) +}) diff --git a/addons/face/manifest.json b/addons/face/manifest.json index 9aba07e9c..285557e6e 100644 --- a/addons/face/manifest.json +++ b/addons/face/manifest.json @@ -16,6 +16,7 @@ "sdk": { "min": 2, "max": 2 }, "pages": ["recognition"], "navigation": [], + "modelActions": [{"capability": "FLAG_FACE_RECOGNITION", "page": "recognition"}], "mainPage": "recognition", "icon": "ui/icon.png", "title": "Face", diff --git a/addons/fine-tuning/manifest.json b/addons/fine-tuning/manifest.json index b0bbe1f4d..9f714a9ed 100644 --- a/addons/fine-tuning/manifest.json +++ b/addons/fine-tuning/manifest.json @@ -22,6 +22,17 @@ "max": 1 }, "capabilities": { + "hostOperations": { + "required": [ + "finetune.checkpoints", + "finetune.dataset", + "finetune.export", + "finetune.get", + "finetune.list", + "finetune.start", + "finetune.stop" + ] + }, "tools": [ "resource-exists", "fine-tune-list", diff --git a/addons/internal/host-compat/src/route.js b/addons/internal/host-compat/src/route.js index 2228353c6..d8514817f 100644 --- a/addons/internal/host-compat/src/route.js +++ b/addons/internal/host-compat/src/route.js @@ -10,6 +10,7 @@ export const MOVED = Object.freeze([ { prefix: '/app/studio', addon: 'studio', page: 'studio' }, { prefix: '/app/talk', addon: 'talk', page: 'talk' }, + { prefix: '/app/voice', addon: 'voice', page: 'voice' }, ]) export function parseAddress(to) { diff --git a/addons/internal/host-compat/test/route.test.mjs b/addons/internal/host-compat/test/route.test.mjs index b05cb4652..a4245a174 100644 --- a/addons/internal/host-compat/test/route.test.mjs +++ b/addons/internal/host-compat/test/route.test.mjs @@ -38,3 +38,8 @@ test('params: the tab is the first segment, the model is ?model= or the second s assert.deepEqual(paramsOf({ path: 'images/by-hand', search: '' }), { tab: 'images', model: 'by-hand' }) assert.deepEqual(paramsOf({ path: 'images/by-hand', search: 'model=wins' }), { tab: 'images', model: 'wins' }) }) + +test('remembered voices opens the declared Voice addon page, not a removed host alias', () => { + assert.deepEqual(resolveTarget('/app/voice', 'studio'), { own: false, address: '/app/addons/voice/voice' }) + assert.deepEqual(resolveTarget('/app/voice?model=encoder', 'voice'), { own: true, subpath: 'voice?model=encoder' }) +}) diff --git a/addons/internal/protocol/extensions_test.go b/addons/internal/protocol/extensions_test.go new file mode 100644 index 000000000..86b507a7b --- /dev/null +++ b/addons/internal/protocol/extensions_test.go @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT +package protocol + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + + "github.com/mudler/LocalAI/pkg/addons/contract" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("typed declaration echo", func() { + It("echoes model actions and typed declarations together separately and absent over authenticated health", func() { + for mask := 0; mask < 16; mask++ { + caps := contract.Capabilities{Service: true} + if mask&1 != 0 { + caps.UI = &contract.UI{Entry: "ui/main.js", SDK: contract.VersionRange{Min: 2, Max: 2}, Pages: []string{"home"}, MainPage: "home", HomeCommands: []contract.HomeCommand{{ID: "open"}}} + } + if mask&8 != 0 { + if caps.UI == nil { + caps.UI = &contract.UI{Entry: "ui.js", SDK: contract.VersionRange{Min: 2, Max: 2}, Pages: []string{"home"}} + } + caps.UI.ModelActions = []contract.ModelAction{{Capability: "FLAG_IMAGE", Page: "home", Path: "images"}} + } + if mask&2 != 0 { + caps.Actions = true + caps.Tools = []string{"run", "health"} + caps.ToolDefinitions = []contract.ToolDefinition{{Name: "run", Description: "Run", Kind: "action", Shadows: "bash", InputSchema: json.RawMessage(`{"type":"object","properties":{"command":{"type":"string"}},"required":["command"],"additionalProperties":false}`)}, {Name: "health", Description: "Status", Kind: "read", UIOnly: true, InputSchema: json.RawMessage(`{"type":"object","additionalProperties":false}`)}} + caps.ExecutionProvider = &contract.ExecutionProvider{Tools: []string{"run"}, StatusTool: "health"} + } + if mask&4 != 0 { + caps.HostOperations = &contract.HostOperations{Required: []string{"workspace.materialize"}, Optional: []string{"egress.check"}} + } + v := contract.VersionRange{Min: 1, Max: 1} + manifest := contract.Manifest{SchemaVersion: 1, ID: "sample", Version: "1.0.0", Executable: "bin/sample", OS: "linux", Arch: "amd64", Host: v, API: v, Protocol: v, DataFormat: v, Capabilities: caps} + b, e := json.Marshal(manifest) + Expect(e).NotTo(HaveOccurred()) + parsed, e := contract.ParseManifest(b) + Expect(e).NotTo(HaveOccurred()) + Expect(parsed.Capabilities).To(Equal(caps)) + original := Startup{Registration: Registration{AddonID: "sample", Generation: 7, Incarnation: "inc", Protocol: 1, Capabilities: parsed.Capabilities}, Credential: "secret", Limits: contract.DefaultLimits()} + b, e = json.Marshal(original) + Expect(e).NotTo(HaveOccurred()) + var startup Startup + Expect(json.Unmarshal(b, &startup)).To(Succeed()) + req := httptest.NewRequest(http.MethodPost, "/health", bytes.NewBufferString(`{}`)) + req.Header.Set("Authorization", "Bearer secret") + req.Header.Set("X-LocalAI-Incarnation", "inc") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + Handler(startup, nil).ServeHTTP(rec, req) + Expect(rec.Code).To(Equal(http.StatusOK)) + var echo Registration + Expect(json.Unmarshal(rec.Body.Bytes(), &echo)).To(Succeed()) + Expect(echo).To(Equal(original.Registration)) + if mask == 0 { + for _, key := range []string{"homeCommands", "executionProvider", "hostOperations", "modelActions"} { + Expect(rec.Body.String()).NotTo(ContainSubstring(key)) + } + } + } + }) +}) diff --git a/addons/office/manifest.json b/addons/office/manifest.json index 315d00fda..1f1fc2405 100644 --- a/addons/office/manifest.json +++ b/addons/office/manifest.json @@ -5,6 +5,12 @@ }, "arch": "amd64", "capabilities": { + "hostOperations": { + "required": [ + "workspace.export", + "workspace.import" + ] + }, "actions": true, "skillDefinitions": [ { diff --git a/addons/quantization/manifest.json b/addons/quantization/manifest.json index f8fb9914c..319221ba1 100644 --- a/addons/quantization/manifest.json +++ b/addons/quantization/manifest.json @@ -22,6 +22,15 @@ "max": 1 }, "capabilities": { + "hostOperations": { + "required": [ + "quantize.get", + "quantize.import", + "quantize.list", + "quantize.start", + "quantize.stop" + ] + }, "tools": [ "resource-exists", "quantize-list", diff --git a/addons/sandbox/internal/tools/executionstatus_test.go b/addons/sandbox/internal/tools/executionstatus_test.go new file mode 100644 index 000000000..23caf6ffb --- /dev/null +++ b/addons/sandbox/internal/tools/executionstatus_test.go @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MIT +package tools + +import ( + "context" + "encoding/json" + "fmt" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/mudler/LocalAI/addons/sandbox/internal/backends" + "github.com/mudler/LocalAI/pkg/addons/contract" +) + +type typedSettings struct { + Settings + status backends.Status +} + +func (s typedSettings) Status() backends.Status { return s.status } + +var _ = Describe("typed execution status", func() { + for _, tc := range []struct{ active, readiness, isolation string }{ + {"gvisor", "available", "isolated"}, {"kvm", "available", "isolated"}, {"host", "available", "host"}, {"", "unavailable", "unknown"}, {"future", "unknown", "unknown"}, + } { + It(fmt.Sprintf("reports %s as %s with %s isolation", tc.active, tc.readiness, tc.isolation), func() { + r := New(nil, typedSettings{status: backends.Status{Active: tc.active}}, nil) + var env contract.Envelope + env.Request.OperationID = "typed-read" + env.Request.Arguments = json.RawMessage(`{"tool":"execution-status"}`) + result, err := r.Read(context.Background(), env) + Expect(err).NotTo(HaveOccurred()) + status, err := contract.DecodeExecutionStatus(result.Payload) + Expect(err).NotTo(HaveOccurred()) + Expect(status.Readiness).To(Equal(tc.readiness)) + Expect(status.Isolation).To(Equal(tc.isolation)) + }) + } +}) diff --git a/addons/sandbox/internal/tools/tools.go b/addons/sandbox/internal/tools/tools.go index fa0953bf8..8fe7e5fe8 100644 --- a/addons/sandbox/internal/tools/tools.go +++ b/addons/sandbox/internal/tools/tools.go @@ -51,14 +51,15 @@ func Runs(acquire runner.Acquire) []runner.Tool { // confirm-host are for the Settings page only (UIOnly), so no model is offered // them, and each asks the host whether the caller is an administrator. const ( - ToolStatus = "status" - ToolSetBackend = "set-backend" - ToolConfirmHost = "confirm-host" + ToolExecutionStatus = "execution-status" + ToolStatus = "status" + ToolSetBackend = "set-backend" + ToolConfirmHost = "confirm-host" ) // Declared is everything the manifest lists under capabilities.tools. func Declared() []string { - return append(append([]string{}, Tools...), ToolStatus, ToolSetBackend, ToolConfirmHost) + return append(append([]string{}, Tools...), ToolExecutionStatus, ToolStatus, ToolSetBackend, ToolConfirmHost) } // Settings is the choice of backend. *backends.Selector implements all but @@ -140,6 +141,10 @@ func Definitions() []contract.ToolDefinition { // them from every model; the add-on still asks the host whether the caller // is an administrator. return append(out, contract.ToolDefinition{ + Name: ToolExecutionStatus, Kind: contract.ToolKindRead, UIOnly: true, + Description: "Report typed execution readiness and isolation.", + InputSchema: json.RawMessage(`{"type":"object","properties":{},"additionalProperties":false}`), + }, contract.ToolDefinition{ Name: ToolStatus, Kind: contract.ToolKindRead, Description: "Say whether this host can run commands in a sandbox now, and which backend runs them.", InputSchema: json.RawMessage(`{"type":"object","properties":{},"additionalProperties":false}`), @@ -176,6 +181,22 @@ func (r *runtime) Read(ctx context.Context, env contract.Envelope) (contract.Res refuse := func(msg string) (contract.Result, error) { return contract.Result{Payload: protocol.Payload(map[string]any{"ok": false, "error": msg})}, nil } + // The typed observation is callback-free, including administrator lookups. + if a.Tool == ToolExecutionStatus { + status := contract.ExecutionStatus{Readiness: "unknown", Isolation: "unknown"} + if r.settings != nil { + switch r.settings.Status().Active { + case "gvisor", "kvm": + status.Readiness, status.Isolation = "available", "isolated" + case "host": + status.Readiness, status.Isolation = "available", "host" + case "": + status.Readiness = "unavailable" + status.Explanation = "No execution backend is available." + } + } + return contract.Result{Payload: protocol.Payload(status)}, nil + } admin := r.admin != nil && r.admin.IsAdmin(ctx, op) switch a.Tool { case ToolStatus: diff --git a/addons/sandbox/internal/tools/tools_test.go b/addons/sandbox/internal/tools/tools_test.go index a56c6b41a..f191f62e1 100644 --- a/addons/sandbox/internal/tools/tools_test.go +++ b/addons/sandbox/internal/tools/tools_test.go @@ -160,7 +160,7 @@ var _ = Describe("Sandbox tools", func() { // chat's own file tools. Its bash replaces the chat's built-in bash. It("offers bash and the settings tools only, and bash shadows the built-in", func() { Expect(tools.Tools).To(Equal([]string{"bash"})) - Expect(tools.Declared()).To(Equal([]string{"bash", "status", "set-backend", "confirm-host"})) + Expect(tools.Declared()).To(Equal([]string{"bash", "execution-status", "status", "set-backend", "confirm-host"})) for _, d := range tools.Definitions() { if d.Name == "bash" { Expect(d.Shadows).To(Equal("bash")) @@ -279,7 +279,7 @@ var _ = Describe("Sandbox tools", func() { switch d.Name { case "status": Expect(d.Kind).To(Equal(contract.ToolKindRead), "status must be a read tool") - case "set-backend", "confirm-host": + case "execution-status", "set-backend", "confirm-host": Expect(d.Kind).To(Equal(contract.ToolKindRead), "%s must be a read tool for the settings page only", d.Name) Expect(d.UIOnly).To(BeTrue(), "%s must be a read tool for the settings page only", d.Name) default: diff --git a/addons/sandbox/manifest.json b/addons/sandbox/manifest.json index 15673c4d6..b698af0ec 100644 --- a/addons/sandbox/manifest.json +++ b/addons/sandbox/manifest.json @@ -5,6 +5,18 @@ }, "arch": "amd64", "capabilities": { + "hostOperations": { + "required": [ + "workspace.changes", + "workspace.commit", + "workspace.materialize" + ], + "optional": [ + "egress.audit", + "egress.check", + "workspace.admin" + ] + }, "actions": true, "toolDefinitions": [ { @@ -31,6 +43,17 @@ }, "shadows": "bash" }, + { + "name": "execution-status", + "kind": "read", + "uiOnly": true, + "description": "Report typed execution readiness and isolation.", + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": false + } + }, { "name": "status", "description": "Say whether this host can run commands in a sandbox now, and which backend runs them.", @@ -87,6 +110,7 @@ ], "tools": [ "bash", + "execution-status", "status", "set-backend", "confirm-host" @@ -136,7 +160,13 @@ "name": "xlsx", "shadows": "xlsx" } - ] + ], + "executionProvider": { + "tools": [ + "bash" + ], + "statusTool": "execution-status" + } }, "dataFormat": { "max": 1, diff --git a/addons/studio/manifest.json b/addons/studio/manifest.json index 683e0b5f0..aca466ab3 100644 --- a/addons/studio/manifest.json +++ b/addons/studio/manifest.json @@ -16,7 +16,9 @@ "sdk": { "min": 2, "max": 2 }, "pages": ["studio"], "navigation": ["studio"], + "modelActions": [{"capability": "FLAG_IMAGE", "page": "studio", "path": "images"}, {"capability": "FLAG_VIDEO", "page": "studio", "path": "video"}, {"capability": "FLAG_TTS", "page": "studio", "path": "tts"}, {"capability": "FLAG_SOUND_GENERATION", "page": "studio", "path": "sound"}], "mainPage": "studio", + "homeCommands": [{ "id": "open", "alias": "studio", "icon": "palette" }], "icon": "ui/icon.png", "title": "Studio", "description": "Make images, video, 3D models, speech, sound and transformed audio with the models you have installed, and keep what you make." diff --git a/addons/talk/manifest.json b/addons/talk/manifest.json index bc47216aa..58f8452a0 100644 --- a/addons/talk/manifest.json +++ b/addons/talk/manifest.json @@ -16,6 +16,7 @@ "sdk": { "min": 2, "max": 2 }, "pages": ["talk"], "navigation": ["talk"], + "modelActions": [{"capability": "FLAG_TRANSCRIPT", "page": "talk"}], "mainPage": "talk", "icon": "ui/icon.png", "title": "Talk", diff --git a/addons/voice/manifest.json b/addons/voice/manifest.json index 3a3eb1d68..ac19090ac 100644 --- a/addons/voice/manifest.json +++ b/addons/voice/manifest.json @@ -16,6 +16,7 @@ "sdk": { "min": 2, "max": 2 }, "pages": ["voice", "recognition", "library", "create", "speech"], "navigation": ["voice"], + "modelActions": [{"capability": "FLAG_SPEAKER_RECOGNITION", "page": "recognition"}], "mainPage": "voice", "icon": "ui/icon.png", "title": "Voice", diff --git a/addons/web/manifest.json b/addons/web/manifest.json index bc22265ee..9a8e352bf 100644 --- a/addons/web/manifest.json +++ b/addons/web/manifest.json @@ -5,6 +5,11 @@ }, "arch": "amd64", "capabilities": { + "hostOperations": { + "optional": [ + "workspace.admin" + ] + }, "toolDefinitions": [ { "name": "search", diff --git a/core/application/addon_gateway.go b/core/application/addon_gateway.go index ac7f8f6e4..b08d40216 100644 --- a/core/application/addon_gateway.go +++ b/core/application/addon_gateway.go @@ -313,6 +313,9 @@ func (a *Application) startAddonGateway() error { } return auth.ValidateAddonActor(ctx, a.authDB, actor, true) } + if e := a.wireExecutionBinding(g); e != nil { + return e + } // Only the owner holds the policy store. Other frontends reach it through the // gateway, and without it the gateway keeps denying every resource. if !filepath.IsAbs(cfg.Root) || cfg.ListenAddress == "" { @@ -662,12 +665,13 @@ func filterByFeature(tools []studio.AddonTool, hostOps map[string][]string, has return out } -// sandboxAddonID is the add-on that runs commands and code. -const sandboxAddonID = "sandbox" - // needsWorkspace says whether the add-on works on the files of the user. func (s *studioAddons) needsWorkspace(addonID string) bool { - for _, p := range s.hostOps[addonID] { + return hasWorkspaceOperation(s.hostOps[addonID]) +} + +func hasWorkspaceOperation(operations []string) bool { + for _, p := range operations { if strings.HasPrefix(p, "workspace.") { return true } @@ -675,69 +679,28 @@ func (s *studioAddons) needsWorkspace(addonID string) bool { return false } -// Unavailable says why the tools of an add-on cannot work now, or "". An -// add-on that needs the Files workspace cannot work without it, and the -// Sandbox cannot run anything on a host where no backend works. +// Unavailable consumes only the owner-selected request-local observation. func (s *studioAddons) Unavailable(ctx context.Context, user, addonID string, workspace bool) string { if !workspace && s.needsWorkspace(addonID) { return "the Files workspace is off on this server, so there is no place for files or code" } - if addonID != sandboxAddonID { - return "" + value, explanation := studio.ExecutionObservation(ctx, user, addonID) + if value.Readiness == "unavailable" { + if explanation != "" { + return explanation + } + return "the selected execution provider is unavailable" } - actor, err := s.actor(user) - if err != nil { - return "" - } - st, ok := s.sandboxStatus(ctx, actor) - if !ok || st.CanRun { - return "" - } - return "no sandbox backend works on this host, so commands cannot run" + return "" } -// sandboxState is the part of the Sandbox add-on's status read that the chats use. -type sandboxState struct { - OK bool `json:"ok"` - CanRun bool `json:"canRun"` - Active string `json:"active"` - HostNotice bool `json:"hostNotice"` -} - -// sandboxStatus reads the Sandbox add-on's status as the actor. ok is false -// when the read failed or the add-on did not answer ok. -func (s *studioAddons) sandboxStatus(ctx context.Context, actor contract.Actor) (sandboxState, bool) { - ctx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - res, err := s.adapter.Execute(ctx, actor, addontools.ToolName(sandboxAddonID, "status"), json.RawMessage(`{}`)) - if err != nil || res.Kind != addontools.ResultData { - return sandboxState{}, false - } - var st sandboxState - if json.Unmarshal(res.Result, &st) != nil || !st.OK { - return sandboxState{}, false - } - return st, true -} - -// Isolation says how the Sandbox add-on runs commands: "isolated", "host" (on -// the server, with no isolation) or "" when it cannot tell. +// Isolation is unknown outside an admitted scope or after freshness is lost. func (s *studioAddons) Isolation(ctx context.Context, user, addonID string) string { - if addonID != sandboxAddonID { + value, _ := studio.ExecutionObservation(ctx, user, addonID) + if value.Readiness != "available" || value.Isolation == "unknown" { return "" } - actor, err := s.actor(user) - if err != nil { - return "" - } - st, ok := s.sandboxStatus(ctx, actor) - switch { - case !ok || !st.CanRun: - return "" - case st.HostNotice || st.Active == "host": - return "host" - } - return "isolated" + return string(value.Isolation) } // Controls returns the host controls the apps list draws for the add-on, as diff --git a/core/application/addon_pin_store_internal_test.go b/core/application/addon_pin_store_internal_test.go index b35467db4..f3e5b9ab4 100644 --- a/core/application/addon_pin_store_internal_test.go +++ b/core/application/addon_pin_store_internal_test.go @@ -3,32 +3,82 @@ package application import ( "bytes" + "context" + "fmt" "log/slog" + "os" + "os/exec" "path/filepath" + "testing" + "time" "github.com/mudler/xlog" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) -func capture() *bytes.Buffer { +const warningCaseEnv = "LOCALAI_WARNING_TEST_CASE" +const warningDirEnv = "LOCALAI_WARNING_TEST_DIR" + +// Run only the helper, not the Ginkgo suite: other specs leave background log +// producers running. Replacing xlog's global logger alongside them is a race. +func runWarningCase(test, id string) { + executable, err := os.Executable() + Expect(err).NotTo(HaveOccurred()) + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + cmd := exec.CommandContext(ctx, executable, "-test.run=^"+test+"$", "-test.count=1", "-test.v") + cmd.Env = append(os.Environ(), warningCaseEnv+"="+id, warningDirEnv+"="+GinkgoT().TempDir()) + output := &warningOutput{} + cmd.Stdout, cmd.Stderr = output, output + err = cmd.Run() + Expect(err).NotTo(HaveOccurred(), "warning case %s (%v):\n%s", id, ctx.Err(), output.String()) + Expect(output.String()).To(ContainSubstring("warning case passed: "+id), "child must execute its assertions") +} + +// Keep failure diagnostics bounded even if a child unexpectedly logs forever. +type warningOutput struct{ bytes.Buffer } + +func (b *warningOutput) Write(p []byte) (int, error) { + n := len(p) + if remaining := 64*1024 - b.Len(); remaining > 0 { + if len(p) > remaining { + p = p[:remaining] + } + _, _ = b.Buffer.Write(p) + } + return n, nil +} + +func TestPinStoreWarningProcess(t *testing.T) { + id := os.Getenv(warningCaseEnv) + if id == "" { + return + } + RegisterTestingT(t) var buf bytes.Buffer + // This process starts no background application and never restores the global. xlog.SetLogger(xlog.NewLoggerWithHandler(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}), xlog.LogLevelDebug)) - return &buf + switch id { + case "memory-pins": + _, err := pinStoreFor(nil, "") + Expect(err).NotTo(HaveOccurred()) + Expect(bytes.Count(buf.Bytes(), []byte("memory only"))).To(Equal(1), "log: %q", buf.String()) + case "file-pins": + _, err := pinStoreFor(nil, filepath.Join(os.Getenv(warningDirEnv), "addon-pins.json")) + Expect(err).NotTo(HaveOccurred()) + Expect(buf.Len()).To(BeZero(), "unexpected log: %q", buf.String()) + default: + Expect(id).To(BeEmpty(), "unknown pin warning case") + } + fmt.Println("warning case passed: " + id) } var _ = Describe("pinStoreFor", func() { It("logs one warning for memory-only pins", func() { - buf := capture() - _, err := pinStoreFor(nil, "") - Expect(err).ToNot(HaveOccurred()) - Expect(bytes.Count(buf.Bytes(), []byte("memory only"))).To(Equal(1), "log: %q", buf.String()) + runWarningCase("TestPinStoreWarningProcess", "memory-pins") }) - It("does not warn with a pins file", func() { - buf := capture() - _, err := pinStoreFor(nil, filepath.Join(GinkgoT().TempDir(), "addon-pins.json")) - Expect(err).ToNot(HaveOccurred()) - Expect(buf.Len()).To(BeZero(), "unexpected log: %q", buf.String()) + runWarningCase("TestPinStoreWarningProcess", "file-pins") }) }) diff --git a/core/application/addon_pins.go b/core/application/addon_pins.go index d2ca3bba1..629b3aa85 100644 --- a/core/application/addon_pins.go +++ b/core/application/addon_pins.go @@ -7,7 +7,6 @@ import ( "path/filepath" "time" - "github.com/mudler/LocalAI/core/http/auth" "github.com/mudler/LocalAI/core/services/addons/builtin" "github.com/mudler/LocalAI/core/services/addons/pins" "github.com/mudler/LocalAI/core/services/advisorylock" @@ -18,12 +17,8 @@ import ( // addonPinSweepEvery is how often the retention sweep runs. const addonPinSweepEvery = 6 * time.Hour -// addonPinSeedTimeout bounds the one-time upgrade seed at start. -const addonPinSeedTimeout = 30 * time.Second - // AddonPinStore returns the pin store, creating it once so the routes and the -// retention sweep share it. Creating it also runs the one-time upgrade seed for -// the accounts that exist now. An error is cached: a store that could not be +// retention sweep share it. An error is cached: a store that could not be // opened stays unavailable, and startup must stop rather than fall back to // memory and lose every unpin at the next restart. func (a *Application) AddonPinStore() (pins.Store, error) { @@ -34,39 +29,10 @@ func (a *Application) AddonPinStore() (pins.Store, error) { return } a.addonPinStore = s - // The seed gets a short bound so a stuck database cannot hold startup. - ctx, cancel := context.WithTimeout(context.Background(), addonPinSeedTimeout) - defer cancel() - accounts, err := a.existingPinAccounts() - if err != nil { - // No marker is written: the seed runs again at the next start, so a - // failed read does not cost the existing accounts their pins. - xlog.Warn("addon pin upgrade seed skipped; it will run again at the next start", "error", err) - return - } - if _, err := s.SeedExisting(ctx, accounts, pins.SeedIDs); err != nil { - xlog.Warn("addon pin upgrade seed failed; it will run again at the next start", "error", err) - } }) return a.addonPinStore, a.addonPinStoreErr } -// existingPinAccounts are the keys pins are stored under for the accounts that -// exist at this moment: every user of the account database, or the one local -// owner when authentication is off. The local owner counts as an existing -// account, so a deployment with authentication off keeps its Studio and Talk -// entries too. -func (a *Application) existingPinAccounts() ([]string, error) { - if a.authDB == nil { - return []string{auth.AddonLocalOwnerID}, nil - } - var ids []string - if err := a.authDB.Model(&auth.User{}).Pluck("id", &ids).Error; err != nil { - return nil, fmt.Errorf("list the accounts for the pin seed: %w", err) - } - return ids, nil -} - // pinStoreFor picks the store: the shared account database when there is one, // otherwise a file next to the other add-on data (one local owner, one host). func pinStoreFor(db *gorm.DB, dataPath string) (pins.Store, error) { diff --git a/core/application/addon_pins_test.go b/core/application/addon_pins_test.go index f0634800f..78166fead 100644 --- a/core/application/addon_pins_test.go +++ b/core/application/addon_pins_test.go @@ -16,7 +16,7 @@ import ( "gorm.io/gorm" ) -var _ = Describe("add-on pin store and the upgrade seed", func() { +var _ = Describe("add-on pin store", func() { pinIDs := func(s pins.Store, user string) []string { list, err := s.List(context.Background(), user) Expect(err).NotTo(HaveOccurred()) @@ -38,68 +38,72 @@ var _ = Describe("add-on pin store and the upgrade seed", func() { return s } - It("pins studio and talk once for the accounts that exist and never again", func() { + It("starts the local owner without implicit pins", func() { + s := store(appOver(nil, GinkgoT().TempDir())) + Expect(pinIDs(s, auth.AddonLocalOwnerID)).To(BeEmpty()) + }) + It("starts existing accounts without implicit pins", func() { db, err := auth.InitDB(filepath.Join(GinkgoT().TempDir(), "auth.db")) Expect(err).NotTo(HaveOccurred()) - for _, id := range []string{"u1", "u2"} { - Expect(db.Create(&auth.User{ID: id, Email: id + "@example.test", Provider: auth.ProviderLocal, Role: auth.RoleUser, Status: auth.StatusActive}).Error).To(Succeed()) - } + Expect(db.Create(&auth.User{ID: "existing", Email: "existing@example.test", Provider: auth.ProviderLocal, Role: auth.RoleUser, Status: auth.StatusActive}).Error).To(Succeed()) + Expect(pinIDs(store(appOver(db, "")), "existing")).To(BeEmpty()) + }) + + It("preserves saved user pins across a second frontend and reopen", func() { + path := filepath.Join(GinkgoT().TempDir(), "auth.db") + db, err := auth.InitDB(path) + Expect(err).NotTo(HaveOccurred()) + saved, err := pins.NewGormStore(db) + Expect(err).NotTo(HaveOccurred()) + Expect(saved.Replace(context.Background(), "u1", []string{"studio", "talk", "arbitrary"})).To(Succeed()) a := appOver(db, "") s := store(a) - Expect(pinIDs(s, "u1")).To(Equal([]string{"studio", "talk"})) - Expect(pinIDs(s, "u2")).To(Equal([]string{"studio", "talk"})) - - // An account created later starts with no pins, and a second seed does nothing. - Expect(db.Create(&auth.User{ID: "u3", Email: "u3@example.test", Provider: auth.ProviderLocal, Role: auth.RoleUser, Status: auth.StatusActive}).Error).To(Succeed()) - Expect(pinIDs(s, "u3")).To(BeEmpty()) - ran, err := s.SeedExisting(context.Background(), []string{"u3"}, pins.SeedIDs) - Expect(err).NotTo(HaveOccurred()) - Expect(ran).To(BeFalse()) - - // The store is created once per application. Expect(store(a)).To(BeIdenticalTo(s)) - - // An unpin sticks across a restart or a second frontend over the same database. + Expect(pinIDs(s, "u1")).To(Equal([]string{"studio", "talk", "arbitrary"})) Expect(s.Replace(context.Background(), "u1", []string{"talk"})).To(Succeed()) - again := store(appOver(db, "")) - Expect(pinIDs(again, "u1")).To(Equal([]string{"talk"})) - Expect(pinIDs(again, "u3")).To(BeEmpty()) + Expect(pinIDs(store(appOver(db, "")), "u1")).To(Equal([]string{"talk"})) + db2, err := auth.InitDB(path) + Expect(err).NotTo(HaveOccurred()) + Expect(pinIDs(store(appOver(db2, "")), "u1")).To(Equal([]string{"talk"})) + Expect(pinIDs(store(appOver(db2, "")), "new")).To(BeEmpty()) }) - It("does not mark the seed as run when the accounts cannot be read, and seeds at the next start", func() { + It("opens the pin store without scanning accounts or creating seed metadata", func() { db, err := auth.InitDB(filepath.Join(GinkgoT().TempDir(), "auth.db")) Expect(err).NotTo(HaveOccurred()) - Expect(db.Create(&auth.User{ID: "u1", Email: "u1@example.test", Provider: auth.ProviderLocal, Role: auth.RoleUser, Status: auth.StatusActive}).Error).To(Succeed()) Expect(db.Migrator().RenameTable(&auth.User{}, "users_away")).To(Succeed()) - s := store(appOver(db, "")) Expect(pinIDs(s, "u1")).To(BeEmpty()) - var marks int64 - Expect(db.Model(&pins.SeedRun{}).Count(&marks).Error).To(Succeed()) - Expect(marks).To(BeZero()) - - Expect(db.Migrator().RenameTable("users_away", &auth.User{})).To(Succeed()) - next := store(appOver(db, "")) - Expect(pinIDs(next, "u1")).To(Equal([]string{"studio", "talk"})) + Expect(db.Migrator().HasTable("addon_pin_seed_runs")).To(BeFalse()) + Expect(s.Replace(context.Background(), "u1", []string{"arbitrary"})).To(Succeed()) + Expect(pinIDs(store(appOver(db, "")), "u1")).To(Equal([]string{"arbitrary"})) }) - It("seeds the local owner when authentication is off and keeps an unpin", func() { + It("keeps explicit local-owner pins and unpins across reopen", func() { dir := GinkgoT().TempDir() s := store(appOver(nil, dir)) - Expect(pinIDs(s, auth.AddonLocalOwnerID)).To(Equal([]string{"studio", "talk"})) + Expect(pinIDs(s, auth.AddonLocalOwnerID)).To(BeEmpty()) + Expect(s.Replace(context.Background(), auth.AddonLocalOwnerID, []string{"studio", "talk"})).To(Succeed()) Expect(s.Replace(context.Background(), auth.AddonLocalOwnerID, []string{"talk"})).To(Succeed()) again := store(appOver(nil, dir)) Expect(pinIDs(again, auth.AddonLocalOwnerID)).To(Equal([]string{"talk"})) raw, err := os.ReadFile(filepath.Join(dir, "addon-pins.json")) Expect(err).NotTo(HaveOccurred()) - Expect(string(raw)).To(ContainSubstring(pins.UpgradeSeedName)) + Expect(string(raw)).NotTo(ContainSubstring("seedRuns")) + Expect(again.Replace(context.Background(), auth.AddonLocalOwnerID, []string{})).To(Succeed()) + Expect(pinIDs(store(appOver(nil, dir)), auth.AddonLocalOwnerID)).To(BeEmpty()) }) It("reports a corrupt pins file instead of falling back to memory", func() { dir := GinkgoT().TempDir() Expect(os.WriteFile(filepath.Join(dir, "addon-pins.json"), []byte("{broken"), 0o600)).To(Succeed()) - _, err := appOver(nil, dir).AddonPinStore() + a := appOver(nil, dir) + _, err := a.AddonPinStore() Expect(err).To(HaveOccurred()) + Expect(os.Remove(filepath.Join(dir, "addon-pins.json"))).To(Succeed()) + s, again := a.AddonPinStore() + Expect(s).To(BeNil()) + Expect(again).To(BeIdenticalTo(err)) }) }) diff --git a/core/application/addons_auth_test.go b/core/application/addons_auth_test.go index 30a6d073f..b79cec469 100644 --- a/core/application/addons_auth_test.go +++ b/core/application/addons_auth_test.go @@ -14,6 +14,7 @@ import ( "crypto/x509/pkix" "encoding/pem" "errors" + "fmt" "github.com/mudler/LocalAI/core/config" "github.com/mudler/LocalAI/core/http/auth" "github.com/mudler/LocalAI/core/services/addons" @@ -32,6 +33,7 @@ import ( "path/filepath" "sync" "sync/atomic" + "testing" "time" ) @@ -260,39 +262,7 @@ var _ = Describe("addon policy bootstrap", func() { It("keeps named-user mode by default and selects local-owner only explicitly with a warning", func() { requireAddonRuntime() - logs := &logCapture{} - xlog.SetLogger(xlog.NewLoggerWithHandler(slog.NewTextHandler(logs, &slog.HandlerOptions{Level: slog.LevelWarn}), xlog.LogLevelWarn)) - DeferCleanup(func() { xlog.SetLogger(xlog.NewLogger(xlog.LogLevel("info"), "text")) }) - - named := setup() - named.authDB = authDB() - Expect(start(named, base())).To(Succeed()) - DeferCleanup(named.Shutdown) - Expect(named.AddonOwnershipMode()).To(Equal("named-user")) - Expect(logs.String()).NotTo(ContainSubstring("local-owner")) - // Named mode still refuses to run without the account database. - noDB := setup() - Expect(start(noDB, base())).NotTo(Succeed()) - - c := base() - c.OwnershipMode = "local-owner" - a := setup() // no account database: local-owner mode does not need one - Expect(start(a, c)).To(Succeed()) - DeferCleanup(a.Shutdown) - Expect(a.AddonOwnershipMode()).To(Equal("local-owner")) - Expect(logs.String()).To(ContainSubstring("local-owner")) - Expect(logs.String()).To(ContainSubstring("unauthenticated callers share")) - Expect(logs.String()).To(ContainSubstring("can reach this port can confirm addon proposals")) - local := contract.Actor{UserID: "local-owner", CredentialID: "local-owner", Mode: "local-owner"} - key, e := a.AddonPolicy().Register(context.Background(), local, "ghost") - Expect(e).To(HaveOccurred(), "creation authority still needs an installed addon") - _ = key - // Sharing is disabled by the store mode. - Expect(a.AddonPolicy().SetGrant(context.Background(), local, policy.ResourceKey{AddonID: "sample", ResourceID: "x"}, "bob", policy.RoleViewer)).NotTo(Succeed()) - - bad := base() - bad.OwnershipMode = "shared" - Expect(start(setup(), bad)).NotTo(Succeed()) + runWarningCase("TestAddonOwnershipWarningProcess", "owner-mode") }) }) @@ -336,3 +306,66 @@ var _ = Describe("addon owner in a full application start", func() { Expect(a.AddonManager()).NotTo(BeNil()) }) }) + +// TestAddonOwnershipWarningProcess deliberately does not run the Ginkgo suite. +// The logger is installed before producers start and is never restored. +func TestAddonOwnershipWarningProcess(t *testing.T) { + if os.Getenv(warningCaseEnv) == "" { + return + } + RegisterTestingT(t) + Expect(os.Getenv(warningCaseEnv)).To(Equal("owner-mode")) + dir, err := filepath.EvalSymlinks(os.Getenv(warningDirEnv)) + Expect(err).NotTo(HaveOccurred()) + next := 0 + setup := func() *Application { + return &Application{applicationConfig: &config.ApplicationConfig{Context: context.Background()}} + } + base := func() config.AddonConfig { + next++ + return config.AddonConfig{Enabled: true, InstanceID: "owner", OwnerID: "owner", OwnerAddress: "https://owner.example", StorageID: "local", Root: filepath.Join(dir, fmt.Sprintf("addons-%d", next)), ListenAddress: "127.0.0.1:0", Transport: ownerTransport()} + } + authDB := func() *gorm.DB { + db, err := auth.InitDB(filepath.Join(dir, "auth.db")) + Expect(err).NotTo(HaveOccurred()) + return db + } + start := func(a *Application, c config.AddonConfig) error { + a.applicationConfig.Addons = c + return a.startAddons() + } + + logs := &logCapture{} + xlog.SetLogger(xlog.NewLoggerWithHandler(slog.NewTextHandler(logs, &slog.HandlerOptions{Level: slog.LevelWarn}), xlog.LogLevelWarn)) + + named := setup() + named.authDB = authDB() + Expect(start(named, base())).To(Succeed()) + defer func() { Expect(named.Shutdown()).To(Succeed()) }() + Expect(named.AddonOwnershipMode()).To(Equal("named-user")) + Expect(logs.String()).NotTo(ContainSubstring("local-owner")) + // Named mode still refuses to run without the account database. + noDB := setup() + Expect(start(noDB, base())).NotTo(Succeed()) + + c := base() + c.OwnershipMode = "local-owner" + a := setup() // no account database: local-owner mode does not need one + Expect(start(a, c)).To(Succeed()) + defer func() { Expect(a.Shutdown()).To(Succeed()) }() + Expect(a.AddonOwnershipMode()).To(Equal("local-owner")) + Expect(logs.String()).To(ContainSubstring("local-owner")) + Expect(logs.String()).To(ContainSubstring("unauthenticated callers share")) + Expect(logs.String()).To(ContainSubstring("can reach this port can confirm addon proposals")) + local := contract.Actor{UserID: "local-owner", CredentialID: "local-owner", Mode: "local-owner"} + key, e := a.AddonPolicy().Register(context.Background(), local, "ghost") + Expect(e).To(HaveOccurred(), "creation authority still needs an installed addon") + _ = key + // Sharing is disabled by the store mode. + Expect(a.AddonPolicy().SetGrant(context.Background(), local, policy.ResourceKey{AddonID: "sample", ResourceID: "x"}, "bob", policy.RoleViewer)).NotTo(Succeed()) + + bad := base() + bad.OwnershipMode = "shared" + Expect(start(setup(), bad)).NotTo(Succeed()) + fmt.Println("warning case passed: owner-mode") +} diff --git a/core/application/application.go b/core/application/application.go index 06e137872..cf00e3952 100644 --- a/core/application/application.go +++ b/core/application/application.go @@ -703,7 +703,6 @@ func (a *Application) Shutdown() error { if a.studioService != nil { _ = a.studioService.Close(context.Background()) } - // After the studio service: a chat may still search a collection. if svc := a.collectionsService.Load(); svc != nil { svc.Close() } diff --git a/core/application/execution_binding.go b/core/application/execution_binding.go new file mode 100644 index 000000000..f5c72c18b --- /dev/null +++ b/core/application/execution_binding.go @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: MIT +package application + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "slices" + + "github.com/mudler/LocalAI/core/http/auth" + "github.com/mudler/LocalAI/core/services/addons/gateway" + addontools "github.com/mudler/LocalAI/core/services/addons/tools" + "github.com/mudler/LocalAI/core/services/studio" + "github.com/mudler/LocalAI/pkg/addons/contract" +) + +// Status is advisory: each admission reads current authorization and selection. +// It does not promise to detect changes that were restored between reads. +func (a *Application) wireExecutionBinding(g *gateway.Gateway) error { + g.ResolveExecutionBinding = a.resolveExecutionBinding + g.AuthorizeExecutionStatus = g.ValidateExecutionBinding + return nil +} + +func (a *Application) resolveExecutionBinding(ctx context.Context, actor contract.Actor, claim gateway.ExecutionBindingClaim) (gateway.ExecutionStatusBinding, error) { + return a.resolveExecutionBindingWithTools(ctx, actor, claim, a.StudioAddonTools()) +} + +func (a *Application) resolveExecutionBindingWithTools(ctx context.Context, actor contract.Actor, claim gateway.ExecutionBindingClaim, broker studio.AddonTools) (gateway.ExecutionStatusBinding, error) { + empty := gateway.ExecutionStatusBinding{} + if err := auth.AddonRevalidate(a.authDB, a.AddonOwnershipMode())(ctx, actor); err != nil { + return empty, err + } + if a.addonManager == nil { + return empty, gateway.ErrUnavailable + } + if a.studioStore == nil { + return empty, gateway.ErrUnavailable + } + principal := actor.UserID + if a.AddonOwnershipMode() == auth.AddonModeLocalOwner { + principal = studio.AnonymousPrincipal + } + cfg := a.applicationConfig.ChatSelectionSnapshot() + pins, err := a.studioToolPins(ctx) + if err != nil { + return empty, err + } + rec, err := a.studioStore.GetChat(ctx, principal, claim.ConversationID) + if err != nil || rec.Principal != principal || rec.ID != claim.ConversationID { + return empty, gateway.ErrForbidden + } + var grants []string + if rec.AgentID != "" { + if a.studioAgents == nil { + return empty, gateway.ErrUnavailable + } + ag, err := a.studioAgents.Get(ctx, principal, rec.AgentID) + if err != nil || ag.Principal != principal { + return empty, gateway.ErrForbidden + } + grants = ag.Addons + } + // Discover through the actual Studio adapter: only currently running, + // actor-visible native replacements are candidates. No readiness read occurs. + if broker == nil { + return empty, gateway.ErrUnavailable + } + defs, err := broker.Tools(ctx, rec.Principal) + if err != nil { + return empty, err + } + lister, ok := broker.(studio.ShadowLister) + if !ok { + return empty, gateway.ErrUnavailable + } + installed, err := lister.AddonShadowers(ctx, rec.Principal) + if err != nil { + return empty, err + } + // Workspace-dependent tools cannot be selected without the actual workspace. + eligible := defs[:0:0] + for _, d := range defs { + needsWorkspace := hasWorkspaceOperation(a.applicationConfig.AddonHostOps[d.AddonID]) + if needsWorkspace && !a.StudioFiles().Available() { + continue + } + eligible = append(eligible, d) + } + selected, ok := studio.SelectExecutionProvider(eligible, installed, rec, grants, pins["bash"]) + if !ok { + return empty, gateway.ErrForbidden + } // Native bash is not an addon binding; no host fallback. + instance, err := a.addonManager.Runtime(ctx, selected.AddonID) + if err != nil { + return empty, gateway.ErrUnavailable + } + caps, err := a.addonManager.DeclaredCapabilities(ctx, selected.AddonID, instance.Generation) + if err != nil { + return empty, err + } + provider := caps.ExecutionProvider + addonID, tool, valid := addontools.ParseToolName(selected.Name) + if !valid || addonID != selected.AddonID { + return empty, gateway.ErrForbidden + } + if provider == nil || !slices.Contains(provider.Tools, tool) { + return empty, gateway.ErrForbidden + } + executionOK, statusOK := false, false + for _, d := range caps.ToolDefinitions { + if d.Name == tool && contract.IsExecutionAction(caps, d) { + executionOK = true + } + if d.Name == provider.StatusTool && contract.IsExecutionStatusTool(caps, d) && contract.ExecutionStatusAcceptsEmptyArguments(d) { + statusOK = true + } + } + if !executionOK || !statusOK || tool == provider.StatusTool { + return empty, gateway.ErrForbidden + } + // Exact runtime identity is checked again; the gateway re-resolves current + // authorization and selection before dispatch and positive publication. + after, err := a.addonManager.Runtime(ctx, selected.AddonID) + if err != nil || after.Generation != instance.Generation || after.Incarnation != instance.Incarnation { + return empty, gateway.ErrUnavailable + } + if err = auth.AddonRevalidate(a.authDB, a.AddonOwnershipMode())(ctx, actor); err != nil { + return empty, err + } + // Compare current selection inputs even when they resolve to the same provider. + selection, _ := json.Marshal([]any{rec.Principal, rec.AgentID, rec.Origin, rec.AddonsOff, grants, cfg.HostExec, pins}) + digest := sha256.Sum256(selection) + return gateway.ExecutionStatusBinding{ConversationID: rec.ID, Actor: actor, AddonID: selected.AddonID, Generation: instance.Generation, Incarnation: instance.Incarnation, ExecutionTool: tool, StatusTool: provider.StatusTool, CurrentSelection: hex.EncodeToString(digest[:])}, nil +} diff --git a/core/application/execution_binding_test.go b/core/application/execution_binding_test.go new file mode 100644 index 000000000..96b84bad6 --- /dev/null +++ b/core/application/execution_binding_test.go @@ -0,0 +1,441 @@ +// SPDX-License-Identifier: MIT +package application + +import ( + "archive/tar" + "bytes" + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "time" + + "github.com/mudler/LocalAI/core/config" + "github.com/mudler/LocalAI/core/http/auth" + "github.com/mudler/LocalAI/core/services/addons" + "github.com/mudler/LocalAI/core/services/addons/catalog" + "github.com/mudler/LocalAI/core/services/addons/gateway" + "github.com/mudler/LocalAI/core/services/addons/hostsettings" + "github.com/mudler/LocalAI/core/services/studio" + "github.com/mudler/LocalAI/core/services/studio/agents" + "github.com/mudler/LocalAI/core/services/studio/session" + "github.com/mudler/LocalAI/core/services/studio/store" + "github.com/mudler/LocalAI/pkg/addons/contract" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type bindingNameBroker struct { + studio.AddonTools + name string +} + +func (b bindingNameBroker) Tools(ctx context.Context, user string) ([]studio.AddonTool, error) { + defs, err := b.AddonTools.Tools(ctx, user) + for i := range defs { + if defs[i].AddonID == "renamed-runner" { + defs[i].Name = b.name + } + } + return defs, err +} + +func (b bindingNameBroker) AddonShadowers(ctx context.Context, user string) ([]studio.AddonShadower, error) { + return b.AddonTools.(studio.ShadowLister).AddonShadowers(ctx, user) +} + +var _ = Describe("execution binding owner resolver", func() { + var a *Application + var ctx context.Context + var actor contract.Actor + var claim gateway.ExecutionBindingClaim + setOwnerPin := func(pin string) { + snap, err := a.addonHostSettings.Snapshot(ctx) + Expect(err).NotTo(HaveOccurred()) + var value *string + if pin != "" { + value = &pin + } + _, err = a.addonHostSettings.Update(ctx, hostsettings.Patch{ToolProviders: map[string]*string{"bash": value}}, snap.Revision, fmt.Sprintf("pin-%d", snap.Revision)) + Expect(err).NotTo(HaveOccurred()) + } + + BeforeEach(func() { + requireAddonRuntime() + ctx = context.Background() + actor = contract.Actor{UserID: auth.AddonLocalOwnerID, CredentialID: auth.AddonLocalOwnerID, Mode: auth.AddonModeLocalOwner} + root := realTempDir() + DeferCleanup(func() { + _ = filepath.Walk(root, func(p string, info os.FileInfo, err error) error { + if err == nil && info.IsDir() { + return os.Chmod(p, 0700) + } + return nil + }) + }) + binary := filepath.Join(root, "fixture") + output, err := exec.Command("go", "build", "-o", binary, "../../tests/addons/fixture/cmd").CombinedOutput() + Expect(err).NotTo(HaveOccurred(), string(output)) + executable, err := os.ReadFile(binary) + Expect(err).NotTo(HaveOccurred()) + raw, err := os.ReadFile("../../tests/addons/fixtures/packages/manifest.json") + Expect(err).NotTo(HaveOccurred()) + var manifest map[string]any + Expect(json.Unmarshal(raw, &manifest)).To(Succeed()) + providers := []struct{ id, execute, status string }{{"renamed-runner", "execute", "inspect"}, {"alternate-worker", "launch", "readiness"}} + archives := map[string][]byte{} + for _, provider := range providers { + manifest["id"] = provider.id + manifest["capabilities"] = contract.Capabilities{Service: true, Actions: true, Tools: []string{provider.execute, provider.status}, ExecutionProvider: &contract.ExecutionProvider{Tools: []string{provider.execute}, StatusTool: provider.status}, ToolDefinitions: []contract.ToolDefinition{ + {Name: provider.execute, Kind: contract.ToolKindAction, Shadows: "bash", Description: "Run a command", InputSchema: json.RawMessage(`{"type":"object","properties":{"command":{"type":"string"}},"required":["command"],"additionalProperties":false}`)}, + {Name: provider.status, Kind: contract.ToolKindRead, UIOnly: true, Description: "Inspect execution", InputSchema: json.RawMessage(`{"type":"object","properties":{},"additionalProperties":false}`)}, + }} + raw, err = json.Marshal(manifest) + Expect(err).NotTo(HaveOccurred()) + var archive bytes.Buffer + tw := tar.NewWriter(&archive) + for name, data := range map[string][]byte{"manifest.json": raw, "bin/sample": executable, "fixture.json": []byte(`{"mode":"execution-status"}`)} { + Expect(tw.WriteHeader(&tar.Header{Name: name, Mode: 0755, Size: int64(len(data))})).To(Succeed()) + _, err = tw.Write(data) + Expect(err).NotTo(HaveOccurred()) + } + Expect(tw.Close()).To(Succeed()) + archives[provider.id] = append([]byte(nil), archive.Bytes()...) + } + var url string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var releases []catalog.Release + for _, provider := range providers { + data := archives[provider.id] + if r.URL.Path == "/"+provider.id+".tar" { + _, _ = w.Write(data) + return + } + releases = append(releases, catalog.Release{ID: provider.id, Name: provider.id, Version: "1.2.3", OS: "linux", Arch: "amd64", Artifact: url + "/" + provider.id + ".tar", Digest: fmt.Sprintf("sha256:%x", sha256.Sum256(data))}) + } + _ = catalog.WriteIndex(w, releases) + })) + DeferCleanup(server.Close) + url = server.URL + ownerRoot := filepath.Join(root, "addons") + Expect(os.MkdirAll(ownerRoot, 0700)).To(Succeed()) + admin := func(context.Context, contract.Actor) error { return nil } + sources, err := catalog.NewSources(filepath.Join(ownerRoot, "sources.json"), admin, catalog.CredentialResolver{}) + Expect(err).NotTo(HaveOccurred()) + Expect(sources.Save(ctx, actor, catalog.Source{ID: "test", Name: "Test", URL: url, Enabled: true, AllowLoopbackHTTP: true})).To(Succeed()) + runCtx, cancel := context.WithCancel(ctx) + DeferCleanup(cancel) + a = &Application{applicationConfig: &config.ApplicationConfig{Context: runCtx, Addons: config.AddonConfig{Enabled: true, InstanceID: "owner", OwnerID: "owner", OwnerAddress: "https://owner.example", StorageID: "local", Root: ownerRoot, AdminCheck: admin, OwnershipMode: auth.AddonModeLocalOwner, Host: contract.HostCompatibility{Host: 1, API: 1, Protocol: 1, SDK: 1, DataFormat: 1, OS: "linux", Arch: "amd64"}}}} + + keys := filepath.Join(root, "keys") + Expect(gateway.GenerateCarrierKeys(keys, []string{"127.0.0.1"})).To(Succeed()) + listener, e := net.Listen("tcp", "127.0.0.1:0") + Expect(e).NotTo(HaveOccurred()) + address := listener.Addr().String() + Expect(listener.Close()).To(Succeed()) + transport, e := gateway.LoadCarrierConfig(keys, "owner", "https://"+address) + Expect(e).NotTo(HaveOccurred()) + a.applicationConfig.Addons.InstanceID = transport.InstanceID + a.applicationConfig.Addons.OwnerID = transport.OwnerID + a.applicationConfig.Addons.OwnerAddress = transport.OwnerAddress + a.applicationConfig.Addons.ListenAddress = address + a.applicationConfig.Addons.Transport = transport + Expect(a.startAddons()).To(Succeed()) + DeferCleanup(a.Shutdown) + for index, provider := range providers { + // Install timestamps have second precision; separate them to test age, not ID tie-breaking. + if index > 0 { + time.Sleep(time.Second) + } + _, err = a.addonManager.Submit(ctx, actor, addons.LifecycleCommand{OperationID: "install-" + provider.id, AddonID: provider.id, Action: "install", SourceID: "test", Version: "1.2.3"}) + Expect(err).NotTo(HaveOccurred()) + _, err = a.addonManager.Submit(ctx, actor, addons.LifecycleCommand{OperationID: "enable-" + provider.id, AddonID: provider.id, Action: "enable", Generation: 1}) + Expect(err).NotTo(HaveOccurred()) + } + a.studioStore, err = store.NewJSON(filepath.Join(root, "chats")) + Expect(err).NotTo(HaveOccurred()) + a.studioAgents, err = agents.NewJSON(filepath.Join(root, "agents")) + Expect(err).NotTo(HaveOccurred()) + claim = gateway.ExecutionBindingClaim{ConversationID: "conversation"} + Expect(a.studioStore.CreateChat(ctx, session.ChatRecord{ID: claim.ConversationID, Principal: studio.AnonymousPrincipal, Created: time.Now(), Updated: time.Now()})).To(Succeed()) + Expect(a.addonGateway.ResolveExecutionBinding).NotTo(BeNil()) + Expect(a.addonGateway.AuthorizeExecutionStatus).NotTo(BeNil()) + }) + It("rejects noncanonical and mismatched selected tool identities", func() { + setOwnerPin("renamed-runner") + for _, name := range []string{"execute", "bash", "addon__alternate-worker__execute", "addon__renamed-runner__bad_name", "addon__renamed-runner__execute__extra"} { + _, err := a.resolveExecutionBindingWithTools(ctx, actor, claim, bindingNameBroker{AddonTools: a.StudioAddonTools(), name: name}) + Expect(err).To(MatchError(gateway.ErrForbidden), name) + } + _, err := a.resolveExecutionBindingWithTools(ctx, actor, claim, bindingNameBroker{AddonTools: a.StudioAddonTools(), name: "addon__renamed-runner__execute"}) + Expect(err).NotTo(HaveOccurred()) + }) + + It("uses owner CAS pins for execution and typed status and fails closed on policy outage", func() { + first, err := a.addonGateway.BindingForActor(ctx, actor, claim) + Expect(err).NotTo(HaveOccurred()) + a.applicationConfig.SetChatsToolProviders(map[string]string{"bash": "renamed-runner"}) + snap, err := a.addonHostSettings.Snapshot(ctx) + Expect(err).NotTo(HaveOccurred()) + pin := "alternate-worker" + _, err = a.addonHostSettings.Update(ctx, hostsettings.Patch{ToolProviders: map[string]*string{"bash": &pin}}, snap.Revision, "pin-alternate") + Expect(err).NotTo(HaveOccurred()) + pins, err := a.studioToolPins(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(pins["bash"]).To(Equal(pin)) + second, err := a.addonGateway.BindingForActor(ctx, actor, claim) + Expect(err).NotTo(HaveOccurred()) + Expect(second.AddonID).To(Equal(pins["bash"])) + Expect(a.addonGateway.AuthorizeExecutionStatus(ctx, actor, first)).NotTo(Succeed()) + Expect(a.addonGateway.AuthorizeExecutionStatus(ctx, actor, second)).To(Succeed()) + _, err = a.addonHostSettings.Update(ctx, hostsettings.Patch{}, snap.Revision, "stale-pin") + Expect(err).To(MatchError(hostsettings.ErrConflict)) + Expect(a.addonGateway.AuthorizeExecutionStatus(ctx, actor, second)).To(Succeed()) + Expect(a.addonHostSettings.Close()).To(Succeed()) + _, err = a.studioToolPins(ctx) + Expect(err).To(HaveOccurred()) + _, err = a.addonGateway.BindingForActor(ctx, actor, claim) + Expect(err).To(HaveOccurred()) + Expect(a.addonGateway.AuthorizeExecutionStatus(ctx, actor, second)).NotTo(Succeed()) + }) + + It("resolves local-owner SQLite using current chat and agent reads", func() { + db, err := auth.InitDB(filepath.Join(realTempDir(), "identity.db")) + Expect(err).NotTo(HaveOccurred()) + sqlDB, err := db.DB() + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(sqlDB.Close) + originalChats, originalAgents := a.studioStore, a.studioAgents + DeferCleanup(func() { a.studioStore, a.studioAgents = originalChats, originalAgents }) + a.authDB = db + a.studioStore, err = store.NewGORM(db) + Expect(err).NotTo(HaveOccurred()) + a.studioAgents, err = agents.NewGORM(db) + Expect(err).NotTo(HaveOccurred()) + Expect(a.studioStore.CreateChat(ctx, session.ChatRecord{ID: claim.ConversationID, Principal: studio.AnonymousPrincipal, Created: time.Now(), Updated: time.Now()})).To(Succeed()) + binding, err := a.addonGateway.BindingForActor(ctx, actor, claim) + Expect(err).NotTo(HaveOccurred()) + Expect(binding.AddonID).To(Equal("renamed-runner")) + Expect(a.addonGateway.AuthorizeExecutionStatus(ctx, actor, binding)).To(Succeed()) + }) + + It("resolves named SQLite chats and rechecks current accounts and credentials", func() { + db, err := auth.InitDB(filepath.Join(realTempDir(), "named.db")) + Expect(err).NotTo(HaveOccurred()) + sqlDB, err := db.DB() + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(sqlDB.Close) + originalChats, originalAgents := a.studioStore, a.studioAgents + DeferCleanup(func() { a.studioStore, a.studioAgents = originalChats, originalAgents }) + a.authDB = db + a.applicationConfig.Addons.OwnershipMode = auth.AddonModeNamedUser + // Install current named-account validation without restarting the runtime. + originalGateway := a.addonGateway + DeferCleanup(func() { a.addonGateway = originalGateway }) + a.addonGateway = &gateway.Gateway{Manager: a.addonManager, Runtime: originalGateway.Runtime, Tools: originalGateway.Tools, HostSettings: originalGateway.HostSettings, Revalidate: auth.AddonRevalidate(db, auth.AddonModeNamedUser)} + Expect(a.wireExecutionBinding(a.addonGateway)).To(Succeed()) + a.studioStore, err = store.NewGORM(db) + Expect(err).NotTo(HaveOccurred()) + a.studioAgents, err = agents.NewGORM(db) + Expect(err).NotTo(HaveOccurred()) + user := auth.User{ID: "runner", Role: auth.RoleUser, Status: auth.StatusActive} + Expect(db.Create(&user).Error).To(Succeed()) + permissions := auth.UserPermission{UserID: user.ID, Permissions: auth.PermissionMap{auth.FeatureAddons: true}} + Expect(db.Create(&permissions).Error).To(Succeed()) + actor = contract.Actor{UserID: user.ID, CredentialID: auth.AddonChatsCredential, Mode: auth.AddonModeNamedUser} + Expect(a.studioStore.CreateChat(ctx, session.ChatRecord{ID: claim.ConversationID, Principal: user.ID, Created: time.Now(), Updated: time.Now()})).To(Succeed()) + binding, err := a.addonGateway.BindingForActor(ctx, actor, claim) + Expect(err).NotTo(HaveOccurred()) + Expect(binding.AddonID).To(Equal("renamed-runner")) + Expect(a.addonGateway.AuthorizeExecutionStatus(ctx, actor, binding)).To(Succeed()) + _, err = a.addonGateway.BindingForActor(ctx, actor, gateway.ExecutionBindingClaim{ConversationID: "foreign"}) + Expect(err).To(HaveOccurred()) + Expect(db.Model(&user).Update("status", "disabled").Error).To(Succeed()) + Expect(a.addonGateway.AuthorizeExecutionStatus(ctx, actor, binding)).NotTo(Succeed()) + Expect(db.Model(&user).Update("status", auth.StatusActive).Error).To(Succeed()) + permissions.Permissions = auth.PermissionMap{} + Expect(db.Model(&auth.UserPermission{}).Where("user_id = ?", user.ID).Update("permissions", permissions.Permissions).Error).To(Succeed()) + Expect(a.addonGateway.AuthorizeExecutionStatus(ctx, actor, binding)).NotTo(Succeed()) + permissions.Permissions = auth.PermissionMap{auth.FeatureAddons: true} + Expect(db.Model(&auth.UserPermission{}).Where("user_id = ?", user.ID).Update("permissions", permissions.Permissions).Error).To(Succeed()) + _, err = a.addonGateway.BindingForActor(ctx, actor, claim) + Expect(err).NotTo(HaveOccurred()) + _, key, err := auth.CreateAPIKey(db, user.ID, "test", auth.RoleUser, "secret", nil) + Expect(err).NotTo(HaveOccurred()) + actor.CredentialID = "key:" + key.ID + binding, err = a.addonGateway.BindingForActor(ctx, actor, claim) + Expect(err).NotTo(HaveOccurred()) + expired := time.Now().Add(-time.Hour) + Expect(db.Model(key).Update("expires_at", expired).Error).To(Succeed()) + Expect(a.addonGateway.AuthorizeExecutionStatus(ctx, actor, binding)).NotTo(Succeed()) + Expect(db.Create(&auth.Session{ID: "browser", UserID: user.ID, ExpiresAt: time.Now().Add(time.Hour)}).Error).To(Succeed()) + actor.CredentialID = "session:browser" + binding, err = a.addonGateway.BindingForActor(ctx, actor, claim) + Expect(err).NotTo(HaveOccurred()) + Expect(db.Delete(&auth.Session{}, "id = ?", "browser").Error).To(Succeed()) + Expect(a.addonGateway.AuthorizeExecutionStatus(ctx, actor, binding)).NotTo(Succeed()) + }) + + It("selects the oldest real provider then pins a second distinct manifest and runtime", func() { + first, err := a.addonGateway.BindingForActor(ctx, actor, claim) + Expect(err).NotTo(HaveOccurred()) + Expect(first.AddonID).To(Equal("renamed-runner")) + Expect(first.ExecutionTool).To(Equal("execute")) + Expect(first.StatusTool).To(Equal("inspect")) + setOwnerPin("alternate-worker") + second, err := a.addonGateway.BindingForActor(ctx, actor, claim) + Expect(err).NotTo(HaveOccurred()) + Expect(second.AddonID).To(Equal("alternate-worker")) + Expect(second.ExecutionTool).To(Equal("launch")) + Expect(second.StatusTool).To(Equal("readiness")) + for _, binding := range []gateway.ExecutionStatusBinding{first, second} { + runtime, err := a.addonManager.Runtime(ctx, binding.AddonID) + Expect(err).NotTo(HaveOccurred()) + Expect(binding.Generation).To(Equal(runtime.Generation)) + Expect(binding.Incarnation).To(Equal(runtime.Incarnation)) + } + Expect(a.addonGateway.AuthorizeExecutionStatus(ctx, actor, first)).NotTo(Succeed()) + Expect(a.addonGateway.AuthorizeExecutionStatus(ctx, actor, second)).To(Succeed()) + }) + It("binds the actual renamed running provider and rejects forged bindings and foreign actors", func() { + b, err := a.addonGateway.BindingForActor(ctx, actor, claim) + Expect(err).NotTo(HaveOccurred()) + Expect(b.AddonID).To(Equal("renamed-runner")) + Expect(b.ExecutionTool).To(Equal("execute")) + Expect(b.StatusTool).To(Equal("inspect")) + current, err := a.addonManager.Runtime(ctx, b.AddonID) + Expect(err).NotTo(HaveOccurred()) + Expect(b.Generation).To(Equal(current.Generation)) + Expect(b.Incarnation).To(Equal(current.Incarnation)) + Expect(a.addonGateway.AuthorizeExecutionStatus(ctx, actor, b)).To(Succeed()) + + forged := b + forged.StatusTool = "execute" + Expect(a.addonGateway.AuthorizeExecutionStatus(ctx, actor, forged)).NotTo(Succeed()) + foreign := actor + foreign.UserID = "other" + Expect(a.addonGateway.AuthorizeExecutionStatus(ctx, foreign, b)).NotTo(Succeed()) + }) + It("observes the actual renamed provider through the Studio typed adapter and withdraws stale facts", func() { + broker := a.StudioAddonTools().(*studioAddons) + request, addon, explanation := broker.NewExecutionObservation(ctx, studio.AnonymousPrincipal, claim.ConversationID) + Expect(request).NotTo(BeNil()) + defer request.Close() + Expect(addon).To(Equal("renamed-runner")) + value := request.Observe(ctx) + Expect(string(value.Readiness)).To(Equal("available")) + Expect(string(value.Isolation)).To(Equal("isolated")) + Expect(explanation()).To(BeEmpty()) + Expect(request.Observe(ctx)).To(Equal(value)) + Expect(a.studioStore.SetAddonsOff(ctx, studio.AnonymousPrincipal, claim.ConversationID, []string{"renamed-runner", "alternate-worker"})).To(Succeed()) + Expect(string(request.Observe(ctx).Isolation)).To(Equal("unknown")) + Expect(broker.Isolation(ctx, studio.AnonymousPrincipal, "renamed-runner")).To(BeEmpty()) + Expect(a.studioStore.SetAddonsOff(ctx, studio.AnonymousPrincipal, claim.ConversationID, nil)).To(Succeed()) + fresh, selected, _ := broker.NewExecutionObservation(ctx, studio.AnonymousPrincipal, claim.ConversationID) + Expect(fresh).NotTo(BeNil()) + defer fresh.Close() + Expect(selected).To(Equal("renamed-runner")) + Expect(string(fresh.Observe(ctx).Readiness)).To(Equal("available")) + }) + It("validates current pins and chat switches without historical ABA guarantees", func() { + b, err := a.addonGateway.BindingForActor(ctx, actor, claim) + Expect(err).NotTo(HaveOccurred()) + setOwnerPin("alternate-worker") + Expect(a.addonGateway.AuthorizeExecutionStatus(ctx, actor, b)).NotTo(Succeed()) + setOwnerPin("") + b, err = a.addonGateway.BindingForActor(ctx, actor, claim) + Expect(err).NotTo(HaveOccurred()) + Expect(a.studioStore.SetAddonsOff(ctx, studio.AnonymousPrincipal, claim.ConversationID, []string{"renamed-runner", "alternate-worker"})).To(Succeed()) + _, err = a.addonGateway.BindingForActor(ctx, actor, claim) + Expect(err).To(HaveOccurred()) + Expect(a.studioStore.SetAddonsOff(ctx, studio.AnonymousPrincipal, claim.ConversationID, nil)).To(Succeed()) + Expect(a.addonGateway.AuthorizeExecutionStatus(ctx, actor, b)).To(Succeed()) + }) + It("denies foreign chat access and invalid named credentials", func() { + Expect(a.studioStore.CreateChat(ctx, session.ChatRecord{ID: "foreign", Principal: "someone", Created: time.Now(), Updated: time.Now()})).To(Succeed()) + _, err := a.addonGateway.BindingForActor(ctx, actor, gateway.ExecutionBindingClaim{ConversationID: "foreign"}) + Expect(err).To(HaveOccurred()) + a.applicationConfig.Addons.OwnershipMode = auth.AddonModeNamedUser + _, err = a.addonGateway.ResolveExecutionBinding(ctx, actor, claim) + Expect(err).To(HaveOccurred()) + }) + It("uses current agent grants and denies unattended actions", func() { + ag := agents.Agent{ID: "agent", Principal: studio.AnonymousPrincipal, Name: "Runner", Addons: []string{"renamed-runner"}} + Expect(a.studioAgents.Put(ctx, ag)).To(Succeed()) + Expect(a.studioStore.CreateChat(ctx, session.ChatRecord{ID: "agent-chat", Principal: studio.AnonymousPrincipal, AgentID: ag.ID, Origin: "trigger", Created: time.Now(), Updated: time.Now()})).To(Succeed()) + c := gateway.ExecutionBindingClaim{ConversationID: "agent-chat"} + b, err := a.addonGateway.BindingForActor(ctx, actor, c) + Expect(err).NotTo(HaveOccurred()) + ag.Addons = nil + Expect(a.studioAgents.Put(ctx, ag)).To(Succeed()) + _, err = a.addonGateway.BindingForActor(ctx, actor, c) + Expect(err).To(HaveOccurred()) + ag.Addons = []string{"renamed-runner"} + Expect(a.studioAgents.Put(ctx, ag)).To(Succeed()) + Expect(a.addonGateway.AuthorizeExecutionStatus(ctx, actor, b)).To(Succeed()) + Expect(a.studioStore.CreateChat(ctx, session.ChatRecord{ID: "unattended", Principal: studio.AnonymousPrincipal, Origin: "trigger", Created: time.Now(), Updated: time.Now()})).To(Succeed()) + _, err = a.addonGateway.BindingForActor(ctx, actor, gateway.ExecutionBindingClaim{ConversationID: "unattended"}) + Expect(err).To(HaveOccurred()) + Expect(a.studioAgents.Put(ctx, agents.Agent{ID: "foreign-agent", Principal: "foreign", Name: "Foreign", Addons: []string{"renamed-runner"}})).To(Succeed()) + Expect(a.studioStore.CreateChat(ctx, session.ChatRecord{ID: "foreign-agent-chat", Principal: studio.AnonymousPrincipal, AgentID: "foreign-agent", Created: time.Now(), Updated: time.Now()})).To(Succeed()) + _, err = a.addonGateway.BindingForActor(ctx, actor, gateway.ExecutionBindingClaim{ConversationID: "foreign-agent-chat"}) + Expect(err).To(HaveOccurred()) + }) + It("rejects stale runtime incarnations without owner callback nonces", func() { + b, err := a.addonGateway.BindingForActor(ctx, actor, claim) + Expect(err).NotTo(HaveOccurred()) + Expect(a.wireExecutionBinding(a.addonGateway)).To(Succeed()) + Expect(a.addonGateway.AuthorizeExecutionStatus(ctx, actor, b)).To(Succeed()) + b, err = a.addonGateway.BindingForActor(ctx, actor, claim) + Expect(err).NotTo(HaveOccurred()) + _, err = a.addonManager.Submit(ctx, actor, addons.LifecycleCommand{OperationID: "disable", AddonID: b.AddonID, Action: "disable", Generation: b.Generation}) + Expect(err).NotTo(HaveOccurred()) + Expect(a.addonGateway.AuthorizeExecutionStatus(ctx, actor, b)).NotTo(Succeed()) + _, err = a.addonManager.Submit(ctx, actor, addons.LifecycleCommand{OperationID: "enable-again", AddonID: b.AddonID, Action: "enable", Generation: b.Generation}) + Expect(err).NotTo(HaveOccurred()) + now, err := a.addonGateway.BindingForActor(ctx, actor, claim) + Expect(err).NotTo(HaveOccurred()) + Expect(now.Incarnation).NotTo(Equal(b.Incarnation)) + Expect(a.addonGateway.AuthorizeExecutionStatus(ctx, actor, b)).NotTo(Succeed()) + }) + It("uses the ordinary chat store interface", func() { + original := a.studioStore + a.studioStore = ordinaryBindingStore{original} + _, err := a.addonGateway.BindingForActor(ctx, actor, claim) + Expect(err).NotTo(HaveOccurred()) + rec, err := a.studioStore.GetChat(ctx, studio.AnonymousPrincipal, claim.ConversationID) + Expect(err).NotTo(HaveOccurred()) + Expect(rec.ID).To(Equal(claim.ConversationID)) + a.studioStore = original + }) + +}) + +// The resolver needs only the ordinary current-read store interface. +type ordinaryBindingStore struct{ store.Store } + +var _ = Describe("workspace operation declarations", func() { + DescribeTable("retains the workspace dot prefix semantics", func(operations []string, want bool) { + Expect(hasWorkspaceOperation(operations)).To(Equal(want)) + adapter := &studioAddons{hostOps: map[string][]string{"sample": operations}} + Expect(adapter.needsWorkspace("sample")).To(Equal(want)) + Expect(adapter.needsWorkspace("other")).To(BeFalse()) + }, + Entry("nil", []string(nil), false), + Entry("empty", []string{}, false), + Entry("materialize", []string{"workspace.materialize"}, true), + Entry("read", []string{"workspace.read"}, true), + Entry("prefix only", []string{"workspace."}, true), + Entry("future suffix", []string{"workspace.future.operation"}, true), + Entry("mixed grants", []string{"egress.check", "workspace.materialize"}, true), + Entry("unrelated operations", []string{"egress.check", "settings.read"}, false), + Entry("bare workspace", []string{"workspace"}, false), + Entry("similar prefix", []string{"workspaces.read", "workspace-other.read", "Workspace.read", "other.workspace.read"}, false), + ) +}) diff --git a/core/application/startup_auth_test.go b/core/application/startup_auth_test.go index 1c6bb8ee1..0889857e5 100644 --- a/core/application/startup_auth_test.go +++ b/core/application/startup_auth_test.go @@ -5,8 +5,12 @@ package application import ( "context" + "fmt" "os" + "os/exec" "path/filepath" + "testing" + "time" "github.com/mudler/LocalAI/core/config" "github.com/mudler/LocalAI/pkg/system" @@ -14,9 +18,35 @@ import ( . "github.com/onsi/gomega" ) -var _ = Describe("auth database location at first start", func() { - It("creates a missing data directory before opening the default auth database", func() { - root := GinkgoT().TempDir() +const startupAuthCaseEnv = "LOCALAI_STARTUP_AUTH_TEST_CASE" +const startupAuthRootEnv = "LOCALAI_STARTUP_AUTH_TEST_ROOT" + +// Shutdown does not join every background producer. Keep the fixture in the +// parent: child exit, not context cancellation, is the barrier before TempDir +// cleanup. Running only this helper also avoids starting the Ginkgo suite again. +func runStartupAuthCase(id string) { + executable, err := os.Executable() + Expect(err).NotTo(HaveOccurred()) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + cmd := exec.CommandContext(ctx, executable, "-test.run=^TestStartupAuthProcess$", "-test.count=1", "-test.v") + cmd.Env = append(os.Environ(), startupAuthCaseEnv+"="+id, startupAuthRootEnv+"="+GinkgoT().TempDir()) + output := &warningOutput{} + cmd.Stdout, cmd.Stderr = output, output + err = cmd.Run() + Expect(err).NotTo(HaveOccurred(), "startup auth case %s (%v):\n%s", id, ctx.Err(), output.String()) + Expect(output.String()).To(ContainSubstring("startup auth case passed: "+id), "child must execute its assertions") +} + +func TestStartupAuthProcess(t *testing.T) { + id := os.Getenv(startupAuthCaseEnv) + if id == "" { + return + } + RegisterTestingT(t) + switch id { + case "default": + root := os.Getenv(startupAuthRootEnv) models, backends := filepath.Join(root, "models"), filepath.Join(root, "backends") Expect(os.Mkdir(models, 0750)).To(Succeed()) Expect(os.Mkdir(backends, 0750)).To(Succeed()) @@ -29,16 +59,15 @@ var _ = Describe("auth database location at first start", func() { config.WithAuthEnabled(true), config.WithAuthDatabaseURL(filepath.Join(data, "database.db")), config.WithAuthAPIKeyHMACSecret("secret")) Expect(err).NotTo(HaveOccurred()) - DeferCleanup(func() { cancel(); _ = app.Shutdown() }) + defer func() { cancel(); Expect(app.Shutdown()).To(Succeed()) }() Expect(app.AuthDB()).NotTo(BeNil()) info, err := os.Stat(data) Expect(err).NotTo(HaveOccurred()) Expect(info.Mode().Perm()&0o027).To(BeZero(), "directory keeps the 0750 semantics") _, err = os.Stat(filepath.Join(data, "database.db")) Expect(err).NotTo(HaveOccurred()) - }) - It("creates the parent of an explicit sqlite path outside the data directory", func() { - root := GinkgoT().TempDir() + case "explicit": + root := os.Getenv(startupAuthRootEnv) models, backends := filepath.Join(root, "models"), filepath.Join(root, "backends") Expect(os.Mkdir(models, 0750)).To(Succeed()) Expect(os.Mkdir(backends, 0750)).To(Succeed()) @@ -50,9 +79,21 @@ var _ = Describe("auth database location at first start", func() { app, err = New(config.WithContext(ctx), config.WithSystemState(state), config.WithDataPath(filepath.Join(root, "data")), config.WithAuthEnabled(true), config.WithAuthDatabaseURL(db), config.WithAuthAPIKeyHMACSecret("secret")) Expect(err).NotTo(HaveOccurred()) - DeferCleanup(func() { cancel(); _ = app.Shutdown() }) + defer func() { cancel(); Expect(app.Shutdown()).To(Succeed()) }() Expect(app.AuthDB()).NotTo(BeNil()) _, err = os.Stat(filepath.Join(root, "elsewhere", "auth", "users.db")) Expect(err).NotTo(HaveOccurred()) + default: + Expect(id).To(BeEmpty(), "unknown startup auth case") + } + fmt.Println("startup auth case passed: " + id) +} + +var _ = Describe("auth database location at first start", func() { + It("creates a missing data directory before opening the default auth database", func() { + runStartupAuthCase("default") + }) + It("creates the parent of an explicit sqlite path outside the data directory", func() { + runStartupAuthCase("explicit") }) }) diff --git a/core/application/studio_execution.go b/core/application/studio_execution.go new file mode 100644 index 000000000..3b5243f98 --- /dev/null +++ b/core/application/studio_execution.go @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: MIT +package application + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "strconv" + "sync" + "time" + + "github.com/mudler/LocalAI/core/services/addons/gateway" + "github.com/mudler/LocalAI/core/services/studio" + "github.com/mudler/LocalAI/core/services/studio/executionrequest" + "github.com/mudler/LocalAI/pkg/addons/contract" + model "github.com/mudler/LocalAI/pkg/addons/model/executionstatus" +) + +func (l lateStudioAddons) NewExecutionObservation(ctx context.Context, user, conversation string) (*executionrequest.Request, string, func() string) { + if p, ok := l.a.StudioAddonTools().(studio.ExecutionObserver); ok { + return p.NewExecutionObservation(ctx, user, conversation) + } + return nil, "", nil +} + +func (s *studioAddons) NewExecutionObservation(ctx context.Context, user, conversation string) (*executionrequest.Request, string, func() string) { + actor, err := s.actor(user) + if err != nil { + return nil, "", nil + } + b, err := s.adapter.BindingForActor(ctx, actor, gateway.ExecutionBindingClaim{ConversationID: conversation}) + if err != nil { + return nil, "", nil + } + var id [24]byte + if _, err = rand.Read(id[:]); err != nil { + return nil, "", nil + } + q := model.ObservationID(hex.EncodeToString(id[:])) + // Keep actor and conversation identity explicit. IO retains the original DTO. + mb := model.Binding{Actor: actor.UserID, Credential: actor.CredentialID, Conversation: b.ConversationID, Mode: actor.Mode, Addon: b.AddonID, Generation: strconv.FormatUint(b.Generation, 10), Incarnation: b.Incarnation, ExecutionTool: b.ExecutionTool, StatusTool: b.StatusTool} + var mu sync.Mutex + explanation := "" + io := executionrequest.IO{ + Validate: func(ctx context.Context, current model.Binding) error { + if current != mb { + return gateway.ErrForbidden + } + return s.adapter.ValidateExecutionBinding(ctx, actor, b) + }, + Read: func(ctx context.Context, current model.Binding, observation model.ObservationID, deadline time.Time) (model.Payload, error) { + if current != mb || observation != q { + return model.InvalidPayload(), gateway.ErrForbidden + } + ctx, cancel := context.WithDeadline(ctx, deadline) + defer cancel() + reply, err := s.adapter.ExecutionStatusRead(ctx, actor, gateway.ExecutionStatusRequest{Version: 1, Binding: b, ObservationID: string(q)}) + if err != nil { + return model.InvalidPayload(), err + } + if reply.Version != 1 || reply.Binding != b || reply.ObservationID != string(q) { + return model.InvalidPayload(), gateway.ErrForbidden + } + raw, err := json.Marshal(reply.Status) + if err != nil { + return model.InvalidPayload(), err + } + status, err := contract.DecodeExecutionStatus(raw) + if err != nil { + return model.InvalidPayload(), err + } + mu.Lock() + explanation = status.Explanation + mu.Unlock() + return model.ValidPayload(model.Value{Readiness: model.Readiness(status.Readiness), Isolation: model.Isolation(status.Isolation)}), nil + }, + } + request := executionrequest.New(ctx, mb, q, model.Eligible, io, nil) + return request, b.AddonID, func() string { mu.Lock(); defer mu.Unlock(); return explanation } +} diff --git a/core/application/studio_service.go b/core/application/studio_service.go index 4db79186c..ff41700b5 100644 --- a/core/application/studio_service.go +++ b/core/application/studio_service.go @@ -410,7 +410,7 @@ func (a *Application) studioRemoteModel(model string) bool { // studioHostExecMode is chats_host_exec as it applies now. Empty is the // default: off on a server with accounts (several people), ask without. func (a *Application) studioHostExecMode() string { - if m := a.applicationConfig.ChatsHostExec; m != "" { + if m := a.applicationConfig.ChatSelectionSnapshot().HostExec; m != "" { return m } if a.authDB != nil { diff --git a/core/application/studio_store_test.go b/core/application/studio_store_test.go index 6dfdbec64..d14ba0d35 100644 --- a/core/application/studio_store_test.go +++ b/core/application/studio_store_test.go @@ -39,15 +39,15 @@ var _ = Describe("studio store at startup", func() { Expect(app.studioStoreKind).To(Equal("gorm")) }) - It("uses JSON files under the data path without a database, and keeps them across a restart", func() { + It("uses JSON files under the data path", func() { dir := GinkgoT().TempDir() first := newApp(config.WithDataPath(dir)) Expect(first.studioStoreKind).To(Equal("json")) Expect(first.StudioStore().CreateChat(context.Background(), session.ChatRecord{ID: "kept", Principal: "", Title: "Kept"})).To(Succeed()) - second := newApp(config.WithDataPath(dir)) - rec, err := second.StudioStore().GetChat(context.Background(), "", "kept") + Expect(first.Shutdown()).To(Succeed()) + rec, err := first.StudioStore().GetChat(context.Background(), "", "kept") Expect(err).NotTo(HaveOccurred()) Expect(rec.Title).To(Equal("Kept")) }) diff --git a/core/cli/run.go b/core/cli/run.go index 9221356d4..865aad5af 100644 --- a/core/cli/run.go +++ b/core/cli/run.go @@ -33,7 +33,8 @@ import ( // and document the deprecation in the help text. type RunCMD struct { - AddonHostOps string `name:"addon-host-ops" env:"LOCALAI_ADDON_HOST_OPS" default:"fine-tuning=finetune.,quantization=quantize.,sandbox=workspace.,sandbox=egress.,office=workspace.,web=workspace.,telegram=connector." help:"Add-ons that may call host operations, as a comma list of addon-id=operation-prefix. An add-on that is not listed gets no host operation channel. Empty gives none" group:"addons"` + AddonHostPolicy string `name:"addon-host-policy" env:"LOCALAI_ADDON_HOST_POLICY" help:"Absolute path to administrator host operation policy JSON, read once at startup. Empty disables environment policy" group:"addons"` + AddonHostOps string `name:"addon-host-ops" env:"LOCALAI_ADDON_HOST_OPS" help:"Add-ons that may call host operations, as a comma list of addon-id=operation-prefix. An add-on that is not listed gets no host operation channel. Empty gives none" group:"addons"` AddonsEnabled bool `name:"addons-enabled" env:"LOCALAI_ADDONS_ENABLED" default:"true" help:"Run the add-on owner, which installs and supervises add-ons. Turn it off with --addons-enabled=false" group:"addons"` AddonsOwnershipMode string `name:"addons-ownership-mode" env:"LOCALAI_ADDONS_OWNERSHIP_MODE" help:"Who may use add-ons: named-user (each account has its own access; needs authentication) or local-owner (every caller shares one owner). Default: local-owner when authentication is off, named-user when it is on" group:"addons"` AddonsRole string `name:"addons-role" env:"LOCALAI_ADDONS_ROLE" default:"combined" help:"How this process takes part in the add-on owner: combined (this node is its own owner, the default), owner (only the owner, serves the carrier on the network) or frontend (no owner here; every add-on request goes to the owner). owner and frontend need --addons-owner-address and --addons-carrier-dir" group:"addons"` @@ -52,6 +53,9 @@ type RunCMD struct { PprofBlockProfileRate int `name:"pprof-block-profile-rate" env:"LOCALAI_PPROF_BLOCK_PROFILE_RATE" default:"0" help:"Block sampling rate in nanoseconds (0 disables sampling); requires pprof" group:"diagnostics"` RequestPhaseTiming bool `name:"request-phase-timing" env:"LOCALAI_REQUEST_PHASE_TIMING" default:"false" help:"Log sanitized request and config-reload phase timings at Info" group:"diagnostics"` + addonHostOpsFlagPresent bool + addonHostPolicyFlagPresent bool + ModelArgs []string `arg:"" optional:"" name:"models" help:"Model configuration URLs to load"` Color string `env:"COLOR" hidden:""` NoColor string `env:"NO_COLOR" hidden:""` @@ -308,6 +312,12 @@ func (r *RunCMD) Run(ctx *cliContext.Context) (result error) { return nil } + hostOps, err := r.resolveAddonHostOps() + if err != nil { + return err + } + logAddonHostOpsResolution(hostOps) + diagnosticOpts := r.diagnosticsOptions() if err := diagnosticOpts.Validate(); err != nil { return err @@ -360,13 +370,8 @@ func (r *RunCMD) Run(ctx *cliContext.Context) (result error) { if !r.PreloadBackendOnly { defer cancelRun(nil) } - hostOps, err := config.ParseAddonHostOps(r.AddonHostOps) - if err != nil { - cancelRun(nil) - return fmt.Errorf("--addon-host-ops: %w", err) - } opts := []config.AppOption{ - config.WithAddonHostOps(hostOps), + config.WithAddonHostOps(hostOps.Grants), config.WithProxyAPIKeyEnvLookup(os.Getenv), config.WithContext(runCtx), config.WithArtifactDownloadConcurrency(r.ArtifactDownloadConcurrency), diff --git a/core/cli/run_addon_host_ops_test.go b/core/cli/run_addon_host_ops_test.go index bab4bffe6..278ce73ba 100644 --- a/core/cli/run_addon_host_ops_test.go +++ b/core/cli/run_addon_host_ops_test.go @@ -2,18 +2,127 @@ package cli import ( + "os" + "path/filepath" "reflect" + "github.com/alecthomas/kong" "github.com/mudler/LocalAI/core/config" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) var _ = Describe("the add-on host operations flag", func() { - It("defaults to the documented setting", func() { + It("has no compiled grant default", func() { f, ok := reflect.TypeOf(RunCMD{}).FieldByName("AddonHostOps") Expect(ok).To(BeTrue(), "the flag is missing") - Expect(f.Tag.Get("default")).To(Equal(config.DefaultAddonHostOps)) + Expect(f.Tag.Get("default")).To(BeEmpty()) Expect(f.Tag.Get("env")).To(Equal("LOCALAI_ADDON_HOST_OPS")) }) }) + +var _ = Describe("addon host policy CLI precedence", Serial, func() { + parse := func(args ...string) *RunCMD { + command := struct { + Run RunCMD `cmd:"" default:"withargs"` + }{} + p, err := kong.New(&command, kong.Vars{ + "basepath": kong.ExpandPath("."), "generatedcontentpath": DefaultGeneratedContentPath(), "uploadpath": DefaultUploadPath(), + "galleries": config.DefaultGalleriesJSON, "backends": config.DefaultBackendGalleriesJSON, + }) + Expect(err).NotTo(HaveOccurred()) + _, err = p.Parse(args) + Expect(err).NotTo(HaveOccurred()) + return &command.Run + } + BeforeEach(func() { + for _, key := range []string{"LOCALAI_ADDON_HOST_OPS", "LOCALAI_ADDON_HOST_POLICY"} { + old, ok := os.LookupEnv(key) + DeferCleanup(func() { + if ok { + Expect(os.Setenv(key, old)).To(Succeed()) + } else { + Expect(os.Unsetenv(key)).To(Succeed()) + } + }) + Expect(os.Unsetenv(key)).To(Succeed()) + } + }) + It("selects an explicitly configured policy", func() { + p := filepath.Join(GinkgoT().TempDir(), "policy.json") + Expect(os.WriteFile(p, []byte(`{"schemaVersion":1,"grants":{"custom":["workspace."]}}`), 0600)).To(Succeed()) + GinkgoT().Setenv("LOCALAI_ADDON_HOST_POLICY", p) + got, err := parse().resolveAddonHostOps() + Expect(err).NotTo(HaveOccurred()) + Expect(got.Source).To(Equal("policy:" + p)) + Expect(got.Grants).To(Equal(map[string][]string{"custom": {"workspace."}})) + }) + It("uses flag presence even when empty and ignores an invalid policy", func() { + GinkgoT().Setenv("LOCALAI_ADDON_HOST_OPS", "env=x.") + GinkgoT().Setenv("LOCALAI_ADDON_HOST_POLICY", "/missing/policy") + got, err := parse("--addon-host-ops=").resolveAddonHostOps() + Expect(err).NotTo(HaveOccurred()) + Expect(got.Grants).To(BeEmpty()) + Expect(got.Source).To(Equal("flag")) + Expect(got.PolicyIgnored).To(BeTrue()) + }) + It("uses explicit empty environment instead of policy", func() { + GinkgoT().Setenv("LOCALAI_ADDON_HOST_OPS", "") + GinkgoT().Setenv("LOCALAI_ADDON_HOST_POLICY", "/missing/policy") + got, err := parse().resolveAddonHostOps() + Expect(err).NotTo(HaveOccurred()) + Expect(got.Grants).To(BeEmpty()) + Expect(got.Source).To(Equal("environment")) + Expect(got.PolicyIgnored).To(BeTrue()) + }) + It("does not union a nonempty flag with environment or policy", func() { + GinkgoT().Setenv("LOCALAI_ADDON_HOST_OPS", "env=x.") + GinkgoT().Setenv("LOCALAI_ADDON_HOST_POLICY", "/missing/policy") + got, err := parse("--addon-host-ops=flag=y.").resolveAddonHostOps() + Expect(err).NotTo(HaveOccurred()) + Expect(got.Grants).To(Equal(map[string][]string{"flag": {"y."}})) + }) + It("lets an empty policy flag disable environment policy without restoring defaults", func() { + GinkgoT().Setenv("LOCALAI_ADDON_HOST_POLICY", "/missing/policy") + got, err := parse("--addon-host-policy=").resolveAddonHostOps() + Expect(err).NotTo(HaveOccurred()) + Expect(got.Grants).To(BeEmpty()) + Expect(got.Source).To(Equal("empty")) + }) + It("lets a policy flag replace environment policy", func() { + p := filepath.Join(GinkgoT().TempDir(), "policy.json") + Expect(os.WriteFile(p, []byte(`{"schemaVersion":1,"grants":{}}`), 0600)).To(Succeed()) + GinkgoT().Setenv("LOCALAI_ADDON_HOST_POLICY", "/missing/policy") + got, err := parse("--addon-host-policy", p).resolveAddonHostOps() + Expect(err).NotTo(HaveOccurred()) + Expect(got.Source).To(Equal("policy:" + p)) + Expect(got.Grants).To(BeEmpty()) + }) + It("fails selected invalid policy rather than falling back", func() { + GinkgoT().Setenv("LOCALAI_ADDON_HOST_POLICY", "/missing/policy") + _, err := parse().resolveAddonHostOps() + Expect(err).To(HaveOccurred()) + }) + It("fails startup before creating directories for a selected invalid policy", func() { + GinkgoT().Setenv("LOCALAI_ADDON_HOST_POLICY", "/missing/policy") + r := parse() + r.ModelsPath = filepath.Join(GinkgoT().TempDir(), "not-created") + Expect(r.Run(nil)).To(MatchError(ContainSubstring("addon host operations (policy)"))) + _, err := os.Stat(r.ModelsPath) + Expect(os.IsNotExist(err)).To(BeTrue()) + }) + It("keeps a resolved policy snapshot when the file changes", func() { + p := filepath.Join(GinkgoT().TempDir(), "policy.json") + Expect(os.WriteFile(p, []byte(`{"schemaVersion":1,"grants":{"a":["workspace."]}}`), 0600)).To(Succeed()) + got, err := parse("--addon-host-policy=" + p).resolveAddonHostOps() + Expect(err).NotTo(HaveOccurred()) + Expect(os.WriteFile(p, []byte(`{"schemaVersion":1,"grants":{}}`), 0600)).To(Succeed()) + Expect(got.Grants).To(Equal(map[string][]string{"a": {"workspace."}})) + }) + It("grants nothing when all selectors are absent", func() { + got, err := parse().resolveAddonHostOps() + Expect(err).NotTo(HaveOccurred()) + Expect(got.Grants).To(BeEmpty()) + Expect(got.Source).To(Equal("empty")) + }) +}) diff --git a/core/cli/run_addon_host_policy.go b/core/cli/run_addon_host_policy.go new file mode 100644 index 000000000..8ea389d6b --- /dev/null +++ b/core/cli/run_addon_host_policy.go @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: MIT +package cli + +import ( + "os" + + "github.com/alecthomas/kong" + "github.com/mudler/LocalAI/core/config" + "github.com/mudler/xlog" +) + +// AfterApply records actual command-line presence, not default or environment +// values. Kong's Path trace distinguishes an explicit empty flag from omission. +func (r *RunCMD) AfterApply(ctx *kong.Context) error { + r.addonHostOpsFlagPresent = false + r.addonHostPolicyFlagPresent = false + for _, path := range ctx.Path { + if path.Flag == nil || path.Resolved { + continue + } + switch path.Flag.Name { + case "addon-host-ops": + r.addonHostOpsFlagPresent = true + case "addon-host-policy": + r.addonHostPolicyFlagPresent = true + } + } + return nil +} + +func (r *RunCMD) resolveAddonHostOps() (config.AddonHostOpsResolution, error) { + var flag, environment, policy *string + if r.addonHostOpsFlagPresent { + flag = &r.AddonHostOps + } + if value, present := os.LookupEnv("LOCALAI_ADDON_HOST_OPS"); present { + environment = &value + } + if r.addonHostPolicyFlagPresent { + policy = &r.AddonHostPolicy + } else if value, present := os.LookupEnv("LOCALAI_ADDON_HOST_POLICY"); present { + policy = &value + } + resolved, err := config.ResolveAddonHostOps(flag, environment, policy) + if err == nil && resolved.Source == "policy" { + resolved.Source = "policy:" + *policy + } + return resolved, err +} + +func logAddonHostOpsResolution(resolved config.AddonHostOpsResolution) { + if resolved.Source == "empty" { + xlog.Warn("Addons receive no host operations: select --addon-host-policy or explicitly configure --addon-host-ops") + } + prefixes := 0 + for _, grants := range resolved.Grants { + prefixes += len(grants) + } + xlog.Info("Addon host operation policy resolved", "source", resolved.Source, "addons", len(resolved.Grants), "prefixes", prefixes) + if resolved.PolicyIgnored { + xlog.Warn("Addon host policy ignored: explicit host operations override takes precedence") + } + xlog.Info("Addon manifests and libraries cannot grant host operations; policy changes require restart") +} diff --git a/core/config/addon_host_ops.go b/core/config/addon_host_ops.go index 99fd8424d..239587131 100644 --- a/core/config/addon_host_ops.go +++ b/core/config/addon_host_ops.go @@ -7,11 +7,6 @@ import ( "strings" ) -// DefaultAddonHostOps names the add-ons that may call host operations, and -// the operation prefixes each may call. It is a host setting: neither a -// manifest field nor a model argument can add to it. -const DefaultAddonHostOps = "fine-tuning=finetune.,quantization=quantize.,sandbox=workspace.,sandbox=egress.,office=workspace.,web=workspace.,telegram=connector." - var ( addonIDForHostOps = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) hostOpPrefix = regexp.MustCompile(`^[a-z]+\.([a-z]+\.)*$`) @@ -29,7 +24,7 @@ func ParseAddonHostOps(s string) (map[string][]string, error) { id, prefix, ok := strings.Cut(item, "=") id, prefix = strings.TrimSpace(id), strings.TrimSpace(prefix) if !ok || !addonIDForHostOps.MatchString(id) || !hostOpPrefix.MatchString(prefix) { - return nil, fmt.Errorf("invalid host operation entry %q: want addon-id=op-prefix, such as fine-tuning=finetune.", item) + return nil, fmt.Errorf("invalid host operation entry %q: want addon-id=op-prefix, such as custom-addon=workspace.", item) } out[id] = append(out[id], prefix) } @@ -37,8 +32,9 @@ func ParseAddonHostOps(s string) (map[string][]string, error) { } // WithAddonHostOps sets which add-ons may call which host operations. -// Without this option no add-on gets a host operation endpoint; the run -// command passes the default setting. +// Nil and empty maps grant nothing. The run command resolves explicit host +// policy before passing its startup snapshot here. func WithAddonHostOps(ops map[string][]string) AppOption { - return func(o *ApplicationConfig) { o.AddonHostOps = ops } + snapshot := CloneAddonHostOps(ops) + return func(o *ApplicationConfig) { o.AddonHostOps = CloneAddonHostOps(snapshot) } } diff --git a/core/config/addon_host_ops_test.go b/core/config/addon_host_ops_test.go index 20ae1f7de..162cbd40e 100644 --- a/core/config/addon_host_ops_test.go +++ b/core/config/addon_host_ops_test.go @@ -7,8 +7,8 @@ import ( ) var _ = Describe("ParseAddonHostOps", func() { - It("parses the default setting", func() { - got, err := ParseAddonHostOps(DefaultAddonHostOps) + It("preserves the historical explicit grant string", func() { + got, err := ParseAddonHostOps("fine-tuning=finetune.,quantization=quantize.,sandbox=workspace.,sandbox=egress.,office=workspace.,web=workspace.,telegram=connector.") Expect(err).ToNot(HaveOccurred()) Expect(got).To(Equal(map[string][]string{"fine-tuning": {"finetune."}, "quantization": {"quantize."}, "sandbox": {"workspace.", "egress."}, "office": {"workspace."}, "web": {"workspace."}, "telegram": {"connector."}})) }) @@ -41,7 +41,16 @@ var _ = Describe("ParseAddonHostOps", func() { }) var _ = Describe("WithAddonHostOps", func() { - It("replaces the default", func() { + It("does not resurrect grants for nil or empty programmatic configuration", func() { + o := &ApplicationConfig{} + Expect(o.AddonHostOps).To(BeEmpty()) + for _, grants := range []map[string][]string{nil, {}} { + WithAddonHostOps(map[string][]string{"custom": {"workspace."}})(o) + WithAddonHostOps(grants)(o) + Expect(o.AddonHostOps).To(BeEmpty()) + } + }) + It("sets only the supplied grants", func() { o := &ApplicationConfig{} Expect(o.AddonHostOps).To(BeNil(), "the field starts unset") WithAddonHostOps(map[string][]string{"x": {"y."}})(o) diff --git a/core/config/addon_host_policy.go b/core/config/addon_host_policy.go new file mode 100644 index 000000000..881295009 --- /dev/null +++ b/core/config/addon_host_policy.go @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: MIT +package config + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" +) + +const maxAddonHostPolicyBytes = 64 * 1024 + +// AddonHostOpsResolution is a startup snapshot. Consumers must not mutate Grants. +// Source and PolicyIgnored are bounded diagnostics, never policy file contents. +type AddonHostOpsResolution struct { + Grants map[string][]string + Source string + PolicyIgnored bool +} + +// ResolveAddonHostOps applies presence-based precedence. Nil means absent; +// a pointer to an empty string is an explicit decision to grant nothing. +// policyPath is already selected by the caller (flag, including empty, over env). +func ResolveAddonHostOps(flag, environment, policyPath *string) (AddonHostOpsResolution, error) { + out := AddonHostOpsResolution{Grants: map[string][]string{}, Source: "empty"} + selected := flag + if selected != nil { + out.Source = "flag" + } else if environment != nil { + selected = environment + out.Source = "environment" + } + var err error + if selected != nil { + out.PolicyIgnored = policyPath != nil && *policyPath != "" + out.Grants, err = ParseAddonHostOps(*selected) + } else if policyPath != nil && *policyPath != "" { + out.Source = "policy" + out.Grants, err = LoadAddonHostPolicy(*policyPath) + } + if err != nil { + return out, fmt.Errorf("addon host operations (%s): %w", out.Source, err) + } + return out, nil +} + +// LoadAddonHostPolicy reads only an explicitly selected administrator file. +// Ownership and mount permissions are the deployment administrator's responsibility. +// Symlinks to regular files are permitted; this is not a filesystem sandbox. +func LoadAddonHostPolicy(path string) (map[string][]string, error) { + if !filepath.IsAbs(path) { + return nil, fmt.Errorf("policy path must be absolute") + } + // Stat before opening rejects directories and special files (including FIFOs) + // without a potentially blocking read. Recheck the opened descriptor as well. + info, err := os.Stat(path) + if err != nil { + return nil, fmt.Errorf("stat policy: %w", err) + } + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("policy must be a regular file") + } + // #nosec G304 -- Only the administrator selects this startup policy path via CLI/config; regular-file and 64 KiB checks bound its read. + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open policy: %w", err) + } + // Read-only cleanup: close errors cannot change the decoded result. + defer func() { _ = f.Close() }() + info, err = f.Stat() + if err != nil { + return nil, fmt.Errorf("stat opened policy: %w", err) + } + if !info.Mode().IsRegular() || info.Size() > maxAddonHostPolicyBytes { + return nil, fmt.Errorf("policy must be a regular file of at most 64 KiB") + } + data, err := io.ReadAll(io.LimitReader(f, maxAddonHostPolicyBytes+1)) + if err != nil { + return nil, fmt.Errorf("read policy: %w", err) + } + if len(data) > maxAddonHostPolicyBytes { + return nil, fmt.Errorf("policy exceeds 64 KiB") + } + return parseAddonHostPolicy(data) +} + +// Token decoding deliberately avoids encoding/json struct-field case folding +// and map decoding's last-key-wins behavior. +func parseAddonHostPolicy(data []byte) (map[string][]string, error) { + d := json.NewDecoder(bytes.NewReader(data)) + d.UseNumber() + expect := func(want json.Delim) error { + t, err := d.Token() + if err != nil { + return err + } + if t != want { + return fmt.Errorf("expected %q", want) + } + return nil + } + if err := expect('{'); err != nil { + return nil, err + } + seen := map[string]bool{} + grants := map[string][]string{} + for d.More() { + key, err := d.Token() + if err != nil { + return nil, err + } + name, ok := key.(string) + if !ok || seen[name] { + return nil, fmt.Errorf("duplicate or invalid policy key") + } + seen[name] = true + switch name { + case "schemaVersion": + version, err := d.Token() + if err != nil { + return nil, err + } + if version != json.Number("1") { + return nil, fmt.Errorf("schemaVersion must be integer 1") + } + case "grants": + if err := expect('{'); err != nil { + return nil, err + } + for d.More() { + token, err := d.Token() + if err != nil { + return nil, err + } + id, ok := token.(string) + if !ok || !addonIDForHostOps.MatchString(id) { + return nil, fmt.Errorf("invalid policy addon ID") + } + if _, exists := grants[id]; exists { + return nil, fmt.Errorf("duplicate policy addon ID") + } + if len(grants) >= 128 { + return nil, fmt.Errorf("policy exceeds 128 addons") + } + if err := expect('['); err != nil { + return nil, err + } + prefixes := []string{} + seenPrefixes := map[string]bool{} + for d.More() { + token, err := d.Token() + if err != nil { + return nil, err + } + prefix, ok := token.(string) + if !ok || !hostOpPrefix.MatchString(prefix) || seenPrefixes[prefix] { + return nil, fmt.Errorf("invalid or duplicate policy prefix") + } + if len(prefixes) >= 32 { + return nil, fmt.Errorf("policy exceeds 32 prefixes per addon") + } + prefixes = append(prefixes, prefix) + seenPrefixes[prefix] = true + } + if err := expect(']'); err != nil { + return nil, err + } + grants[id] = prefixes + } + if err := expect('}'); err != nil { + return nil, err + } + default: + return nil, fmt.Errorf("unknown policy key") + } + } + if err := expect('}'); err != nil { + return nil, err + } + if !seen["schemaVersion"] || !seen["grants"] { + return nil, fmt.Errorf("policy requires schemaVersion and grants") + } + if _, err := d.Token(); err != io.EOF { + return nil, fmt.Errorf("unexpected trailing policy data") + } + return grants, nil +} + +// CloneAddonHostOps isolates startup grants from caller-owned maps and slices. +func CloneAddonHostOps(ops map[string][]string) map[string][]string { + if ops == nil { + return nil + } + out := make(map[string][]string, len(ops)) + for id, prefixes := range ops { + out[id] = append([]string(nil), prefixes...) + } + return out +} diff --git a/core/config/addon_host_policy_test.go b/core/config/addon_host_policy_test.go new file mode 100644 index 000000000..e548bbc86 --- /dev/null +++ b/core/config/addon_host_policy_test.go @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: MIT +package config + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Addon host policy", func() { + write := func(body string) string { + p := filepath.Join(GinkgoT().TempDir(), "policy.json") + Expect(os.WriteFile(p, []byte(body), 0600)).To(Succeed()) + return p + } + It("loads exact grants and accepts an empty mapping", func() { + got, err := LoadAddonHostPolicy(write(`{"schemaVersion":1,"grants":{"sandbox":["workspace.","egress."]}}`)) + Expect(err).NotTo(HaveOccurred()) + Expect(got).To(Equal(map[string][]string{"sandbox": {"workspace.", "egress."}})) + got, err = LoadAddonHostPolicy(write(`{"schemaVersion":1,"grants":{}}`)) + Expect(err).NotTo(HaveOccurred()) + Expect(got).To(BeEmpty()) + }) + DescribeTable("rejects strict schema violations", func(body string) { + _, err := LoadAddonHostPolicy(write(body)) + Expect(err).To(HaveOccurred()) + }, + Entry("missing version", `{"grants":{}}`), + Entry("missing grants", `{"schemaVersion":1}`), + Entry("null grants", `{"schemaVersion":1,"grants":null}`), + Entry("null prefixes", `{"schemaVersion":1,"grants":{"a":null}}`), + Entry("wrong version", `{"schemaVersion":2,"grants":{}}`), + Entry("string version", `{"schemaVersion":"1","grants":{}}`), + Entry("fraction version", `{"schemaVersion":1.0,"grants":{}}`), + Entry("unknown key", `{"schemaVersion":1,"grants":{},"extra":true}`), + Entry("case variant", `{"SchemaVersion":1,"grants":{}}`), + Entry("duplicate key", `{"schemaVersion":1,"schemaVersion":1,"grants":{}}`), + Entry("duplicate addon", `{"schemaVersion":1,"grants":{"a":[],"a":[]}}`), + Entry("escaped duplicate", `{"schemaVersion":1,"grants":{"a":[],"\u0061":[]}}`), + Entry("duplicate prefix", `{"schemaVersion":1,"grants":{"a":["x.","x."]}}`), + Entry("invalid addon", `{"schemaVersion":1,"grants":{"A":["x."]}}`), + Entry("invalid prefix", `{"schemaVersion":1,"grants":{"a":["X."]}}`), + Entry("untrimmed prefix", `{"schemaVersion":1,"grants":{"a":[" x."]}}`), + Entry("exact operation", `{"schemaVersion":1,"grants":{"a":["workspace.read"]}}`), + Entry("non string", `{"schemaVersion":1,"grants":{"a":[1]}}`), + Entry("trailing JSON", `{"schemaVersion":1,"grants":{}} {}`), + Entry("malformed", `{"schemaVersion":1,`), + Entry("array", `[]`), + ) + It("enforces file type, absolute paths, existence and size", func() { + for _, p := range []string{"relative.json", "https://example.com/policy.json", GinkgoT().TempDir(), filepath.Join(GinkgoT().TempDir(), "absent")} { + _, err := LoadAddonHostPolicy(p) + Expect(err).To(HaveOccurred(), p) + } + body := `{"schemaVersion":1,"grants":{}}` + _, err := LoadAddonHostPolicy(write(body + strings.Repeat(" ", 65536-len(body)))) + Expect(err).NotTo(HaveOccurred()) + _, err = LoadAddonHostPolicy(write(body + strings.Repeat(" ", 65537-len(body)))) + Expect(err).To(HaveOccurred()) + }) + It("permits a symlink to an explicitly selected regular file", func() { + path := write(`{"schemaVersion":1,"grants":{}}`) + link := filepath.Join(GinkgoT().TempDir(), "selected.json") + Expect(os.Symlink(path, link)).To(Succeed()) + got, err := LoadAddonHostPolicy(link) + Expect(err).NotTo(HaveOccurred()) + Expect(got).To(BeEmpty()) + }) + It("rejects an unreadable file when the operating system denies access", func() { + path := write(`{"schemaVersion":1,"grants":{}}`) + Expect(os.Chmod(path, 0000)).To(Succeed()) + if os.Geteuid() == 0 { + Skip("root can read mode-0000 files") + } + _, err := LoadAddonHostPolicy(path) + Expect(err).To(HaveOccurred()) + }) + It("bounds addon and prefix counts", func() { + entries := []string{} + for i := 0; i < 129; i++ { + entries = append(entries, fmt.Sprintf(`"a-%d":[]`, i)) + } + _, err := LoadAddonHostPolicy(write(`{"schemaVersion":1,"grants":{` + strings.Join(entries[:128], ",") + `}}`)) + Expect(err).NotTo(HaveOccurred()) + _, err = LoadAddonHostPolicy(write(`{"schemaVersion":1,"grants":{` + strings.Join(entries, ",") + `}}`)) + Expect(err).To(HaveOccurred()) + prefixes := []string{} + for i := 0; i < 33; i++ { + prefixes = append(prefixes, fmt.Sprintf(`"x.%s."`, string(rune('a'+i/26))+string(rune('a'+i%26)))) + } + _, err = LoadAddonHostPolicy(write(`{"schemaVersion":1,"grants":{"a":[` + strings.Join(prefixes[:32], ",") + `]}}`)) + Expect(err).NotTo(HaveOccurred()) + _, err = LoadAddonHostPolicy(write(`{"schemaVersion":1,"grants":{"a":[` + strings.Join(prefixes, ",") + `]}}`)) + Expect(err).To(HaveOccurred()) + }) + It("resolves presence without union or opening an ignored policy", func() { + empty, env, flag := "", "env=x.", "flag=y." + bad := "/not-present-policy.json" + policy := write(`{"schemaVersion":1,"grants":{"file":["z."]}}`) + for _, tc := range []struct { + flag, env, policy *string + want map[string][]string + source string + ignored bool + }{ + {nil, nil, nil, map[string][]string{}, "empty", false}, + {nil, nil, &policy, map[string][]string{"file": {"z."}}, "policy", false}, + {nil, &env, &bad, map[string][]string{"env": {"x."}}, "environment", true}, + {&flag, &env, &bad, map[string][]string{"flag": {"y."}}, "flag", true}, + {&empty, &env, &bad, map[string][]string{}, "flag", true}, + {nil, &empty, &bad, map[string][]string{}, "environment", true}, + {nil, nil, &empty, map[string][]string{}, "empty", false}, + } { + got, err := ResolveAddonHostOps(tc.flag, tc.env, tc.policy) + Expect(err).NotTo(HaveOccurred()) + Expect(got.Grants).To(Equal(tc.want)) + Expect(got.Source).To(Equal(tc.source)) + Expect(got.PolicyIgnored).To(Equal(tc.ignored)) + } + _, err := ResolveAddonHostOps(nil, nil, &bad) + Expect(err).To(HaveOccurred()) + }) + It("snapshots programmatic grants including an exact empty decision", func() { + original := map[string][]string{"a": {"x."}} + option := WithAddonHostOps(original) + original["a"][0] = "changed." + c := &ApplicationConfig{} + option(c) + Expect(c.AddonHostOps).To(Equal(map[string][]string{"a": {"x."}})) + c.AddonHostOps["a"][0] = "other." + second := &ApplicationConfig{} + option(second) + Expect(second.AddonHostOps).To(Equal(map[string][]string{"a": {"x."}})) + WithAddonHostOps(map[string][]string{})(c) + Expect(c.AddonHostOps).To(BeEmpty()) + }) +}) diff --git a/core/config/addon_model_actions_test.go b/core/config/addon_model_actions_test.go new file mode 100644 index 000000000..75ae121b3 --- /dev/null +++ b/core/config/addon_model_actions_test.go @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +package config + +import ( + "github.com/mudler/LocalAI/pkg/addons/contract" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "slices" +) + +var _ = Describe("Addon model capability vocabulary", func() { + It("matches every concrete non-native host model flag", func() { + var expected []string + for name := range GetAllModelConfigUsecases() { + if name != "FLAG_CHAT" && name != "FLAG_COMPLETION" && name != "FLAG_LLM" { + expected = append(expected, name) + } + } + Expect(contract.ModelActionCapabilities()).To(ConsistOf(expected)) + Expect(contract.MaxModelActions).To(Equal(len(expected))) + flags := contract.ModelActionCapabilities() + slices.Sort(flags) + Expect(slices.Compact(flags)).To(HaveLen(len(expected))) + }) +}) diff --git a/core/config/application_config.go b/core/config/application_config.go index b9d380a24..eb0576eba 100644 --- a/core/config/application_config.go +++ b/core/config/application_config.go @@ -89,6 +89,7 @@ func WithAddons(c AddonConfig) AppOption { } type ApplicationConfig struct { + chatSelection *chatSelectionState // OfficialLibraries is the switch for the shipped default library sources // of every kind. On by default; off keeps them listed but unread. OfficialLibraries bool @@ -561,6 +562,7 @@ func NewApplicationConfig(o ...AppOption) *ApplicationConfig { oo(opt) } opt.SetMaxConcurrentBackendOperations(opt.MaxConcurrentBackendOperations) + opt.selectionState() return opt } @@ -1409,7 +1411,7 @@ const ( // WithChatsHostExec sets who gets the chats' built-in bash: off, ask or on. func WithChatsHostExec(mode string) AppOption { return func(o *ApplicationConfig) { - o.ChatsHostExec = mode + o.SetChatsHostExec(mode) } } diff --git a/core/config/chat_selection.go b/core/config/chat_selection.go new file mode 100644 index 000000000..2b19fc60f --- /dev/null +++ b/core/config/chat_selection.go @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MIT +package config + +import ( + "maps" + "sync" +) + +// ChatSelection is a detached current snapshot, not an execution permission. +type ChatSelection struct { + HostExec string + ToolProviders map[string]string +} + +type chatSelectionState struct { + mu sync.RWMutex + value ChatSelection +} + +// ApplicationConfig is copied by value by embedders. Keep synchronization and +// canonical selection state behind a pointer, never copy a live mutex. The +// initialization lock also supports legacy zero-value configs. Public fields +// remain startup inputs/serialization mirrors, not concurrent mutation APIs. +var chatSelectionInit sync.Mutex + +func (o *ApplicationConfig) selectionState() *chatSelectionState { + chatSelectionInit.Lock() + defer chatSelectionInit.Unlock() + if o.chatSelection == nil { + o.chatSelection = &chatSelectionState{value: ChatSelection{HostExec: o.ChatsHostExec, ToolProviders: maps.Clone(o.ChatsToolProviders)}} + } + return o.chatSelection +} + +func (o *ApplicationConfig) ChatSelectionSnapshot() ChatSelection { + s := o.selectionState() + s.mu.RLock() + defer s.mu.RUnlock() + v := s.value + v.ToolProviders = maps.Clone(v.ToolProviders) + return v +} + +func (o *ApplicationConfig) SetChatsHostExec(v string) { + s := o.selectionState() + s.mu.Lock() + defer s.mu.Unlock() + if s.value.HostExec != v { + s.value.HostExec = v + } + o.ChatsHostExec = v +} + +func (o *ApplicationConfig) SetChatsToolProviders(v map[string]string) { + s := o.selectionState() + s.mu.Lock() + defer s.mu.Unlock() + if !maps.Equal(s.value.ToolProviders, v) { + s.value.ToolProviders = maps.Clone(v) + } + o.ChatsToolProviders = maps.Clone(v) +} diff --git a/core/config/chat_selection_shared_test.go b/core/config/chat_selection_shared_test.go new file mode 100644 index 000000000..a3d88ccf4 --- /dev/null +++ b/core/config/chat_selection_shared_test.go @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +package config + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "sync" +) + +var _ = Describe("shared selection ownership", func() { + It("shares synchronized selection ownership across config value copies", func() { + o := NewApplicationConfig() + _ = o.ChatSelectionSnapshot() + copied := ApplicationConfig{chatSelection: o.selectionState()} + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + for range 100 { + WithChatsHostExec("on")(&copied) + WithChatsHostExec("off")(&copied) + } + }() + go func() { + defer wg.Done() + for range 100 { + _ = o.ChatSelectionSnapshot() + } + }() + wg.Wait() + Expect(o.ChatSelectionSnapshot().HostExec).To(Equal("off")) + }) +}) diff --git a/core/config/chat_selection_test.go b/core/config/chat_selection_test.go new file mode 100644 index 000000000..6ec6dfdb0 --- /dev/null +++ b/core/config/chat_selection_test.go @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MIT +package config_test + +import ( + "github.com/mudler/LocalAI/core/config" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("current chat selection", func() { + It("clones pins and applies current pin and host mode settings", func() { + o := config.NewApplicationConfig() + a := map[string]string{"bash": "alpha"} + o.ApplyRuntimeSettings(&config.RuntimeSettings{ChatsToolProviders: &a}) + first := o.ChatSelectionSnapshot() + a["bash"] = "caller" + first.ToolProviders["bash"] = "consumer" + Expect(o.ChatSelectionSnapshot().ToolProviders["bash"]).To(Equal("alpha")) + b := map[string]string{"bash": "beta"} + o.ApplyRuntimeSettings(&config.RuntimeSettings{ChatsToolProviders: &b}) + Expect(o.ChatSelectionSnapshot().ToolProviders["bash"]).To(Equal("beta")) + a["bash"] = "alpha" + o.ApplyRuntimeSettings(&config.RuntimeSettings{ChatsToolProviders: &a}) + o.ApplyRuntimeSettings(&config.RuntimeSettings{ChatsToolProviders: &a}) + mode := o.ChatSelectionSnapshot().HostExec + Expect(o.ChatSelectionSnapshot().ToolProviders["bash"]).To(Equal("alpha")) + config.WithChatsHostExec("different")(o) + Expect(o.ChatSelectionSnapshot().HostExec).To(Equal("different")) + config.WithChatsHostExec(mode)(o) + Expect(o.ChatSelectionSnapshot().HostExec).To(Equal(mode)) + }) + It("captures legacy startup values and routes startup settings through mutations", func() { + o := &config.ApplicationConfig{ChatsHostExec: "ask", ChatsToolProviders: map[string]string{"bash": "first"}} + initial := o.ChatSelectionSnapshot() + Expect(initial.HostExec).To(Equal("ask")) + Expect(initial.ToolProviders["bash"]).To(Equal("first")) + pins := map[string]string{"bash": "second"} + // Startup preserves explicit legacy inputs over persisted settings. + o.ApplyRuntimeSettingsAtStartup(&config.RuntimeSettings{ChatsToolProviders: &pins}) + Expect(o.ChatSelectionSnapshot().ToolProviders["bash"]).To(Equal("first")) + o = config.NewApplicationConfig() + o.ApplyRuntimeSettingsAtStartup(&config.RuntimeSettings{ChatsToolProviders: &pins}) + Expect(o.ChatSelectionSnapshot().ToolProviders["bash"]).To(Equal("second")) + }) + +}) diff --git a/core/config/runtime_settings_registry.go b/core/config/runtime_settings_registry.go index 112f922c5..c77da7112 100644 --- a/core/config/runtime_settings_registry.go +++ b/core/config/runtime_settings_registry.go @@ -747,12 +747,12 @@ var runtimeSettingsFields = []fieldSpec{ // Built-in chat tools: the host-commands mode and the add-on pins. field("chats_host_exec", func(s *RuntimeSettings) **string { return &s.ChatsHostExec }, - func(o *ApplicationConfig) string { return o.ChatsHostExec }, - func(o *ApplicationConfig, v string) { o.ChatsHostExec = v }), + func(o *ApplicationConfig) string { return o.ChatSelectionSnapshot().HostExec }, + func(o *ApplicationConfig, v string) { o.SetChatsHostExec(v) }), fieldEq("chats_tool_providers", func(s *RuntimeSettings) **map[string]string { return &s.ChatsToolProviders }, - func(o *ApplicationConfig) map[string]string { return maps.Clone(o.ChatsToolProviders) }, - func(o *ApplicationConfig, v map[string]string) { o.ChatsToolProviders = maps.Clone(v) }, + func(o *ApplicationConfig) map[string]string { return o.ChatSelectionSnapshot().ToolProviders }, + func(o *ApplicationConfig, v map[string]string) { o.SetChatsToolProviders(v) }, func(a, b map[string]string) bool { return maps.Equal(a, b) }), // Branding: the file is the only source (no env/CLI), so it is always diff --git a/core/http/endpoints/addons/pins_test.go b/core/http/endpoints/addons/pins_test.go index 0d8a5ef64..7434dd3ec 100644 --- a/core/http/endpoints/addons/pins_test.go +++ b/core/http/endpoints/addons/pins_test.go @@ -25,15 +25,12 @@ func madeUpPages() { })) } -// pinsApp builds the two routes over a store. With seeded set, the upgrade seed -// has run for the local owner, as it does at start in a deployment that -// existed before this release. -func pinsApp(seeded bool) *echo.Echo { +func pinsApp(withPins bool) *echo.Echo { madeUpPages() store, err := pins.NewFileStore("") Expect(err).ToNot(HaveOccurred()) - if seeded { - _, err := store.SeedExisting(context.Background(), []string{auth.AddonLocalOwnerID}, []string{"guide", "help"}) + if withPins { + err := store.Replace(context.Background(), auth.AddonLocalOwnerID, []string{"guide", "help"}) Expect(err).ToNot(HaveOccurred()) } svc := &pins.Service{Store: store} @@ -57,7 +54,7 @@ var _ = Describe("the pins routes", func() { It("gives no pins to an account created after the upgrade", func() { e := pinsApp(false) code, got := call(e, http.MethodGet, "") - Expect(code).To(Equal(200), "no seed has run for this account, so it has no pins") + Expect(code).To(Equal(200), "no pins were explicitly saved for this account") Expect(got).To(BeEmpty()) }) @@ -65,7 +62,7 @@ var _ = Describe("the pins routes", func() { e := pinsApp(true) code, got := call(e, http.MethodGet, "") Expect(code).To(Equal(200)) - Expect(got).To(Equal([]string{"guide", "help"}), "seed") + Expect(got).To(Equal([]string{"guide", "help"}), "explicit saved pins") code, got = call(e, http.MethodPut, `["help","guide"]`) Expect(code).To(Equal(200)) Expect(got).To(HaveLen(2)) diff --git a/core/http/endpoints/studio/addons.go b/core/http/endpoints/studio/addons.go index 172b6cc40..362839ac3 100644 --- a/core/http/endpoints/studio/addons.go +++ b/core/http/endpoints/studio/addons.go @@ -24,6 +24,9 @@ func writeAddonList(c echo.Context, list []studiosvc.AddonInfo, cmds studiosvc.C out := schema.ChatsAddonList{Addons: make([]schema.ChatsAddon, 0, len(list)), Commands: string(cmds.State), CommandsReason: cmds.Reason, CommandsIsolation: cmds.Isolation, CommandsProvider: cmds.Provider, CommandsConsent: cmds.Consent, CommandsFallback: cmds.Fallback, CommandsFallbackReason: cmds.FallbackReason} + if v := cmds.ExecutionStatus; v != nil { + out.ExecutionStatus = &schema.ChatsExecutionStatus{Readiness: v.Readiness, Isolation: v.Isolation, Explanation: v.Explanation, Generation: v.Generation, Incarnation: v.Incarnation} + } for _, a := range list { out.Addons = append(out.Addons, schema.ChatsAddon{ID: a.ID, Tools: a.Tools, Off: a.Off}) } diff --git a/core/http/react-ui/e2e/addon-model-actions.spec.js b/core/http/react-ui/e2e/addon-model-actions.spec.js new file mode 100644 index 000000000..30a096c92 --- /dev/null +++ b/core/http/react-ui/e2e/addon-model-actions.spec.js @@ -0,0 +1,66 @@ +import { test, expect } from './coverage-fixtures.js' +import { mockLedger, GALLERY } from './ledger-fixtures.js' +const model = 'org/model:雪?&' +const provider = (id = 'creative') => ({ id, title: 'Creative', generation: 3, enabled: true, state: 'running', ui: { sdk: {min:2,max:2}, entry: `/api/addons/${id}/assets/${'a'.repeat(64)}/ui.js`, pages:['canvas'], modelActions:[{capability:'FLAG_IMAGE',page:'canvas',path:'images'}] } }) +async function setup(page, providers) { + await mockLedger(page, { installed: [{id:model,backend:'diffusers',capabilities:['FLAG_IMAGE']}], gallery: [{...GALLERY[0], name:model, tags:['image']}] }) + await page.route('**/api/auth/status', r=>r.fulfill({json:{authEnabled:false}})) + await page.route('**/api/features', r=>r.fulfill({json:{addons:true}})) + const state = { providers, reads:0, assets:0 } + await page.route('**/api/addons/apps', r=>{state.reads++;return r.fulfill({json:{apps:state.providers}})}) + await page.route('**/api/addons/*/assets/**', r=>{state.assets++;return r.fulfill({status:403,body:''})}) + return state +} +async function open(page, surface) { + if (surface === 'detail') await page.goto(`/app/models/${encodeURIComponent(model)}`) + else { + await page.goto(surface === 'installed' ? '/app/models?view=installed' : '/app/models') + await page.getByTestId('models-search').fill(model) + await page.locator('[data-entity]').filter({hasText:model}).first().click() + } + return page.locator('[data-model-action="FLAG_IMAGE"]').first() +} +for (const surface of ['installed','gallery','detail']) { + test(`model actions ${surface}: arbitrary single provider one click encodes model without eager load`, async ({page})=>{ + const state = await setup(page,[provider()]); const action = await open(page,surface) + await expect(action).toHaveAttribute('data-action-status','ready') + expect(state.assets).toBe(0); const reads = state.reads + await action.getByRole('button').click() + await expect(page).toHaveURL(/\/app\/addons\/creative\/canvas\/images\?model=/) + expect(new URL(page.url()).searchParams.get('model')).toBe(model) + expect(state.reads).toBeGreaterThan(reads) + }) + test(`model actions ${surface}: named collision choices never choose first provider`, async ({page})=>{ + const state = await setup(page,[provider('zebra'),provider('arbitrary')]); const action = await open(page,surface) + await expect(action).toHaveAttribute('data-action-status','ready') + await action.getByRole('button').click() + const menu = page.getByRole('menu') + await expect(menu.getByRole('menuitem')).toHaveText(['Creative (arbitrary)','Creative (zebra)']) + expect(state.assets).toBe(0) + await menu.getByRole('menuitem',{name:'Creative (zebra)',exact:true}).click() + await expect(page).toHaveURL(/\/app\/addons\/zebra\/canvas\/images\?model=/) + }) + test(`model actions ${surface}: absence retains label and never promotes installation`, async ({page})=>{ + await setup(page,[]); const action = await open(page,surface) + await expect(action).toHaveAttribute('data-action-status','absent') + await expect(action).toContainText(/image/i) + await expect(action.getByRole('button')).toHaveCount(0) + await expect(action.getByRole('link')).toHaveCount(0) + }) + test(`model actions ${surface}: changed entry refuses captured choice without retarget`, async ({page})=>{ + const state = await setup(page,[provider()]); const action = await open(page,surface) + await expect(action).toHaveAttribute('data-action-status','ready') + state.providers = [{...provider(),generation:4}] + await action.getByRole('button').click() + await expect(action).toHaveAttribute('data-action-status','stale') + expect(new URL(page.url()).pathname).not.toContain('/app/addons/') + }) +} +test('removed addon addresses reach NotFound rather than addon or install promotion',async({page})=>{ + await setup(page,[]) + for (const path of ['studio','talk','image','video','3d','tts','sound','diarization','transform','face','voice','voice-library','fine-tune','quantize']) { + await page.goto(`/app/${path}`) + await expect(page.getByTestId('not-found')).toBeVisible() + expect(new URL(page.url()).pathname).toBe(`/app/${path}`) + } +}) diff --git a/core/http/react-ui/e2e/addon-navigation.spec.js b/core/http/react-ui/e2e/addon-navigation.spec.js index 419adb17e..346cc411d 100644 --- a/core/http/react-ui/e2e/addon-navigation.spec.js +++ b/core/http/react-ui/e2e/addon-navigation.spec.js @@ -4,6 +4,7 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' import { ADMIN, MEMBER, app, installedAddon, release, sessionFor, mockAddons } from './addons-fixtures.js' import { buildAddonBundles, installStudioAddons, packagedApp } from './studio-addon.js' +import { mockSettings } from './settings-fixtures.js' import { FACE_MODEL, clipFile, faces, match, mockIdentity, people, photoFile } from './identity-fixtures.js' // Studio and Talk are packaged add-ons, not built-in tiles: the specs install @@ -223,6 +224,7 @@ test.describe('Add-on navigation', () => { test('the Settings page lists the add-ons with settings in an Add-ons group, and only when there is one', async ({ page }) => { await setup(page, { pins: [], studio: false, user: ADMIN, apps: [withSettings(), app({ id: 'notes', title: 'Notes' })] }) + await mockSettings(page) await page.goto('/app/settings') const group = page.locator('.st-group[data-group="addons"]') await expect(group).toBeVisible() @@ -239,6 +241,7 @@ test.describe('Add-on navigation', () => { test('the Settings page has no Add-ons group when no add-on has settings', async ({ page }) => { await setup(page, { pins: [], studio: false, user: ADMIN, apps: [app({ id: 'notes', title: 'Notes' })] }) + await mockSettings(page) const listed = page.waitForResponse(r => /\/api\/addons\/apps$/.test(new URL(r.url()).pathname)) await page.goto('/app/settings') await listed @@ -492,23 +495,24 @@ test.describe('Add-on navigation: the legacy Add-ons hub tab and the route in bo await expect(page.locator('.dk-hubtabs [data-hub-tab="addons"]')).toHaveAttribute('aria-current', 'page') }) - test('an old generator link keeps its model, query and hash', async ({ page }) => { + test('a canonical generator link keeps its model, query and hash', async ({ page }) => { await setup(page) - await page.goto('/app/image/stable%2Fdiffusion?x=1#top') + await page.goto('/app/addons/studio/studio/images?x=1&model=stable%2Fdiffusion#top') await expect(page).toHaveURL(/\/app\/addons\/studio\/studio\/images\?x=1&model=stable%2Fdiffusion#top$/) }) - test('the legacy ?tab= link of Studio still works', async ({ page }) => { + test('the canonical Studio tab address stays on its declared page', async ({ page }) => { await setup(page) - await page.goto('/app/studio?tab=tts') + await page.goto('/app/addons/studio/studio/tts') await expect(page).toHaveURL(/\/app\/addons\/studio\/studio\/tts$/) }) - test('a viewer without the 3d feature still ends on the app home', async ({ page }) => { + test('a viewer without the native 3d feature stays in Studio overview without the gated tab', async ({ page }) => { await setup(page) await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(MEMBER, { addons: true, '3d': false }) })) - await page.goto('/app/3d') - await expect(page).toHaveURL(/\/app$/) + await page.goto('/app/addons/studio/studio/threed') + await expect(page.locator('.studio-tab-active[data-tab="overview"]')).toBeVisible() + await expect(page.locator('[data-tab="threed"]')).toHaveCount(0) }) }) @@ -633,10 +637,10 @@ test.describe('Add-on navigation: the Voice recognition page', () => { await expect(page.getByRole('heading', { level: 1 })).toHaveText('Indexer') }) - test('an old Voice link ends in the add-on shell, which says the add-on is not available', async ({ page }) => { + test('a canonical Voice link ends in the add-on shell, which says the add-on is not available', async ({ page }) => { await setup(page, { apps: [] }) await asAdmin(page) - await page.goto('/app/voice') + await page.goto('/app/addons/voice/voice/recognition') await expect(page).toHaveURL(/\/app\/addons\/voice\//) await expect(page.getByTestId('addon-unavailable')).toBeVisible() }) @@ -646,7 +650,7 @@ test.describe('Add-on navigation: the Voice recognition page', () => { await asAdmin(page) await serveVoice(page) await mockIdentity(page) - await page.goto('/app/voice/org%2Fname') + await page.goto('/app/addons/voice/voice/recognition?model=org%2Fname') await expect(page).toHaveURL(/\/app\/addons\/voice\/voice\/recognition\?model=org%2Fname$/) await expect(page.getByTestId('voices-page')).toBeVisible() }) @@ -886,10 +890,10 @@ test.describe('Add-on navigation: the Face recognition page', () => { await expect(page.getByTestId('known-empty')).toContainText('Nobody enrolled in this browser yet') }) - test('an old Face link ends in the add-on shell, which says the add-on is not available', async ({ page }) => { + test('a canonical Face link ends in the add-on shell, which says the add-on is not available', async ({ page }) => { await setup(page, { apps: [] }) await asAdmin(page) - await page.goto('/app/face') + await page.goto('/app/addons/face/recognition') await expect(page).toHaveURL(/\/app\/addons\/face\//) await expect(page.getByTestId('addon-unavailable')).toBeVisible() }) @@ -899,7 +903,7 @@ test.describe('Add-on navigation: the Face recognition page', () => { await asFaceAdmin(page) await serveFace(page) await mockIdentity(page) - await page.goto('/app/face/org%2Fname') + await page.goto('/app/addons/face/recognition?model=org%2Fname') await expect(page).toHaveURL(/\/app\/addons\/face\/recognition\?model=org%2Fname$/) await expect(page.getByTestId('faces-page')).toBeVisible() }) @@ -935,32 +939,32 @@ test.describe('Add-on navigation: the Face recognition page', () => { await expect(page.locator('.bt-experimental')).toBeVisible() }) - test('an old fine-tuning link ends in the add-on shell, which says the add-on is not available', async ({ page }) => { + test('a canonical fine-tuning link ends in the add-on shell, which says the add-on is not available', async ({ page }) => { await setup(page, { apps: [] }) await asAdmin(page) - await page.goto('/app/fine-tune') + await page.goto('/app/addons/fine-tuning/jobs') await expect(page).toHaveURL(/\/app\/addons\/fine-tuning\//) await expect(page.getByTestId('addon-unavailable')).toBeVisible() }) - test('an old fine-tuning link opens the add-on when it is installed and running', async ({ page }) => { + test('a canonical fine-tuning link opens the add-on when it is installed and running', async ({ page }) => { await setup(page, { apps: [ftApp()] }) await serveFineTuning(page) - await page.goto('/app/fine-tune') + await page.goto('/app/addons/fine-tuning/jobs') await expect(page).toHaveURL(/\/app\/addons\/fine-tuning\/jobs$/) }) - test('an old quantization link ends in the add-on shell, which says the add-on is not available', async ({ page }) => { + test('a canonical quantization link ends in the add-on shell, which says the add-on is not available', async ({ page }) => { await setup(page, { apps: [] }) await asAdmin(page) - await page.goto('/app/quantize') + await page.goto('/app/addons/quantization/jobs') await expect(page).toHaveURL(/\/app\/addons\/quantization\//) await expect(page.getByTestId('addon-unavailable')).toBeVisible() }) - test('an old quantization link opens the add-on when it is installed and running', async ({ page }) => { + test('a canonical quantization link opens the add-on when it is installed and running', async ({ page }) => { await setup(page, { apps: [app({ id: 'quantization', name: 'quantization', title: 'Quantization', icon: undefined, mainPage: '/app/addons/quantization/jobs', ui: { entry: `/api/addons/quantization/assets/${'a'.repeat(64)}/ui/quantization.js`, css: [], sdk: { min: 2, max: 2 }, pages: ['jobs'], navigation: [], chatActions: [] } })] }) - await page.goto('/app/quantize') + await page.goto('/app/addons/quantization/jobs') await expect(page).toHaveURL(/\/app\/addons\/quantization\/jobs$/) }) @@ -1075,7 +1079,7 @@ test.describe('Add-on navigation: the addons feature gate', () => { } }) - for (const address of ['/app/addons', '/app/addons/studio/home', '/app/fine-tune', '/app/quantize']) { + for (const address of ['/app/addons', '/app/addons/studio/home', '/app/addons/fine-tuning/jobs', '/app/addons/quantization/jobs']) { test(`with add-ons off ${address} ends at /app and calls no add-on route`, async ({ page }) => { const calls = await watch(page) await setup(page, { features: OFF, user: ADMIN }) diff --git a/core/http/react-ui/e2e/addons-gallery.spec.js b/core/http/react-ui/e2e/addons-gallery.spec.js index c34c36cb4..f32aa4882 100644 --- a/core/http/react-ui/e2e/addons-gallery.spec.js +++ b/core/http/react-ui/e2e/addons-gallery.spec.js @@ -251,8 +251,8 @@ test.describe('Add-ons: apps', () => { await page.clock.runFor(21_000) await expect.poll(() => state.log.appsList).toBe(4) await expect(page.getByText('may be out of date')).toHaveCount(0) - // Leaving the page stops the requests. - await page.evaluate(() => { window.history.pushState({}, '', '/app/chats'); window.dispatchEvent(new PopStateEvent('popstate')) }) + // Leave for a route without a mounted add-on consumer (Home keeps commands current). + await page.evaluate(() => { window.history.pushState({}, '', '/app/polling-left'); window.dispatchEvent(new PopStateEvent('popstate')) }) await expect(page.locator('[data-app="notes"]')).toHaveCount(0) const seen = state.log.appsList await page.clock.runFor(180_000) @@ -315,7 +315,7 @@ async function appsAnswered(page, state, n) { } test.describe('Add-ons: registry polling', () => { - const leave = (page) => page.evaluate(() => { window.history.pushState({}, '', '/app/chats'); window.dispatchEvent(new PopStateEvent('popstate')) }) + const leave = (page) => page.evaluate(() => { window.history.pushState({}, '', '/app/polling-left'); window.dispatchEvent(new PopStateEvent('popstate')) }) const contributing = (over = {}) => app({ ui: { entry: '/api/addons/notes/assets/' + 'c'.repeat(64) + '/ui.js', css: [], sdk: { min: 1, max: 2 }, pages: ['home'], navigation: ['home'] }, ...over }) test('apps: an add-on that contributes UI keeps the list current off the page and its disable stops the reads', async ({ page }) => { @@ -333,7 +333,12 @@ test.describe('Add-ons: registry polling', () => { const onPage = state.log.appsList await expect.poll(() => page.evaluate(() => window.__appsAnswers)).toBe(onPage) await leave(page) + // Finish the lazy route transition under the paused page clock before + // testing the registry alone; Home would intentionally retain a consumer. + await page.clock.runFor(500) + await settle(page) await expect(page.locator('[data-app="notes"]')).toHaveCount(0) + await expect(page.getByRole('heading', { name: 'This page does not exist' })).toBeVisible() // Off the page the registry reads the list at once. Each step waits until // the page has read the answer, and so has set its next timer, before the // clock moves again. diff --git a/core/http/react-ui/e2e/audio-transform.spec.js b/core/http/react-ui/e2e/audio-transform.spec.js index 8ec08d300..2b27df232 100644 --- a/core/http/react-ui/e2e/audio-transform.spec.js +++ b/core/http/react-ui/e2e/audio-transform.spec.js @@ -104,7 +104,7 @@ test.describe('Audio Transform', () => { }) test('audio input has Upload + Record tabs', async ({ page }) => { - await page.goto('/app/transform') + await page.goto('/app/addons/studio/studio/transform') await expect(page.getByRole('button', { name: 'localvqe' })).toBeVisible({ timeout: 10_000 }) // The Audio (required) input should expose both tabs. @@ -114,7 +114,7 @@ test.describe('Audio Transform', () => { }) test('echo-test button only appears once a reference is loaded', async ({ page }) => { - await page.goto('/app/transform') + await page.goto('/app/addons/studio/studio/transform') await expect(page.getByRole('button', { name: 'localvqe' })).toBeVisible({ timeout: 10_000 }) // No reference yet → echo button hidden. @@ -126,7 +126,7 @@ test.describe('Audio Transform', () => { }) test('renders the AudioTransform page directly', async ({ page }) => { - await page.goto('/app/transform') + await page.goto('/app/addons/studio/studio/transform') await expect(page.getByRole('heading', { name: /Audio Transform/ })).toBeVisible({ timeout: 10_000 }) await expect(page.getByRole('button', { name: 'localvqe' })).toBeVisible() // Audio (required) + Reference (optional) drop zones @@ -137,7 +137,7 @@ test.describe('Audio Transform', () => { test('uploads an audio file, posts multipart, renders enhanced waveform', async ({ page }) => { const submitted = mockAudioTransform(page, 'enhanced.wav') - await page.goto('/app/transform') + await page.goto('/app/addons/studio/studio/transform') await expect(page.getByRole('button', { name: 'localvqe' })).toBeVisible({ timeout: 10_000 }) // Upload mic file via the hidden file input under "Audio (required)". @@ -165,7 +165,7 @@ test.describe('Audio Transform', () => { test('reference file is forwarded as a multipart field when provided', async ({ page }) => { const submitted = mockAudioTransform(page) - await page.goto('/app/transform') + await page.goto('/app/addons/studio/studio/transform') await expect(page.getByRole('button', { name: 'localvqe' })).toBeVisible({ timeout: 10_000 }) const inputs = page.locator('input[type="file"]') @@ -185,7 +185,7 @@ test.describe('Audio Transform', () => { test('history entry appears after a successful transform and persists across navigation', async ({ page }) => { mockAudioTransform(page, 'enhanced.wav') - await page.goto('/app/transform') + await page.goto('/app/addons/studio/studio/transform') await expect(page.getByRole('button', { name: 'localvqe' })).toBeVisible({ timeout: 10_000 }) await page.locator('input[type="file"]').first().setInputFiles(makeFakeWav('mic.wav')) @@ -197,14 +197,14 @@ test.describe('Audio Transform', () => { // Persist across page reloads via localStorage. await page.waitForTimeout(600) - await page.goto('/app/transform') + await page.goto('/app/addons/studio/studio/transform') await expect(page.getByTestId('media-history-item')).toHaveCount(1) }) test('renders an input spectrogram on upload and an output one after transform', async ({ page }) => { mockAudioTransform(page, 'enhanced.wav') - await page.goto('/app/transform') + await page.goto('/app/addons/studio/studio/transform') await expect(page.getByRole('button', { name: 'localvqe' })).toBeVisible({ timeout: 10_000 }) // Choosing a clip should render its input spectrogram immediately — no @@ -231,7 +231,7 @@ test.describe('Audio Transform', () => { }) }) - await page.goto('/app/transform') + await page.goto('/app/addons/studio/studio/transform') await expect(page.getByRole('button', { name: 'localvqe' })).toBeVisible({ timeout: 10_000 }) await page.locator('input[type="file"]').first().setInputFiles(makeFakeWav('mic.wav')) diff --git a/core/http/react-ui/e2e/chats-commands.spec.js b/core/http/react-ui/e2e/chats-commands.spec.js index afe16eb84..5bc68c4b1 100644 --- a/core/http/react-ui/e2e/chats-commands.spec.js +++ b/core/http/react-ui/e2e/chats-commands.spec.js @@ -40,7 +40,7 @@ const none = () => ({ addons: [], commands: 'none' }) test.describe('Chat that cannot run commands', () => { test.beforeEach(async ({ page }) => { await mockChat(page) }) - test('an administrator sees the chip and a notice in the Tools menu with the way to install the Sandbox', async ({ page }) => { + test('an administrator sees command availability without promoting a provider', async ({ page }) => { await stubChat(page, none) await page.goto('/app/chats/c1') const chip = page.getByTestId('chat-commands-chip') @@ -50,8 +50,7 @@ test.describe('Chat that cannot run commands', () => { const notice = page.getByTestId('chat-commands-notice') await expect(notice).toContainText("Commands can't run here") await expect(notice).toContainText('No add-on that runs code is installed, so the assistant can write files but cannot run scripts or commands.') - await expect(notice.getByTestId('chat-commands-notice-link')).toHaveText('Install the Sandbox') - await expect(notice.getByTestId('chat-commands-notice-link')).toHaveAttribute('href', '/app/addons?tab=browse&q=sandbox') + await expect(notice.getByTestId('chat-commands-notice-link')).toHaveCount(0) await expect(notice).not.toContainText('Ask an administrator') await shot(page, 'tools-menu-admin') }) @@ -71,18 +70,19 @@ test.describe('Chat that cannot run commands', () => { await expect(chip).toBeFocused() }) - test('a person who is not an administrator is told to ask one, with no install link', async ({ page }) => { + test('a non-administrator sees command availability without an install link', async ({ page }) => { await mockUser(page, { chats: true }) await stubChat(page, none) await page.goto('/app/chats/c1') await page.getByTestId('chat-tools-button').click() const notice = page.getByTestId('chat-commands-notice') - await expect(notice).toContainText('Ask an administrator to install the Sandbox add-on.') + await expect(notice).not.toContainText('Ask an administrator') + await expect(notice).toContainText('No add-on that runs code is installed') await expect(notice.getByTestId('chat-commands-notice-link')).toHaveCount(0) await shot(page, 'tools-menu-user') }) - test('hiding the notice hides the chip and leaves one line in the Tools menu, also after a reload', async ({ page }) => { + test('hiding the notice hides the chip and leaves one line in the Tools menu, but returns after a reload', async ({ page }) => { await stubChat(page, none) await page.goto('/app/chats/c1') await page.getByTestId('chat-tools-button').click() @@ -91,22 +91,21 @@ test.describe('Chat that cannot run commands', () => { await expect(page.getByTestId('chat-commands-notice')).toHaveCount(0) await expect(page.getByTestId('chat-commands-notice-line')).toHaveText("Commands can't run here") await page.reload() - await expect(page.getByTestId('chat-tools-button')).toBeVisible() - await expect(page.getByTestId('chat-commands-chip')).toHaveCount(0) + await expect(page.getByTestId('chat-commands-chip')).toBeVisible() await page.getByTestId('chat-tools-button').click() - await expect(page.getByTestId('chat-commands-notice-line')).toBeVisible() + await expect(page.getByTestId('chat-commands-notice')).toBeVisible() await shot(page, 'tools-menu-hidden') }) test('a Sandbox that is off for the chat points at its switch, and switching it on removes the notice', async ({ page }) => { const calls = await stubChat(page, (off) => { const isOff = off === null ? true : off.includes('sandbox') - return { addons: [{ id: 'sandbox', tools: 7, off: isOff }], commands: isOff ? 'off' : 'available' } + return { addons: [{ id: 'sandbox', tools: 7, off: isOff }], commands: isOff ? 'off' : 'available', commands_provider: 'sandbox', commands_isolation: 'isolated' } }) await page.goto('/app/chats/c1') await page.getByTestId('chat-tools-button').click() const notice = page.getByTestId('chat-commands-notice') - await expect(notice).toContainText('The Sandbox add-on is off for this chat. Switch it on in the Tools menu to let the assistant run code.') + await expect(notice).toContainText('Running commands is switched off for this chat.') await expect(notice.getByTestId('chat-commands-notice-link')).toHaveCount(0) await page.getByRole('switch', { name: 'sandbox' }).check() await expect.poll(() => calls.at(-1)).toEqual([]) @@ -114,17 +113,18 @@ test.describe('Chat that cannot run commands', () => { await expect(page.getByTestId('chat-commands-chip')).toHaveCount(0) }) - test('a Sandbox that cannot run here gives the reason and the add-on page', async ({ page }) => { - await stubChat(page, () => ({ addons: [{ id: 'sandbox', tools: 1, off: false }], commands: 'unavailable', commands_reason: 'no sandbox backend works on this host, so commands cannot run' })) + test('an unavailable selected provider gives its typed reason without an install link', async ({ page }) => { + await stubChat(page, () => ({ addons: [{ id: 'sandbox', tools: 1, off: false }], commands: 'unavailable', commands_provider: 'sandbox', execution_status: { readiness: 'unavailable', explanation: 'no sandbox backend works on this host, so commands cannot run' }, commands_reason: 'no sandbox backend works on this host, so commands cannot run' })) await page.goto('/app/chats/c1') await page.getByTestId('chat-tools-button').click() const notice = page.getByTestId('chat-commands-notice') - await expect(notice).toContainText('The Sandbox add-on cannot run commands on this server: no sandbox backend works on this host, so commands cannot run') - await expect(notice.getByTestId('chat-commands-notice-link')).toHaveAttribute('href', '/app/addons?detail=sandbox') + await expect(notice).toContainText('The selected execution provider is unavailable.') + await expect(notice).toContainText('no sandbox backend works on this host') + await expect(notice.getByTestId('chat-commands-notice-link')).toHaveCount(0) }) test('a chat that runs commands shows neither the chip nor the notice', async ({ page }) => { - await stubChat(page, () => ({ addons: [{ id: 'sandbox', tools: 7, off: false }], commands: 'available' })) + await stubChat(page, () => ({ addons: [{ id: 'sandbox', tools: 7, off: false }], commands: 'available', commands_provider: 'sandbox', commands_isolation: 'isolated' })) await page.goto('/app/chats/c1') await page.getByTestId('chat-tools-button').click() await expect(page.getByRole('switch', { name: 'sandbox' })).toBeChecked() @@ -158,7 +158,7 @@ test.describe('Chat that cannot run commands', () => { test.describe('A chat that switched commands off', () => { test.beforeEach(async ({ page }) => { await mockChat(page) }) - test('shows no notice and no chip: the switch in the Tools menu says it', async ({ page }) => { + test('shows the disabled notice and the off switch without an install link', async ({ page }) => { await stubChat(page, () => ({ addons: [], commands: 'switched_off' })) await page.route('**/api/chats/c1/tools', route => route.fulfill({ json: { @@ -169,8 +169,9 @@ test.describe('A chat that switched commands off', () => { await page.goto('/app/chats/c1') await page.getByTestId('chat-tools-button').click() await expect(page.getByRole('switch', { name: 'Run commands' })).toHaveAttribute('aria-checked', 'false') - await expect(page.getByTestId('chat-commands-notice')).toHaveCount(0) - await expect(page.getByTestId('chat-commands-chip')).toHaveCount(0) + await expect(page.getByTestId('chat-commands-notice')).toContainText('Running commands is switched off for this chat.') + await expect(page.getByTestId('chat-commands-chip')).toBeVisible() + await expect(page.getByTestId('chat-commands-notice-link')).toHaveCount(0) }) }) diff --git a/core/http/react-ui/e2e/chats-egress.spec.js b/core/http/react-ui/e2e/chats-egress.spec.js index ac2116761..394dea97c 100644 --- a/core/http/react-ui/e2e/chats-egress.spec.js +++ b/core/http/react-ui/e2e/chats-egress.spec.js @@ -150,7 +150,7 @@ test.describe('Network of the Sandbox commands', () => { await stubChat(page, { egress: view({ applies: false, applies_reason: 'host' }) }) await page.goto('/app/chats/c1') await page.getByTestId('chat-tools-button').click() - await expect(page.getByTestId('chat-network-host')).toHaveText('Commands run on this machine and use its network. Network limits need the Sandbox add-on.') + await expect(page.getByTestId('chat-network-unenforced')).toContainText('Commands run on this machine and use its network.') await expect(page.getByTestId('chat-network')).toHaveCount(0) }) @@ -160,7 +160,7 @@ test.describe('Network of the Sandbox commands', () => { await page.getByTestId('chat-tools-button').click() await expect(page.getByText('Tools for this chat')).toBeVisible() await expect(page.getByTestId('chat-network')).toHaveCount(0) - await expect(page.getByTestId('chat-network-host')).toHaveCount(0) + await expect(page.getByTestId('chat-network-unenforced')).toHaveCount(0) }) test('a second add-on with the same control draws the same row, and its link opens that add-on', async ({ page }) => { diff --git a/core/http/react-ui/e2e/chats-host-exec.spec.js b/core/http/react-ui/e2e/chats-host-exec.spec.js index d4fcadf7d..25bc17bbe 100644 --- a/core/http/react-ui/e2e/chats-host-exec.spec.js +++ b/core/http/react-ui/e2e/chats-host-exec.spec.js @@ -43,7 +43,7 @@ const host = { addons: [], commands: 'available', commands_provider: 'builtin', test.describe('Commands on this machine', () => { test.beforeEach(async ({ page }) => { await mockChat(page) }) - test('the Tools menu and the chip say that commands run here, not isolated, with the install link', async ({ page }) => { + test('the Tools menu and chip warn that commands run here without isolation or install advice', async ({ page }) => { await stubChat(page, { addons: host }) await page.goto('/app/chats/c1') const chip = page.getByTestId('chat-commands-chip') @@ -51,8 +51,8 @@ test.describe('Commands on this machine', () => { await expect(chip).toHaveAttribute('data-state', 'host') await page.getByTestId('chat-tools-button').click() const notice = page.getByTestId('chat-commands-notice') - await expect(notice).toContainText('Commands run on this machine, with its files and network, not isolated. Install the Sandbox for isolation.') - await expect(notice.getByTestId('chat-commands-notice-link')).toHaveAttribute('href', '/app/addons?tab=browse&q=sandbox') + await expect(notice).toContainText('Commands run on the server without isolation.') + await expect(notice.getByTestId('chat-commands-notice-link')).toHaveCount(0) await shot(page, 'host-notice') }) @@ -81,7 +81,7 @@ test.describe('Commands on this machine', () => { await page.goto('/app/chats/c1') await page.getByTestId('chat-tools-button').click() const notice = page.getByTestId('chat-commands-notice') - await expect(notice).toContainText('The Sandbox is off for this chat, so commands run on this machine') + await expect(notice).toContainText('Commands run on the server without isolation.') await expect(notice.getByTestId('chat-commands-notice-link')).toHaveCount(0) }) @@ -207,7 +207,7 @@ test.describe('Commands on this machine', () => { await expect(page.getByText('There is one file.')).toBeVisible({ timeout: 15_000 }) await page.getByTestId('chat-activity').locator('.cx-fold__head').first().click() const tags = page.getByTestId('chat-step-provider') - await expect(tags).toHaveText(['This machine, allowed once', 'Sandbox']) + await expect(tags).toHaveText(['This machine, allowed once', 'Add-on sandbox']) await shot(page, 'host-provider-tags') }) }) diff --git a/core/http/react-ui/e2e/chats-switches-real.spec.js b/core/http/react-ui/e2e/chats-switches-real.spec.js index 5e7a67fc8..b87d062e4 100644 --- a/core/http/react-ui/e2e/chats-switches-real.spec.js +++ b/core/http/react-ui/e2e/chats-switches-real.spec.js @@ -62,14 +62,14 @@ test('switching the files group off in a chat stores it, and Select all puts it await expect.poll(async () => (await group('files')).off).toBe(false) }) -test('an add-on address with add-ons off lands on Home with a notice, and /studio offers Install Studio', async ({ page, request }) => { +test('an add-on address with add-ons off lands on Home with a notice, without package-specific promotion', async ({ page, request }) => { const features = await (await request.get('/api/features')).json() test.skip(features.addons !== false, 'needs a server with add-ons off') - await page.goto('/app/image') + await page.goto('/app/addons/studio/studio/images') await page.waitForURL(/\/app$/) const note = page.getByTestId('home-addons-off') await expect(note).toContainText('Add-ons are off on this server') - await expect(note).toContainText('Studio is an add-on') + await expect(note).toContainText('That page is an add-on') // The Home blocks fade in; let them settle before the picture. await page.waitForTimeout(1000) await page.screenshot({ path: 'test-results/wiring-addons-off.png' }) @@ -77,7 +77,6 @@ test('an add-on address with add-ons off lands on Home with a notice, and /studi await expect(note).toHaveCount(0) await page.getByRole('combobox', { name: 'Message' }).fill('/studio') - await expect(page.getByRole('option', { name: /It is an add-on/ })).toBeVisible() - await page.keyboard.press('Enter') - await expect(page.getByTestId('home-addons-off')).toContainText('Studio is an add-on') + await expect(page.getByRole('option')).toHaveCount(0) + await expect(page.getByRole('button', { name: 'Install Studio' })).toHaveCount(0) }) diff --git a/core/http/react-ui/e2e/chats-ui.spec.js b/core/http/react-ui/e2e/chats-ui.spec.js index 4f6850604..6233b8721 100644 --- a/core/http/react-ui/e2e/chats-ui.spec.js +++ b/core/http/react-ui/e2e/chats-ui.spec.js @@ -531,28 +531,21 @@ test.describe('Chat tools menu: add-ons', () => { await expect.poll(() => calls.at(-1)).toEqual(['office', 'sandbox']) }) - test('says the Sandbox is starting while its service does not answer, and asks again later, not at once', async ({ page }) => { + test('uses the typed execution observation without private discovery or a retry loop', async ({ page }) => { const api = newChatsApi(page, { running: false, pending: [] }) await api.install() - await page.route('**/api/chats/c1/addons', route => route.fulfill({ json: { addons: [{ id: 'sandbox', tools: 2, off: false }] } })) - await page.route('**/api/addons/apps', route => route.fulfill({ json: { apps: [{ id: 'sandbox', generation: 4 }] } })) + await page.route('**/api/chats/c1/addons', route => route.fulfill({ json: { + addons: [{ id: 'runner', tools: 2, off: false }], commands: 'available', commands_provider: 'runner', + execution_status: { readiness: 'unavailable', isolation: 'unknown', explanation: 'Provider is starting' }, + } })) const reads = [] - await page.route('**/api/addons/dispatch', route => { - reads.push(Date.now()) - if (reads.length === 1) return route.fulfill({ status: 503, json: { error: { message: 'the add-on owner is unavailable' } } }) - return route.fulfill({ json: { payload: { ok: true, canRun: true, active: 'gvisor' } } }) - }) - const errors = [] - page.on('pageerror', e => errors.push(e.message)) + await page.route('**/api/addons/dispatch', route => { reads.push(route.request().url()); return route.abort() }) await page.goto('/app/chats/c1') - await expect.poll(() => reads.length).toBe(1) await page.getByTestId('chat-tools-button').click() - await expect(page.getByTestId('chat-tools-note')).toHaveText('The Sandbox is starting. LocalAI checks again in 2 s.') - // The next read waits for the backoff, then the note says what the Sandbox does. - await expect(page.getByTestId('chat-tools-note')).toHaveText('Runs commands isolated (gvisor)', { timeout: 10_000 }) - expect(reads.length).toBe(2) - expect(reads[1] - reads[0]).toBeGreaterThanOrEqual(1800) - expect(errors).toEqual([]) + await expect(page.getByTestId('chat-commands-notice')).toContainText('Provider is starting') + await page.waitForTimeout(2200) + expect(reads).toEqual([]) + await expect(page.getByTestId('chat-commands-notice')).toHaveAttribute('data-state', 'unknown') }) test('shows no menu when the user has no add-on tools', async ({ page }) => { @@ -773,7 +766,7 @@ test.describe('Chat tools menu: every group of built-in tools', () => { await open(page) const names = await page.getByTestId('chat-tools-group-builtins').locator('.swl-row__name').allTextContents() expect(names).toEqual(['Run commands', 'Your files', 'Web', 'Images and audio', 'Collections', 'Sub-agents', 'Triggers']) - await expect(row(page, 'Run commands').getByTestId('chat-tools-note')).toHaveText(['Sandbox', 'Commands run isolated']) + await expect(row(page, 'Run commands').getByTestId('chat-tools-note')).toHaveText(['Served by add-on sandbox', 'Commands run isolated']) await expect(row(page, 'Your files')).toHaveAttribute('title', 'list_files, read_file, write_file · built-in') await expect(sw(page, 'Collections')).toBeDisabled() await expect(sw(page, 'Collections')).toHaveAccessibleDescription('collections are not enabled for this account') @@ -790,7 +783,7 @@ test.describe('Chat tools menu: every group of built-in tools', () => { await sw(page, 'Run commands').click() await expect.poll(() => state.puts.at(-1)).toEqual({ off: ['files', 'shell'] }) await expect(sw(page, 'Run commands')).toHaveAttribute('aria-checked', 'false') - await expect(row(page, 'Run commands').getByTestId('chat-tools-note').first()).toHaveText('Sandbox') + await expect(row(page, 'Run commands').getByTestId('chat-tools-note').first()).toHaveText('Served by add-on sandbox') await shot(page, 'groups-shell-off') await sw(page, 'Your files').click() await expect.poll(() => state.puts.at(-1)).toEqual({ off: ['shell'] }) @@ -813,7 +806,7 @@ test.describe('Chat tools menu: every group of built-in tools', () => { await open(page) const shell = row(page, 'Run commands') await expect(shell.getByTestId('chat-tools-note').first()).toHaveText('This machine') - await expect(shell).toContainText('Commands run directly on this machine, with no isolation') + await expect(shell).toContainText('Commands run on the server without isolation.') await expect(shell.locator('.swl-row__desc--warn')).toBeVisible() }) diff --git a/core/http/react-ui/e2e/chrome-audit.spec.js b/core/http/react-ui/e2e/chrome-audit.spec.js index 246f966fc..cc5ad84fa 100644 --- a/core/http/react-ui/e2e/chrome-audit.spec.js +++ b/core/http/react-ui/e2e/chrome-audit.spec.js @@ -6,7 +6,7 @@ import { installStudioAddons } from './studio-addon.js' // whose sprite target is missing or that has no size), and controls left with // the user agent's own chrome, which is a pale grey button on a dark ground. const ROUTES = [ - '/app', '/app/chats/all', '/app/models', '/app/studio', '/app/talk', + '/app', '/app/chats/all', '/app/models', '/app/addons/studio/studio', '/app/addons/talk/talk', '/app/agents', '/app/skills', '/app/collections', '/app/triggers', '/app/mcp-connectors', '/app/models?view=installed', '/app/backends', '/app/activity', '/app/operate', '/app/settings', '/app/traces', '/app/usage', '/app/nodes', '/app/p2p', @@ -14,7 +14,7 @@ const ROUTES = [ ] test('no page renders a dead icon or a default-chrome control', async ({ page }) => { - // Studio and Talk are add-ons; /app/studio and /app/talk open them. + // Studio and Talk are add-ons; /app/addons/studio/studio and /app/addons/talk/talk open them. await installStudioAddons(page) // One test walks every route, so its budget has to scale with the list rather // than sit on Playwright's per-test default of 30s. At 25 routes that default diff --git a/core/http/react-ui/e2e/diarization-profiles.spec.js b/core/http/react-ui/e2e/diarization-profiles.spec.js index 3adc70fba..71724bc31 100644 --- a/core/http/react-ui/e2e/diarization-profiles.spec.js +++ b/core/http/react-ui/e2e/diarization-profiles.spec.js @@ -30,7 +30,7 @@ async function setup(page, permission = true, diarizationPermission = true) { // Studio and Voice are add-ons. Installed after the catch-all above, so their routes win. // The remembered-voices link opens the Voice add-on page. await installStudioAddons(page, { ids: ['studio', 'voice'] }) - await page.goto('/app/diarization') + await page.goto('/app/addons/studio/studio/diarization') if (!diarizationPermission) return await expect(page.getByRole('button', { name: 'diarizer', exact: true })).toBeVisible() await page.getByLabel('Recording', { exact: true }).setInputFiles(file) @@ -174,8 +174,9 @@ test('missing requested profiles is an error and normal defaults remain opt-in', test('diarization permission gates direct page and Studio tab', async ({ page }) => { await setup(page, true, false) - await expect(page).toHaveURL(/\/app$/) - await page.goto('/app/studio') + await expect(page.locator('.studio-tab-active[data-tab="overview"]')).toBeVisible() + await expect(page.getByRole('button', { name: 'Diarize', exact: true })).toHaveCount(0) + await page.goto('/app/addons/studio/studio') await expect(page.locator('[data-tab="diarization"]')).toHaveCount(0) }) @@ -186,7 +187,7 @@ test('Studio exposes diarization and navigation revokes recording URL', async ({ URL.revokeObjectURL = url => { window.revoked.push(url); revoke(url) } }) await setup(page) - await page.goto('/app/studio/diarization') + await page.goto('/app/addons/studio/studio/diarization') await expect(page.getByRole('heading', { name: 'Speaker diarization' })).toBeVisible() await page.getByLabel('Recording', { exact: true }).setInputFiles(file) await run(page) diff --git a/core/http/react-ui/e2e/home-console.spec.js b/core/http/react-ui/e2e/home-console.spec.js index f5e538796..f7496ab8b 100644 --- a/core/http/react-ui/e2e/home-console.spec.js +++ b/core/http/react-ui/e2e/home-console.spec.js @@ -33,22 +33,23 @@ test.describe('Home slash menu', () => { await ready(page) await page.locator('.home-textarea').fill('/') const names = await page.locator('.dk-cmd-name').allTextContents() - // The administrator gets /studio while Studio is not installed: it - // opens the add-on in the library. + // Native Apps opens the generic installed list, never a promoted package. // Home's own commands first; the commands it shares with a chat follow: - // /attach, /skill, /skills and one per skill of the server, then /help. + // /attach, /skills and any declared skills, then /help. expect(names.slice(0, 15)).toEqual([ '/model', '/new', '/chat', '/assistant', '/models', '/gallery', '/installed', '/import', '/stop', - // An admin without Studio is offered /studio to install it. - '/agents', '/mcp', '/memory', '/studio', '/settings', '/docs', + // No installation advice for a particular provider. + '/agents', '/mcp', '/memory', '/apps', '/settings', '/docs', ]) - expect(names.slice(15, 18)).toEqual(['/attach', '/skill', '/skills']) + expect(names.slice(15, 18)).toEqual(['/attach', '/skills', '/help']) + expect(names).not.toContain('/skill') // No skills are declared by this fixture. expect(names[names.length - 1]).toBe('/help') // A chat that does not exist yet has nothing to compact or clear. expect(names).not.toContain('/compact') expect(names).not.toContain('/clear') - await expect(page.getByTestId('home-slash-studioInstall')).toBeVisible() + await expect(page.getByTestId('home-slash-apps')).toBeVisible() + await expect(page.getByTestId('home-slash-studioInstall')).toHaveCount(0) await expect(page.getByTestId('home-slash-studio')).toHaveCount(0) }) @@ -73,8 +74,8 @@ test.describe('Home slash menu', () => { await installStudioAddons(page, { ids: ['studio'] }) await ready(page) await page.locator('.home-textarea').fill('/') - await expect(page.getByTestId('home-slash-studio')).toBeVisible() - await page.getByTestId('home-slash-studio').click() + await expect(page.getByTestId('home-slash-addon:studio:open:alias')).toBeVisible() + await page.getByTestId('home-slash-addon:studio:open:alias').click() await expect(page).toHaveURL(/\/app\/addons\/studio\/studio/) }) @@ -450,29 +451,26 @@ test.describe('Home first run', () => { await expect(guide.getByRole('button', { name: /import model/i })).toBeVisible() }) - test('offers to install the Sandbox, says what happens without it, and can be skipped', async ({ page }) => { + test('first run offers native model setup without promoting an uninstalled provider', async ({ page }) => { await mockFirstRun(page) await stubAddonRoutes(page) await mockAddons(page, { apps: [] }) await ready(page) - const step = page.getByTestId('home-first-run-sandbox') - await expect(step).toContainText('Run code safely') - await expect(step).toContainText('commands run directly on this machine') - await expect(step).toContainText('full network access and no isolation') - await step.getByRole('button', { name: 'Skip' }).click() - await expect(step).toHaveCount(0) - await page.reload() await expect(page.getByTestId('home-first-run')).toBeVisible() await expect(page.getByTestId('home-first-run-sandbox')).toHaveCount(0) + await expect(page.getByRole('button', { name: 'Install Sandbox' })).toHaveCount(0) + await page.reload() + await expect(page.getByTestId('home-first-run-sandbox')).toHaveCount(0) }) - test('the Sandbox step opens the add-on browser', async ({ page }) => { + test('the generic apps command opens the add-on browser without an identity filter', async ({ page }) => { await mockFirstRun(page) await stubAddonRoutes(page) await mockAddons(page, { apps: [] }) await ready(page) - await page.getByTestId('home-first-run-sandbox').getByRole('button', { name: 'Install Sandbox' }).click() - await expect(page).toHaveURL(/\/app\/addons\?tab=browse&q=sandbox/) + await page.locator('.home-textarea').fill('/apps') + await page.getByTestId('home-slash-apps').click() + await expect(page).toHaveURL(/\/app\/addons$/) }) test('the Sandbox step is hidden once the Sandbox is installed', async ({ page }) => { diff --git a/core/http/react-ui/e2e/legacy-redirects.spec.js b/core/http/react-ui/e2e/legacy-redirects.spec.js index 1a50c2d93..d2afea778 100644 --- a/core/http/react-ui/e2e/legacy-redirects.spec.js +++ b/core/http/react-ui/e2e/legacy-redirects.spec.js @@ -1,40 +1,31 @@ import { test, expect } from '@playwright/test' import { MEMBER, sessionFor } from './addons-fixtures.js' import { installStudioAddons } from './studio-addon.js' -import { MIGRATION_ROUTES } from '../src/utils/legacyRoutes.js' +import { MIGRATION_ROUTES, chatsRedirect } from '../src/utils/legacyRoutes.js' -// Every moved generator route, with and without a model: 14 old paths. For -// each, in both flag states, the final location is the tab of the Studio add-on -// and that generator is drawn (its tab is active), not the overview fallback. -// A model travels as ?model=, because an add-on path segment refuses "/" and ":". -const MOVED = MIGRATION_ROUTES.filter(r => r.kind === 'studio-tab') -const CASES = MOVED.flatMap(r => [ - { from: r.from, to: r.to, tab: r.to.split('/').pop() }, - { from: `${r.from}/my-model`, to: `${r.to}?model=my-model`, tab: r.to.split('/').pop() }, -]) - -async function setup(page, flag) { - await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(MEMBER, { addons: true, '3d': true, audio_diarization: true, audio_transform: true }) })) - await page.route('**/api/features', route => route.fulfill({ json: { addon_navigation: flag } })) - await page.route('**/api/addons/builtin', route => route.fulfill({ json: { builtin: [] } })) - await installStudioAddons(page, { ids: ['studio'] }) - await page.route('**/api/addons/pins', route => route.fulfill({ json: [] })) -} - -test('the table covers all 14 old paths', () => { - expect(CASES).toHaveLength(14) +// Native migrations remain supported; removed addon aliases must not select +// a privileged provider, even when the corresponding addon is installed. +test('the migration table preserves native destinations only', () => { + expect(MIGRATION_ROUTES.map(r => r.from)).toEqual([ + '/app/chat', '/app/chats/agents', '/app/chats/triggers', '/app/chats/skills', + '/app/agent-jobs', '/app/agents', '/app/skills', '/app/models', '/app/settings', + ]) + for (const r of MIGRATION_ROUTES) { + expect(r.to).not.toContain('/addons/') + if (r.kind === 'chats') expect(chatsRedirect(r.from)).toBe(r.to) + else expect(r.to).toBe(r.from) + } }) -for (const flag of [true, false]) { - test.describe(`old generator links, addon_navigation=${flag}`, () => { - for (const c of CASES) { - test(`${c.from} opens ${c.to}`, async ({ page }) => { - await setup(page, flag) - await page.goto(c.from) - await expect(page).toHaveURL(new RegExp(`${c.to.replace(/[/?]/g, '\\$&')}$`)) - await expect(page.locator(`.studio-tab-active[data-tab="${c.tab}"]`)).toBeVisible() - await expect(page.getByTestId('studio-overview')).toHaveCount(0) - }) - } - }) +for (const from of ['image', 'video', 'threed', 'tts', 'sound', 'audio-transform', 'diarization']) { + for (const suffix of ['', '/my-model']) { + test(`removed generator /app/${from}${suffix} is NotFound, not an addon redirect`, async ({ page }) => { + await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(MEMBER, { addons: true }) })) + await installStudioAddons(page, { ids: ['studio'] }) + await page.goto(`/app/${from}${suffix}`) + await expect(page.getByRole('heading', { name: 'This page does not exist' })).toBeVisible() + await expect(page).toHaveURL(new RegExp(`/app/${from}${suffix}$`)) + await expect(page.getByTestId('studio-overview')).toHaveCount(0) + }) + } } diff --git a/core/http/react-ui/e2e/media-history.spec.js b/core/http/react-ui/e2e/media-history.spec.js index 3a7690567..ee4ebdc11 100644 --- a/core/http/react-ui/e2e/media-history.spec.js +++ b/core/http/react-ui/e2e/media-history.spec.js @@ -72,7 +72,7 @@ test.describe('Media History - Image Generation', () => { test('history entry appears after image generation', async ({ page }) => { await mockImageGeneration(page, [{ url: '/generated-images/test.png' }]) - await page.goto('/app/studio/images') + await page.goto('/app/addons/studio/studio/images') await expect(page.getByRole('button', { name: 'test-image-model' })).toBeVisible({ timeout: 10_000 }) await page.locator('[data-testid="ws-compose"] textarea').first().fill('a beautiful sunset') @@ -99,7 +99,7 @@ test.describe('Media History - Image Generation', () => { }) }) - await page.goto('/app/studio/images') + await page.goto('/app/addons/studio/studio/images') await expect(page.getByRole('button', { name: 'test-image-model' })).toBeVisible({ timeout: 10_000 }) // Generate first image @@ -124,7 +124,7 @@ test.describe('Media History - Image Generation', () => { test('history persists across navigation', async ({ page }) => { await mockImageGeneration(page, [{ url: '/generated-images/persist.png' }]) - await page.goto('/app/studio/images') + await page.goto('/app/addons/studio/studio/images') await expect(page.getByRole('button', { name: 'test-image-model' })).toBeVisible({ timeout: 10_000 }) await page.locator('[data-testid="ws-compose"] textarea').first().fill('persist test') @@ -136,7 +136,7 @@ test.describe('Media History - Image Generation', () => { // Navigate away and back await page.goto('/app') - await page.goto('/app/studio/images') + await page.goto('/app/addons/studio/studio/images') // Re-register the mock for capabilities after navigation await expect(page.getByTestId('media-history')).toBeVisible({ timeout: 10_000 }) @@ -147,7 +147,7 @@ test.describe('Media History - Image Generation', () => { test('delete removes a history entry', async ({ page }) => { await mockImageGeneration(page, [{ url: '/generated-images/del.png' }]) - await page.goto('/app/studio/images') + await page.goto('/app/addons/studio/studio/images') await expect(page.getByRole('button', { name: 'test-image-model' })).toBeVisible({ timeout: 10_000 }) await page.locator('[data-testid="ws-compose"] textarea').first().fill('delete me') @@ -174,7 +174,7 @@ test.describe('Media History - Image Generation', () => { }) }) - await page.goto('/app/studio/images') + await page.goto('/app/addons/studio/studio/images') await expect(page.getByRole('button', { name: 'test-image-model' })).toBeVisible({ timeout: 10_000 }) // Generate two images @@ -200,7 +200,7 @@ test.describe('Media History - TTS', () => { test('TTS history entry appears with server URL from Content-Disposition', async ({ page }) => { await mockTTSGeneration(page, 'tts.wav') - await page.goto('/app/studio/tts') + await page.goto('/app/addons/studio/studio/tts') await expect(page.getByRole('button', { name: 'test-tts-model' })).toBeVisible({ timeout: 10_000 }) await page.getByPlaceholder('Enter text to synthesize...').fill('hello world') @@ -226,7 +226,7 @@ test.describe('Media History - Sound Generation', () => { test('Sound generation history entry appears', async ({ page }) => { await mockSoundGeneration(page, 'sound.wav') - await page.goto('/app/studio/sound') + await page.goto('/app/addons/studio/studio/sound') await expect(page.getByRole('button', { name: 'test-sound-model' })).toBeVisible({ timeout: 10_000 }) await page.getByRole('button', { name: 'Prompt expansion model', exact: true }).click() @@ -247,7 +247,7 @@ test.describe('Media History - Video Generation', () => { test('Video history entry appears after generation', async ({ page }) => { await mockVideoGeneration(page, [{ url: '/generated-videos/test.mp4' }]) - await page.goto('/app/studio/video') + await page.goto('/app/addons/studio/studio/video') await expect(page.getByRole('button', { name: 'test-video-model' })).toBeVisible({ timeout: 10_000 }) await page.locator('[data-testid="ws-compose"] textarea').first().fill('a running cat') @@ -261,7 +261,7 @@ test.describe('Media History - Video Generation', () => { let requestBody await mockVideoGeneration(page, [{ url: '/generated-videos/avatar.mp4' }], (body) => { requestBody = body }) - await page.goto('/app/studio/video') + await page.goto('/app/addons/studio/studio/video') await expect(page.getByRole('button', { name: 'test-video-model' })).toBeVisible({ timeout: 10_000 }) await page.getByRole('button', { name: 'Avatar audio' }).click() await page.getByLabel('Avatar audio', { exact: true }).setInputFiles({ diff --git a/core/http/react-ui/e2e/navigation.spec.js b/core/http/react-ui/e2e/navigation.spec.js index d26bc248c..3828e7b13 100644 --- a/core/http/react-ui/e2e/navigation.spec.js +++ b/core/http/react-ui/e2e/navigation.spec.js @@ -42,7 +42,7 @@ test.describe('Navigation', () => { await expect(page).toHaveURL(/\/app\/settings/) await expect(page.locator('.dk-hubtabs [data-hub-tab="settings"]')).toHaveAttribute('aria-current', 'page') // Studio and Talk are add-ons: the sidebar does not list them; they are in the drawer while installed. - for (const href of ['/app/studio', '/app/talk']) { + for (const href of ['/app/addons/studio/studio', '/app/addons/talk/talk']) { await expect(page.locator(`.sidebar-nav a.nav-item[href="${href}"]`)).toHaveCount(0) } }) diff --git a/core/http/react-ui/e2e/no-edge-rails.spec.js b/core/http/react-ui/e2e/no-edge-rails.spec.js index 3c54e74c0..f71479ca4 100644 --- a/core/http/react-ui/e2e/no-edge-rails.spec.js +++ b/core/http/react-ui/e2e/no-edge-rails.spec.js @@ -9,15 +9,15 @@ import { installStudioAddons } from './studio-addon.js' // 4. a narrow, tall child pinned to the left edge of its parent. // Selection and status use a surface step, a check or a dot instead. const ROUTES = [ - '/app', '/app/chats/all', '/app/models', '/app/models?view=installed', '/app/studio', - '/app/talk', '/app/agents', '/app/skills', '/app/collections', '/app/triggers', + '/app', '/app/chats/all', '/app/models', '/app/models?view=installed', '/app/addons/studio/studio', + '/app/addons/talk/talk', '/app/agents', '/app/skills', '/app/collections', '/app/triggers', '/app/backends', '/app/activity', '/app/operate', '/app/mcp-connectors', '/app/settings', '/app/traces', '/app/usage', '/app/nodes', '/app/p2p', '/app/account', ] test('no element on the main routes carries a left accent rail', async ({ page }) => { - // Studio and Talk are add-ons; /app/studio and /app/talk open them. + // Studio and Talk are add-ons; /app/addons/studio/studio and /app/addons/talk/talk open them. await installStudioAddons(page) test.setTimeout(ROUTES.length * 8_000) const findings = [] diff --git a/core/http/react-ui/e2e/page-render-smoke.spec.js b/core/http/react-ui/e2e/page-render-smoke.spec.js index 33e449d8d..95447ab48 100644 --- a/core/http/react-ui/e2e/page-render-smoke.spec.js +++ b/core/http/react-ui/e2e/page-render-smoke.spec.js @@ -53,8 +53,8 @@ const ADDON_PAGES = [ ['/app/addons/talk/talk', 'Talk', '.talk-page'], ['/app/addons/studio/studio', 'Studio', '[data-testid="studio-overview"]'], ['/app/addons/studio/studio/threed', 'Studio 3D', '.ws-title'], - ['/app/talk', 'Talk (old address)', '.talk-page'], - ['/app/studio/threed', 'Studio 3D (old address)', '.ws-title'], + ['/app/addons/talk/talk', 'Talk (old address)', '.talk-page'], + ['/app/addons/studio/studio/threed', 'Studio 3D (old address)', '.ws-title'], ] test.describe('Add-on page render smoke', () => { diff --git a/core/http/react-ui/e2e/settings-tools.spec.js b/core/http/react-ui/e2e/settings-tools.spec.js index c09571106..2d1fc66cf 100644 --- a/core/http/react-ui/e2e/settings-tools.spec.js +++ b/core/http/react-ui/e2e/settings-tools.spec.js @@ -31,7 +31,7 @@ test.describe('Settings > Tools', () => { await expect(page.getByTestId('settings-tools-bash-provider')).toHaveText('Built-in') await expect(page.getByTestId('settings-tools-bash-warning')).toContainText('reach the internet, your network and the LocalAI API') await expect(row).toContainText('Not set: the default applies (ask each time).') - await expect(page.getByTestId('settings-tools-install-sandbox')).toHaveAttribute('href', '/app/addons?tab=browse&q=sandbox') + await expect(page.getByTestId('settings-tools-install-sandbox')).toHaveCount(0) await shot(page, 'settings-tools') await row.getByRole('radio', { name: /Allow for everyone/ }).check() await page.getByRole('button', { name: 'Apply' }).click() diff --git a/core/http/react-ui/e2e/sound-expansion.spec.js b/core/http/react-ui/e2e/sound-expansion.spec.js index cfbba10f7..a4aceab2c 100644 --- a/core/http/react-ui/e2e/sound-expansion.spec.js +++ b/core/http/react-ui/e2e/sound-expansion.spec.js @@ -28,7 +28,7 @@ async function setup(page, models = [audio, chat('writer'), chat('poet')], saved requests.push(route.request().postDataJSON()) return route.fulfill({ contentType: 'audio/wav', body: silentWav() }) }) - await page.goto('/app/studio/sound') + await page.goto('/app/addons/studio/studio/sound') await expect(page.getByRole('button', { name: 'music', exact: true })).toBeVisible() return requests } diff --git a/core/http/react-ui/e2e/studio-addon.js b/core/http/react-ui/e2e/studio-addon.js index d50e093f8..683f4c909 100644 --- a/core/http/react-ui/e2e/studio-addon.js +++ b/core/http/react-ui/e2e/studio-addon.js @@ -1,8 +1,7 @@ // Studio and Talk are packaged add-ons. A spec that draws one of them installs // the real built bundle here: the app list names it, and its module and style // sheet are served from the build output the way the add-on owner serves them. -// The old /app/studio and /app/talk addresses redirect to the add-on page, so a -// spec can still open those. +// Specs open canonical declared pages, never historical standalone redirects. import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' @@ -46,7 +45,7 @@ export function packagedApp(id, over = {}) { return app({ id, name: id, title: ui.title, description: ui.description, icon: undefined, generation: 1, version: manifest.version, mainPage: `/app/addons/${id}/${ui.mainPage}`, tools: [], toolCount: 0, - ui: { entry: `${base}/${ui.entry}`, css: ui.css.map(c => `${base}/${c}`), sdk: ui.sdk, pages: ui.pages, navigation: ui.navigation }, + ui: { entry: `${base}/${ui.entry}`, css: ui.css.map(c => `${base}/${c}`), sdk: ui.sdk, pages: ui.pages, navigation: ui.navigation, modelActions: ui.modelActions, homeCommands: ui.homeCommands?.map(c => ({ ...c, page: c.page || ui.mainPage, label: c.label || ui.title, icon: c.icon || 'puzzle' })) }, ...over, }) } diff --git a/core/http/react-ui/e2e/studio-addons.spec.js b/core/http/react-ui/e2e/studio-addons.spec.js index 3e56c0424..e0e3deae0 100644 --- a/core/http/react-ui/e2e/studio-addons.spec.js +++ b/core/http/react-ui/e2e/studio-addons.spec.js @@ -5,7 +5,7 @@ import { mockStudio } from './studio-fixtures.js' import { fakeRealtime } from './chat-fixtures.js' // Studio and Talk are packaged add-ons. These specs run the real built bundles -// inside the add-on shell: the pages draw, the old addresses open them, the +// inside the add-on shell: the pages draw, canonical addresses open them, the // links they hold reach the core pages, and nothing is left when they are not // installed. @@ -61,14 +61,14 @@ test.describe('Studio add-on', () => { test('a model id with a slash and a colon travels as ?model= and reaches the page', async ({ page }) => { await mockStudio(page) - await page.goto('/app/studio/images/sd-1.5-lcm?size=768x768') + await page.goto('/app/addons/studio/studio/images?size=768x768&model=sd-1.5-lcm') await expect(page).toHaveURL(/\/app\/addons\/studio\/studio\/images\?size=768x768&model=sd-1\.5-lcm$/) await expect(page.locator('.studio-tab-active[data-tab="images"]')).toBeVisible({ timeout: 15_000 }) }) - test('the old Studio addresses say so plainly when the add-on is not installed', async ({ page }) => { + test('the canonical Studio addresses say so plainly when the add-on is not installed', async ({ page }) => { await stubAddonRoutes(page) - for (const address of ['/app/studio', '/app/studio/tts', '/app/image', '/app/talk']) { + for (const address of ['/app/addons/studio/studio', '/app/addons/studio/studio/tts', '/app/addons/studio/studio/images', '/app/addons/talk/talk']) { await page.goto(address) await expect(page.getByTestId('addon-unavailable')).toBeVisible({ timeout: 15_000 }) await expect(page).toHaveURL(/\/app\/addons\/(studio|talk)\//) @@ -119,7 +119,7 @@ test.describe('Without the add-ons', () => { test('the Add-ons drawer does not list Studio or Talk, and the sidebar has no Create group', async ({ page }) => { await page.goto('/app/addons') await expect(page.getByTestId('addon-tile').filter({ hasText: /Studio|Talk/ })).toHaveCount(0) - await expect(page.locator('.sidebar-nav a[href="/app/studio"], .sidebar-nav a[href="/app/talk"]')).toHaveCount(0) + await expect(page.locator('.sidebar-nav a[href="/app/addons/studio/studio"], .sidebar-nav a[href="/app/addons/talk/talk"]')).toHaveCount(0) await expect(page.locator('.sidebar-section-title', { hasText: 'Create' })).toHaveCount(0) }) diff --git a/core/http/react-ui/e2e/studio-composer.spec.js b/core/http/react-ui/e2e/studio-composer.spec.js index e14c92a81..f1b3f45d1 100644 --- a/core/http/react-ui/e2e/studio-composer.spec.js +++ b/core/http/react-ui/e2e/studio-composer.spec.js @@ -22,7 +22,7 @@ const generate = (page) => page.locator('[data-testid="studio-generate"]') test.describe('Studio composer: choosing a type', () => { test('opens on Images with a prompt box, starters and the model that will run', async ({ page }) => { await mockStudio(page) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') await expect(page.getByRole('heading', { name: 'What do you want to make?' })).toBeVisible() await expect(composer(page)).toHaveAttribute('data-type', 'images') await expect(prompt(page)).toHaveAttribute('placeholder', /Describe the image/) @@ -32,7 +32,7 @@ test.describe('Studio composer: choosing a type', () => { test('has no chip row: the tabs are the modes, and the composer names the one it will open', async ({ page }) => { await mockStudio(page) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') await expect(page.locator('.studio-types')).toHaveCount(0) await expect(composer(page)).toHaveAttribute('data-types', 'images video threed tts sound transform diarization') await expect(page.getByTestId('studio-current-type')).toHaveText('Images') @@ -42,7 +42,7 @@ test.describe('Studio composer: choosing a type', () => { test('a starter fills the prompt and the starters go away', async ({ page }) => { await mockStudio(page) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') await page.locator('[data-testid="studio-starters"] button', { hasText: 'Macro fern' }).click() await expect(prompt(page)).toHaveValue(/dew-covered fern/) await expect(page.locator('[data-testid="studio-starters"]')).toHaveCount(0) @@ -50,7 +50,7 @@ test.describe('Studio composer: choosing a type', () => { test('size and count appear only where the workspace has them', async ({ page }) => { await mockStudio(page) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') await expect(page.locator('[data-testid="studio-size"]')).toBeVisible() await expect(page.locator('[data-testid="studio-count"]')).toBeVisible() await pickType(page, 'video') @@ -63,7 +63,7 @@ test.describe('Studio composer: choosing a type', () => { test('types that start from a file say so instead of offering a prompt box', async ({ page }) => { await mockStudio(page) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') for (const key of ['threed', 'transform', 'diarization']) { await pickType(page, key) await expect(prompt(page)).toHaveCount(0) @@ -76,7 +76,7 @@ test.describe('Studio composer: choosing a type', () => { test.describe('Studio composer: suggesting a type', () => { test('typing a video-sounding sentence suggests Video and offers to switch', async ({ page }) => { await mockStudio(page) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') await prompt(page).fill('Slow dolly through a misty pine forest, drone shot') await expect(hint(page)).toContainText('Sounds like Video') await page.locator('[data-testid="studio-switch"]').click() @@ -87,7 +87,7 @@ test.describe('Studio composer: suggesting a type', () => { test('the suggestion never switches the type on its own', async ({ page }) => { await mockStudio(page) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') await prompt(page).fill('Read this aloud in a warm voice') await expect(hint(page)).toContainText('Sounds like TTS') await expect(composer(page)).toHaveAttribute('data-type', 'images') @@ -95,7 +95,7 @@ test.describe('Studio composer: suggesting a type', () => { test('Alt+Enter takes the suggestion', async ({ page }) => { await mockStudio(page) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') await prompt(page).fill('Lo-fi piano with rain on glass') await expect(hint(page)).toContainText('Sounds like Sound') await page.keyboard.press('Alt+Enter') @@ -104,7 +104,7 @@ test.describe('Studio composer: suggesting a type', () => { test('a short or ordinary sentence suggests nothing', async ({ page }) => { await mockStudio(page) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') await prompt(page).fill('hello') await expect(hint(page)).toHaveText('') await prompt(page).fill('My cat sleeping on the sofa') @@ -113,14 +113,14 @@ test.describe('Studio composer: suggesting a type', () => { test('no suggestion when the sentence already fits the chosen type', async ({ page }) => { await mockStudio(page) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') await prompt(page).fill('A poster of a lighthouse at dusk') await expect(hint(page)).toHaveText('') }) test('a suggested type with no model says so and offers to show what it needs', async ({ page }) => { await mockStudio(page, { types: ['images'] }) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') await prompt(page).fill('Slow dolly through a misty pine forest') await expect(hint(page)).toContainText('no Video model is installed yet') await page.locator('[data-testid="studio-switch"]').click() @@ -133,7 +133,7 @@ test.describe('Studio composer: suggesting a type', () => { test.describe('Studio composer: keys', () => { test('/ focuses the prompt from anywhere on the page', async ({ page }) => { await mockStudio(page) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') // The add-on shell titles the page; click that title, away from every field. await page.locator('h1.page-title').first().click() await page.keyboard.press('/') @@ -145,7 +145,7 @@ test.describe('Studio composer: keys', () => { test('Alt+1 to Alt+7 pick a type', async ({ page }) => { await mockStudio(page) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') // The key handler is armed when the composer mounts: wait for it before the // first press, and retry a press that lands during a re-render. await expect(prompt(page)).toBeVisible() @@ -160,7 +160,7 @@ test.describe('Studio composer: keys', () => { test('Ctrl+Enter generates, and does nothing with an empty prompt', async ({ page }) => { await mockStudio(page) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') await prompt(page).focus() await page.keyboard.press('Control+Enter') await expect(page).toHaveURL(/\/app\/addons\/studio\/studio$/) @@ -173,7 +173,7 @@ test.describe('Studio composer: keys', () => { test.describe('Studio composer: Generate', () => { test('is disabled with a reason until there is a prompt', async ({ page }) => { await mockStudio(page) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') await expect(generate(page)).toBeDisabled() await expect(page.locator('[data-testid="studio-why"]')).toHaveText('Write a prompt first') await prompt(page).fill('a brass orrery') @@ -183,7 +183,7 @@ test.describe('Studio composer: Generate', () => { test('is disabled with the install reason when the type has no model', async ({ page }) => { await mockStudio(page, { types: ['images'] }) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') await pickType(page, 'video') await prompt(page).fill('waves on black sand') await expect(generate(page)).toBeDisabled() @@ -192,13 +192,13 @@ test.describe('Studio composer: Generate', () => { test('the model select offers the installed models of the chosen type', async ({ page }) => { await mockStudio(page) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') await expect(page.locator('[data-testid="studio-model"] option')).toHaveText(INSTALLED.images) }) test('shows how the model fits this machine when the gallery can say', async ({ page }) => { await mockStudio(page) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') await expect(page.locator('[data-testid="studio-fit"]')).toContainText('Needs 13.0 GB') await expect(page.locator('[data-testid="studio-fit"]')).toHaveAttribute('data-fit', 'fits') }) @@ -206,7 +206,7 @@ test.describe('Studio composer: Generate', () => { test('shows no fit at all when nothing is known about the model', async ({ page }) => { await mockStudio(page) await page.route(/\/api\/models\/estimate\//, route => route.fulfill({ json: {} })) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') await expect(page.locator('[data-testid="studio-model"]')).toHaveValue('flux.1-schnell') await expect(page.locator('[data-testid="studio-fit"]')).toHaveCount(0) }) @@ -217,7 +217,7 @@ test.describe('Studio composer: Generate', () => { test.describe('Studio composer: hand-off to the workspaces', () => { test('Images opens with the prompt, model, size and count filled in', async ({ page }) => { await mockStudio(page) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') await prompt(page).fill('A brass orrery on a walnut desk') await page.locator('[data-testid="studio-model"]').selectOption('sd-1.5-lcm') await page.locator('[data-testid="studio-size"]').selectOption('768x768') @@ -234,7 +234,7 @@ test.describe('Studio composer: hand-off to the workspaces', () => { test('Video opens with the prompt, model and size', async ({ page }) => { await mockStudio(page) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') await pickType(page, 'video') await prompt(page).fill('Waves rolling onto black sand, drone shot') await page.locator('[data-testid="studio-size"]').selectOption('1280x720') @@ -247,7 +247,7 @@ test.describe('Studio composer: hand-off to the workspaces', () => { test('TTS opens with the words in the text box', async ({ page }) => { await mockStudio(page) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') await pickType(page, 'tts') await prompt(page).fill('Welcome to the harbour tour.') await generate(page).click() @@ -258,7 +258,7 @@ test.describe('Studio composer: hand-off to the workspaces', () => { test('Sound opens with the description in the simple prompt', async ({ page }) => { await mockStudio(page) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') await pickType(page, 'sound') await prompt(page).fill('Wind through pines, no music') await generate(page).click() @@ -270,7 +270,7 @@ test.describe('Studio composer: hand-off to the workspaces', () => { await mockStudio(page) await stubMedia(page) await seedHistory(page, sampleHistory()) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') await pickType(page, 'threed') await pickSource(page, /ceramic bowls/) await generate(page).click() @@ -286,9 +286,9 @@ test.describe('Studio composer: hand-off to the workspaces', () => { await mockStudio(page) await stubMedia(page) await seedHistory(page, sampleHistory()) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') for (const [key, edge] of [['transform', 'transform'], ['diarization', 'diarize']]) { - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') await pickType(page, key) await pickSource(page, /Welcome to the harbour tour/) await generate(page).click() @@ -303,7 +303,7 @@ test.describe('Studio composer: hand-off to the workspaces', () => { await mockStudio(page) await page.route('**/generated-audio/**', route => route.fulfill({ status: 404, body: '' })) await seedHistory(page, sampleHistory()) - await page.goto('/app/studio/diarization?from=tts-welcome&edge=diarize') + await page.goto('/app/addons/studio/studio/diarization?from=tts-welcome&edge=diarize') const note = page.locator('[data-testid="studio-handoff"]') await expect(note).toHaveAttribute('data-status', 'error') await expect(note).toContainText('could not be loaded') @@ -312,14 +312,14 @@ test.describe('Studio composer: hand-off to the workspaces', () => { test('a source that is not in this browser is reported plainly', async ({ page }) => { await mockStudio(page) - await page.goto('/app/studio/video?from=gone&edge=animate') + await page.goto('/app/addons/studio/studio/video?from=gone&edge=animate') await expect(page.locator('[data-testid="studio-handoff"]')).toHaveAttribute('data-status', 'missing') await expect(page.locator('[data-testid="ws-compose"] textarea').first()).toBeVisible() }) test('a workspace opened by hand has no hand-off note and an empty form', async ({ page }) => { await mockStudio(page) - await page.goto('/app/studio/images') + await page.goto('/app/addons/studio/studio/images') await expect(page.locator('[data-testid="studio-handoff"]')).toHaveCount(0) await expect(page.locator('[data-testid="ws-compose"] textarea').first()).toHaveValue('') }) @@ -333,7 +333,7 @@ test.describe('Studio composer: hand-off to the workspaces', () => { // shows "Loading" until its module has run. That takes about two seconds // here and over seven on a loaded machine, so the first assertion after // each load gets a longer budget than the default five seconds. - await page.goto('/app/studio/video?prompt=Slow%20push-in&from=img-forest&edge=animate&model=wan2.1-t2v-1.3b') + await page.goto('/app/addons/studio/studio/video?prompt=Slow%20push-in&from=img-forest&edge=animate&model=wan2.1-t2v-1.3b') await expect(page.locator('[data-testid="studio-handoff"]')).toHaveAttribute('data-status', 'ready', { timeout: 20_000 }) await page.getByRole('button', { name: /generate/i }).click() await expect(page.locator('[data-testid="media-history-item"]').first()).toBeVisible() @@ -342,7 +342,7 @@ test.describe('Studio composer: hand-off to the workspaces', () => { expect(saved.edge).toBe('animate') expect(saved.prompt).toBe('Slow push-in') // And the front page now stacks the forest picture with its clip. - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') await expect(page.locator('[data-testid="work-project"][data-id="img-forest"]')).toHaveAttribute('data-count', '2', { timeout: 20_000 }) }) @@ -350,7 +350,7 @@ test.describe('Studio composer: hand-off to the workspaces', () => { await mockStudio(page) await page.route('**/v1/images/generations', route => route.fulfill({ json: { data: [{ url: '/generated-images/solo.png' }] } })) await stubMedia(page) - await page.goto('/app/studio/images') + await page.goto('/app/addons/studio/studio/images') await page.locator('[data-testid="ws-compose"] textarea').first().fill('a lone picture') await page.getByRole('button', { name: /generate/i }).click() await expect.poll(async () => (await readStore(page, 'localai_image_history'))?.length).toBe(1) @@ -363,7 +363,7 @@ test.describe('Studio composer: hand-off to the workspaces', () => { await mockStudio(page) await stubMedia(page) await page.route('**/v1/images/generations', route => route.fulfill({ json: { data: [{ url: '/generated-images/long.png' }] } })) - await page.goto('/app/studio/images') + await page.goto('/app/addons/studio/studio/images') await page.locator('[data-testid="ws-compose"] textarea').first().fill('x'.repeat(5000)) await page.getByRole('button', { name: /generate/i }).click() await expect.poll(async () => (await readStore(page, 'localai_image_history'))?.length).toBe(1) @@ -375,7 +375,7 @@ test.describe('Studio composer: hand-off to the workspaces', () => { await page.route('**/v1/audio/diarization', route => route.fulfill({ json: { segments: [{ speaker: 'SPEAKER_00', label: 0, start: 0, end: 4 }, { speaker: 'SPEAKER_01', label: 1, start: 4, end: 9 }], speakers: [{ label: 0, id: 'SPEAKER_00' }, { label: 1, id: 'SPEAKER_01' }] }, })) - await page.goto('/app/studio/diarization') + await page.goto('/app/addons/studio/studio/diarization') await page.locator('#diarization-file').setInputFiles({ name: 'meeting.wav', mimeType: 'audio/wav', buffer: Buffer.alloc(64) }) await page.getByRole('button', { name: 'Diarize' }).click() await expect.poll(async () => (await readStore(page, 'localai_diarization_history'))?.length).toBe(1) @@ -383,7 +383,7 @@ test.describe('Studio composer: hand-off to the workspaces', () => { expect(saved.prompt).toBe('meeting.wav') expect(saved.params).toEqual({ speakers: 2, seconds: 9 }) expect(saved.results).toEqual([]) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') await expect(page.locator('[data-testid="work-tile"][data-type="diarization"]')).toContainText('meeting.wav') }) }) @@ -391,7 +391,7 @@ test.describe('Studio composer: hand-off to the workspaces', () => { test.describe('Studio composer: a model that is missing', () => { test('the missing type has a hollow tab dot and opens its note only when picked', async ({ page }) => { await mockStudio(page, { types: ['images', 'tts'] }) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') await expect(tabDot(page, 'video', 'off')).toBeVisible() await expect(tabDot(page, 'images', 'on')).toBeVisible() await expect(page.locator('[data-testid="studio-install-note"]')).toHaveCount(0) @@ -413,7 +413,7 @@ test.describe('Studio composer: a model that is missing', () => { test('Install asks the gallery for that model, keeps the typed words, and the note goes when the model arrives', async ({ page }) => { const state = await mockStudio(page, { types: ['images'], installOnPost: true }) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') await pickType(page, 'video') await prompt(page).fill('Waves rolling onto black sand, drone shot') await page.locator('[data-testid="studio-install"]').click() @@ -429,7 +429,7 @@ test.describe('Studio composer: a model that is missing', () => { test('shows the progress of the install from the operations list', async ({ page }) => { await mockStudio(page, { types: ['images'], operations: [{ id: 'wan2.1-t2v-1.3b', name: 'wan2.1-t2v-1.3b', jobID: 'j1', progress: 42, isBackend: false, isDeletion: false, taskType: 'installation' }] }) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') await pickType(page, 'video') const note = page.locator('[data-testid="studio-install-note"]') await expect(note).toContainText('Installing wan2.1-t2v-1.3b 42%') @@ -439,7 +439,7 @@ test.describe('Studio composer: a model that is missing', () => { test('a failed install says why, in words, and offers the button again', async ({ page }) => { await mockStudio(page, { types: ['images'], installStatus: 500 }) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') await pickType(page, 'video') await page.locator('[data-testid="studio-install"]').click() await expect(page.getByRole('alert')).toContainText('The install did not start') @@ -449,7 +449,7 @@ test.describe('Studio composer: a model that is missing', () => { test('when the gallery lists nothing for a type it says so and points to the models page', async ({ page }) => { await mockStudio(page, { types: ['images'] }) await page.route(/\/api\/models(\?.*)?$/, route => route.fulfill({ json: { models: [], total_pages: 1 } })) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') await pickType(page, 'video') const note = page.locator('[data-testid="studio-install-note"]') await expect(note).toContainText('The gallery lists no Video model') @@ -459,7 +459,7 @@ test.describe('Studio composer: a model that is missing', () => { test('when the gallery cannot be reached it says so', async ({ page }) => { await mockStudio(page, { types: ['images'] }) await page.route(/\/api\/models(\?.*)?$/, route => route.fulfill({ status: 500, json: { error: 'down' } })) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') await pickType(page, 'video') await expect(page.locator('[data-testid="studio-install-note"]')).toContainText('could not be reached') }) @@ -467,7 +467,7 @@ test.describe('Studio composer: a model that is missing', () => { test('a download size the server does not know is not invented', async ({ page }) => { await mockStudio(page, { types: ['images'] }) await page.route(/\/api\/models\/estimate\//, route => route.fulfill({ json: {} })) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') await pickType(page, 'video') const note = page.locator('[data-testid="studio-install-note"]') await expect(note).toContainText('has no download size listed') @@ -476,7 +476,7 @@ test.describe('Studio composer: a model that is missing', () => { test('a new user with no models at all gets one honest path, not a wall', async ({ page }) => { await mockStudio(page, { types: [] }) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') await expect(page.locator('.studio-tab__dot--off')).toHaveCount(7) await expect(page.locator('[data-testid="studio-first-run"]')).toContainText('Nothing is installed yet') await expect(page.locator('[data-testid="studio-install-note"]')).toContainText('No Images model installed') @@ -486,7 +486,7 @@ test.describe('Studio composer: a model that is missing', () => { test('a machine with one model has that tab filled and the rest hollow', async ({ page }) => { await mockStudio(page, { types: ['tts'] }) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') await expect(page.locator('.studio-tab__dot--off')).toHaveCount(6) // It opens on the first type that works, not on one that cannot. await expect(composer(page)).toHaveAttribute('data-type', 'tts') @@ -495,7 +495,7 @@ test.describe('Studio composer: a model that is missing', () => { test('when the installed models cannot be read it says so and offers one action', async ({ page }) => { const state = await mockStudio(page, { capabilitiesStatus: 500 }) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') const error = page.locator('[data-testid="studio-models-error"]') await expect(error).toContainText('could not read the installed models') await expect(error.getByRole('button')).toHaveCount(1) @@ -511,7 +511,7 @@ test.describe('Studio composer: layout', () => { test('on a phone the composer fits the width and Generate stays reachable', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }) await mockStudio(page) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') await prompt(page).fill('a brass orrery') await expect(generate(page)).toBeVisible() const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) @@ -527,7 +527,7 @@ test.describe('Studio composer: layout', () => { await new Promise(r => setTimeout(r, 3_000)) route.fulfill({ json: { data: [] } }) }) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') await expect(page.locator('[data-testid="studio-composer-loading"]')).toBeVisible() await expect(page.locator('[data-testid="studio-first-run"]')).toHaveCount(0) await expect(page.locator('[data-testid="studio-composer"]')).toBeVisible() @@ -538,7 +538,7 @@ test.describe('Studio composer: layout', () => { await mockStudio(page) await stubMedia(page) await seedHistory(page, sampleHistory()) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') const name = await page.locator('[data-testid="work-tile"]').first().evaluate(el => getComputedStyle(el).animationName) expect(name).toBe('none') }) @@ -549,7 +549,7 @@ test.describe('Studio composer: draft survives', () => { await mockStudio(page) await stubMedia(page) await seedHistory(page, sampleHistory()) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') await prompt(page).fill('half a thought') await page.locator('[data-testid="work-tile"][data-id="img-forest"]').getByRole('button').first().click() await expect(page.locator('[data-testid="studio-lineage"]')).toBeVisible() @@ -560,7 +560,7 @@ test.describe('Studio composer: draft survives', () => { test('3D history written by the 3D workspace shows on the front page', async ({ page }) => { await mockStudio(page) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') await seedThreeD(page, [{ id: 'm1', createdAt: Date.now() - 1000, name: 'vase.glb', label: 'Ribbed vase', model: 'trellis-image-to-3d', outputType: 'mesh', operation: 'generate' }]) await page.reload() const tile = page.locator('[data-testid="work-tile"][data-type="threed"]') diff --git a/core/http/react-ui/e2e/studio-lineage.spec.js b/core/http/react-ui/e2e/studio-lineage.spec.js index 6c82fb494..c0838085f 100644 --- a/core/http/react-ui/e2e/studio-lineage.spec.js +++ b/core/http/react-ui/e2e/studio-lineage.spec.js @@ -11,7 +11,7 @@ async function open(page, work, { types } = {}) { await mockStudio(page, { types }) await stubMedia(page) await seedHistory(page, sampleHistory()) - await page.goto(`/app/studio?work=${work}`) + await page.goto(`/app/addons/studio/studio?work=${work}`) await expect(page.locator('[data-testid="studio-lineage"]')).toBeVisible() } @@ -29,7 +29,7 @@ test.describe('Lineage view', () => { await mockStudio(page) await stubMedia(page) await seedHistory(page, sampleHistory()) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') await page.locator('[data-testid="work-project"] button').first().click() await expect(page).toHaveURL(/\/app\/addons\/studio\/studio\?work=vid-push/) await expect(node(page, 'vid-push')).toHaveAttribute('data-selected', 'true') @@ -204,10 +204,10 @@ test.describe('Lineage view: what a page cannot take yet', () => { test('a 3D object cannot be repeated or branched, and the dock says so', async ({ page }) => { await mockStudio(page) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') const { seedThreeD } = await import('./studio-fixtures.js') await seedThreeD(page, [{ id: 'm1', createdAt: Date.now() - 5000, name: 'vase.glb', label: 'Ribbed vase', model: 'trellis-image-to-3d' }]) - await page.goto('/app/studio?work=m1') + await page.goto('/app/addons/studio/studio?work=m1') await expect(page.locator('[data-testid="studio-take"]')).toBeDisabled() await expect(page.locator('[data-testid="studio-branch"]')).toBeDisabled() await expect(page.locator('[data-testid="studio-dock-hint"]')).toContainText('original input was not kept') @@ -276,7 +276,7 @@ test.describe('Inline upscale', () => { : { data: [{ url: '/generated-images/upscaled.png', width: 99999, height: 99999 }] } }) }) if (failure === 'result-decode') await page.route('**/generated-images/upscaled.png', route => route.fulfill({ contentType: 'image/png', body: 'broken' })) - await page.goto('/app/studio?work=img-bowls') + await page.goto('/app/addons/studio/studio?work=img-bowls') await page.getByTestId('studio-dock-toggle').click() return () => requests } diff --git a/core/http/react-ui/e2e/studio-overview.spec.js b/core/http/react-ui/e2e/studio-overview.spec.js index da0fd2d7c..27ce5e8da 100644 --- a/core/http/react-ui/e2e/studio-overview.spec.js +++ b/core/http/react-ui/e2e/studio-overview.spec.js @@ -35,26 +35,26 @@ async function mockCapabilities(page, payload = SOME_MODELS) { test.describe('Studio overview', () => { test('Studio opens on the overview rather than dropping into Images', async ({ page }) => { await mockCapabilities(page) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') await expect(page.locator(OVERVIEW)).toBeVisible() }) test('a generator path opens that generator', async ({ page }) => { await mockCapabilities(page) - await page.goto('/app/studio/images') + await page.goto('/app/addons/studio/studio/images') await expect(page.locator(OVERVIEW)).toHaveCount(0) await expect(page.locator('[data-testid="studio-workspace"]')).toBeVisible() }) test('an unrecognised tab falls back to the overview, not to Images', async ({ page }) => { await mockCapabilities(page) - await page.goto('/app/studio/nonsense') + await page.goto('/app/addons/studio/studio/nonsense') await expect(page.locator(OVERVIEW)).toBeVisible() }) test('the tab strip reports which modalities have a model', async ({ page }) => { await mockCapabilities(page) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') // Filled: something installed advertises the capability. await expect(tabFor(page, 'images').locator('.studio-tab__dot--on')).toBeVisible() await expect(tabFor(page, 'tts').locator('.studio-tab__dot--on')).toBeVisible() @@ -65,7 +65,7 @@ test.describe('Studio overview', () => { test('the tab dots are quiet and the state is spoken, not only drawn', async ({ page }) => { await mockCapabilities(page) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') await expect(tabFor(page, 'images')).toContainText('(model installed)') await expect(tabFor(page, 'video')).toContainText('(no model installed)') await expect(page.getByRole('button', { name: /^Images \(model installed\)$/ })).toBeVisible() @@ -88,7 +88,7 @@ test.describe('Studio overview', () => { test('a modality with no model offers a way to install one', async ({ page }) => { await mockCapabilities(page) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') // A hollow dot on the tab, because nothing serves it. Picking it in the // composer is how you learn what it needs. await expect(tabFor(page, 'video').locator('.studio-tab__dot--off')).toBeVisible() @@ -99,7 +99,7 @@ test.describe('Studio overview', () => { test('a modality with a model names it instead of offering an install', async ({ page }) => { await mockCapabilities(page) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') await expect(tabFor(page, 'images').locator('.studio-tab__dot--on')).toBeVisible() await expect(page.locator('[data-testid="studio-model"]')).toHaveValue('flux.1-schnell') await expect(page.locator('[data-testid="studio-install-note"]')).toHaveCount(0) @@ -118,7 +118,7 @@ test.describe('Studio overview', () => { }, })) await mockCapabilities(page) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') await expect(page.locator(OVERVIEW)).toBeVisible() await expect(tabFor(page, 'threed')).toHaveCount(0) await expect(composer(page)).not.toHaveAttribute('data-types', /threed/) @@ -131,7 +131,7 @@ test.describe('Studio overview', () => { calls += 1 route.fulfill({ json: SOME_MODELS }) }) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') await expect(page.locator(OVERVIEW)).toBeVisible() await page.waitForTimeout(500) // useModels() fetches the whole list and filters in the browser, so one @@ -141,7 +141,7 @@ test.describe('Studio overview', () => { test('an installation with no models at all still renders every modality', async ({ page }) => { await mockCapabilities(page, { data: [] }) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') await expect(page.locator(OVERVIEW)).toBeVisible() await expect(composer(page)).toBeVisible() await expect(page.locator('.studio-tab__dot--off')).toHaveCount(7) @@ -157,7 +157,7 @@ test.describe('Studio overview', () => { { id: 'i1', createdAt: Date.now(), model: 'flux.1-schnell', prompt: 'a brass orrery', results: [] }, ])) }) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') const tile = page.locator('[data-testid="work-tile"]') await expect(tile).toHaveCount(1) await expect(tile).toContainText('flux.1-schnell') @@ -166,14 +166,14 @@ test.describe('Studio overview', () => { test('no history means no tiles, and an empty state that says what happens next', async ({ page }) => { await mockCapabilities(page) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') await expect(page.locator('[data-testid="work-tile"]')).toHaveCount(0) await expect(page.locator('[data-testid="studio-work-empty"]')).toContainText('Nothing here yet') }) test('the overview is reachable back from a generator tab', async ({ page }) => { await mockCapabilities(page) - await page.goto('/app/studio/images') + await page.goto('/app/addons/studio/studio/images') await tabFor(page, 'overview').click() await expect(page.locator(OVERVIEW)).toBeVisible() }) @@ -182,7 +182,7 @@ test.describe('Studio overview', () => { // Bookmarks and older docs still use the query form; they must keep working // and must land on the canonical URL rather than a second spelling of it. await mockCapabilities(page) - await page.goto('/app/studio?tab=images') + await page.goto('/app/addons/studio/studio?tab=images') await expect(page).toHaveURL(/\/app\/addons\/studio\/studio\/images$/) await expect(page.locator('[data-testid="studio-workspace"]')).toBeVisible() }) diff --git a/core/http/react-ui/e2e/studio-transform.spec.js b/core/http/react-ui/e2e/studio-transform.spec.js index 26f1f0bf5..095c47687 100644 --- a/core/http/react-ui/e2e/studio-transform.spec.js +++ b/core/http/react-ui/e2e/studio-transform.spec.js @@ -5,7 +5,7 @@ test.describe('Studio - Transform', () => { test.beforeEach(async ({ page }) => { await installStudioAddons(page, { ids: ['studio'] }) }) test('Studio exposes a Transform tab that renders Audio Transform', async ({ page }) => { - await page.goto('/app/studio/transform') + await page.goto('/app/addons/studio/studio/transform') await expect(page.locator('.studio-tab', { hasText: 'Transform' })).toBeVisible() await expect(page.locator('h2.ws-title', { hasText: 'Audio Transform' })).toBeVisible({ timeout: 15_000 }) }) diff --git a/core/http/react-ui/e2e/studio-work.spec.js b/core/http/react-ui/e2e/studio-work.spec.js index 4f1f31cc1..7731db7cb 100644 --- a/core/http/react-ui/e2e/studio-work.spec.js +++ b/core/http/react-ui/e2e/studio-work.spec.js @@ -11,7 +11,7 @@ async function open(page, opts = {}) { await mockStudio(page) await stubMedia(page) await seedHistory(page, sampleHistory(), opts) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') await expect(page.locator('[data-testid="studio-work"]')).toBeVisible() } @@ -127,7 +127,7 @@ test.describe('Your work', () => { test('shows a skeleton while 3D history is read, not an empty state', async ({ page }) => { await mockStudio(page) - await page.goto('/app/studio') + await page.goto('/app/addons/studio/studio') await expect(page.locator('[data-testid="studio-work"]')).toBeVisible() // Once answered, the empty state is the truth. await expect(page.locator('[data-testid="studio-work-empty"]')).toBeVisible() diff --git a/core/http/react-ui/e2e/studio-workbench.spec.js b/core/http/react-ui/e2e/studio-workbench.spec.js index 211ae2240..7362b5783 100644 --- a/core/http/react-ui/e2e/studio-workbench.spec.js +++ b/core/http/react-ui/e2e/studio-workbench.spec.js @@ -8,7 +8,7 @@ test.describe('Studio workbench', () => { test.beforeEach(async ({ page }) => { await installStudioAddons(page, { ids: ['studio'] }) }) test('the compose card is a flat hairline card, not a shadowed one', async ({ page }) => { - await page.goto('/app/studio/images') + await page.goto('/app/addons/studio/studio/images') const card = page.locator('[data-testid="ws-compose"]') await expect(card).toBeVisible() const shadow = await card.evaluate(el => getComputedStyle(el).boxShadow) @@ -18,7 +18,7 @@ test.describe('Studio workbench', () => { }) test('fields are labelled in caps', async ({ page }) => { - await page.goto('/app/studio/images') + await page.goto('/app/addons/studio/studio/images') await page.getByRole('button', { name: /Advanced Settings/ }).click() const label = page.locator('[data-testid="ws-compose"] .ws-label').first() await expect(label).toBeVisible() @@ -28,7 +28,7 @@ test.describe('Studio workbench', () => { test('no request is shown before one has been made', async ({ page }) => { // A panel describing a request nobody sent is a tutorial, not a record. - await page.goto('/app/studio/images') + await page.goto('/app/addons/studio/studio/images') await expect(page.locator('.request-panel')).toHaveCount(0) }) @@ -38,7 +38,7 @@ test.describe('Studio workbench', () => { await page.route('**/v1/images/generations', route => route.fulfill({ json: { data: [{ url: 'https://example.invalid/a.png' }] } })) - await page.goto('/app/studio/images') + await page.goto('/app/addons/studio/studio/images') await page.locator('[data-testid="ws-compose"] textarea').first().fill('a brass orrery') await page.getByRole('button', { name: /generate/i }).click() diff --git a/core/http/react-ui/e2e/studio-workspace.spec.js b/core/http/react-ui/e2e/studio-workspace.spec.js index b2055acc6..78889cfd6 100644 --- a/core/http/react-ui/e2e/studio-workspace.spec.js +++ b/core/http/react-ui/e2e/studio-workspace.spec.js @@ -22,7 +22,7 @@ async function setup(page, opts = {}) { test.describe('Workspace frame (Images)', () => { test('the type tabs switch the workspace', async ({ page }) => { await setup(page) - await page.goto('/app/studio/images') + await page.goto('/app/addons/studio/studio/images') await expect(page.locator('[data-testid="studio-workspace"]')).toHaveAttribute('data-type', 'images') await expect(page.locator('.studio-tab')).toHaveCount(8) await page.locator('.studio-tab[data-tab="tts"]').click() @@ -32,7 +32,7 @@ test.describe('Workspace frame (Images)', () => { test('the compose card has the model chip, the essentials, an estimate and one action', async ({ page }) => { await setup(page) - await page.goto('/app/studio/images') + await page.goto('/app/addons/studio/studio/images') // The page title is Studio's own h1 above the tabs; the card heading is a section. await expect(page.getByRole('heading', { level: 1 })).toHaveText('Studio') await expect(compose(page).getByRole('heading', { level: 2 })).toContainText('Image') @@ -50,7 +50,7 @@ test.describe('Workspace frame (Images)', () => { test('starters fill the prompt and go away once there are words', async ({ page }) => { await setup(page) - await page.goto('/app/studio/images') + await page.goto('/app/addons/studio/studio/images') await page.locator('[data-testid="ws-starters"] button').first().click() await expect(prompt(page)).not.toHaveValue('') await expect(page.locator('[data-testid="ws-starters"]')).toHaveCount(0) @@ -58,7 +58,7 @@ test.describe('Workspace frame (Images)', () => { test('the Advanced fold names what is inside, opens on demand and keeps what is set in its name', async ({ page }) => { await setup(page) - await page.goto('/app/studio/images') + await page.goto('/app/addons/studio/studio/images') const bar = compose(page).getByRole('button', { name: /Advanced Settings/ }) await expect(bar).toHaveAttribute('aria-expanded', 'false') await expect(bar).toContainText('negative prompt, steps, seed') @@ -74,7 +74,7 @@ test.describe('Workspace frame (Images)', () => { await setup(page) let release await page.route('**/v1/images/generations', async route => { await new Promise(r => { release = r }); await route.fulfill({ json: { data: [{ url: '/generated-images/x.png' }] } }) }) - await page.goto('/app/studio/images') + await page.goto('/app/addons/studio/studio/images') await prompt(page).fill('a lighthouse') await generate(page).click() const job = page.locator('[data-testid="ws-job"]') @@ -95,7 +95,7 @@ test.describe('Workspace frame (Images)', () => { if (calls === 1) return route.fulfill({ status: 500, json: { error: { message: 'out of memory while sampling' } } }) return route.fulfill({ json: { data: [{ url: '/generated-images/ok.png' }] } }) }) - await page.goto('/app/studio/images') + await page.goto('/app/addons/studio/studio/images') await prompt(page).fill('a lighthouse') await generate(page).click() const failed = page.locator('[data-testid="ws-failed"]') @@ -109,7 +109,7 @@ test.describe('Workspace frame (Images)', () => { test('with no model for the type the install note shows and Generate says why it cannot run', async ({ page }) => { await setup(page, { types: ['tts'] }) - await page.goto('/app/studio/images') + await page.goto('/app/addons/studio/studio/images') await expect(page.locator('[data-testid="studio-install-note"]')).toBeVisible() await prompt(page).fill('a lighthouse') await expect(generate(page)).toBeDisabled() @@ -121,7 +121,7 @@ test.describe('Workspace frame (Images)', () => { test('the strip lists this type from the shared history, filters favourites and toggles them', async ({ page }) => { await setup(page) await seedHistory(page, sampleHistory(), { favourites: ['img-forest'] }) - await page.goto('/app/studio/images') + await page.goto('/app/addons/studio/studio/images') await expect(tile(page)).toHaveCount(4) await page.getByRole('tab', { name: /Favourites/ }).click() await expect(tile(page)).toHaveCount(1) @@ -141,7 +141,7 @@ test.describe('Workspace frame (Images)', () => { test('Use in lists the next steps and opens the destination with the result as its source', async ({ page }) => { await setup(page) await seedHistory(page, sampleHistory()) - await page.goto('/app/studio/images') + await page.goto('/app/addons/studio/studio/images') await tile(page).filter({ hasText: 'Misty pine forest' }).locator('.ws-tile__open').click() await page.locator('[data-testid="ws-use-in"]').click() await expect(page.locator('[data-testid="use-in-animate"]')).toBeEnabled() @@ -155,7 +155,7 @@ test.describe('Workspace frame (Images)', () => { test('a step the destination cannot start from is listed, disabled, with the reason', async ({ page }) => { await setup(page) await seedHistory(page, sampleHistory()) - await page.goto('/app/studio/video') + await page.goto('/app/addons/studio/studio/video') await tile(page).locator('.ws-tile__open').click() await page.locator('[data-testid="ws-use-in"]').click() const item = page.locator('[data-testid="use-in-soundtrack"]') @@ -166,7 +166,7 @@ test.describe('Workspace frame (Images)', () => { test('a type with nothing to send on has Use in disabled with the reason', async ({ page }) => { await setup(page) await seedHistory(page, sampleHistory()) - await page.goto('/app/studio/diarization') + await page.goto('/app/addons/studio/studio/diarization') await tile(page).locator('.ws-tile__open').click() await expect(page.locator('[data-testid="ws-use-in"]')).toBeDisabled() }) @@ -174,7 +174,7 @@ test.describe('Workspace frame (Images)', () => { test('Lineage opens the front page lineage view for the result', async ({ page }) => { await setup(page) await seedHistory(page, sampleHistory()) - await page.goto('/app/studio/images') + await page.goto('/app/addons/studio/studio/images') await tile(page).filter({ hasText: 'Misty pine forest' }).locator('.ws-tile__open').click() await page.locator('[data-testid="ws-lineage"]').click() await expect(page).toHaveURL(/\/app\/addons\/studio\/studio\?work=img-forest/) @@ -184,7 +184,7 @@ test.describe('Workspace frame (Images)', () => { test('a result made through a hand-off names where it came from and links to it', async ({ page }) => { await setup(page) await seedHistory(page, sampleHistory()) - await page.goto('/app/studio/video') + await page.goto('/app/addons/studio/studio/video') await tile(page).locator('.ws-tile__open').click() const from = page.locator('[data-testid="ws-from"]') await expect(from).toContainText('animate') @@ -199,7 +199,7 @@ test.describe('Workspace frame (Images)', () => { await seedHistory(page, history) let body await page.route('**/v1/images/generations', route => { body = route.request().postDataJSON(); return route.fulfill({ json: { data: [{ url: '/generated-images/again.png' }] } }) }) - await page.goto('/app/studio/images') + await page.goto('/app/addons/studio/studio/images') await tile(page).filter({ hasText: 'Misty pine forest' }).locator('.ws-tile__open').click() await page.locator('[data-testid="ws-rerun"]').click() await expect(prompt(page)).toHaveValue(/Misty pine forest/) @@ -222,7 +222,7 @@ test.describe('Workspace frame (Images)', () => { test('Re-run with edits is disabled, with the reason, when the input was not kept', async ({ page }) => { await setup(page) await seedHistory(page, sampleHistory()) - await page.goto('/app/studio/diarization') + await page.goto('/app/addons/studio/studio/diarization') await tile(page).locator('.ws-tile__open').click() await expect(page.locator('[data-testid="ws-rerun"]')).toBeDisabled() await expect(page.locator('[data-testid="ws-rerun"]')).toHaveAttribute('title', /not kept/) @@ -231,7 +231,7 @@ test.describe('Workspace frame (Images)', () => { test('deleting and clearing from the strip remove entries', async ({ page }) => { await setup(page) await seedHistory(page, sampleHistory()) - await page.goto('/app/studio/images') + await page.goto('/app/addons/studio/studio/images') await expect(tile(page)).toHaveCount(4) await tile(page).first().hover() await tile(page).first().getByTestId('media-history-delete').click() @@ -243,7 +243,7 @@ test.describe('Workspace frame (Images)', () => { test('the hand-off note and source chip show when the page starts from a result', async ({ page }) => { await setup(page) await seedHistory(page, sampleHistory()) - await page.goto('/app/studio/video?prompt=Slow%20push-in&from=img-forest&edge=animate') + await page.goto('/app/addons/studio/studio/video?prompt=Slow%20push-in&from=img-forest&edge=animate') await expect(page.locator('[data-testid="studio-handoff"]')).toHaveAttribute('data-status', 'ready') await expect(compose(page).locator('.ws-source--filled img')).toBeVisible() await expect(prompt(page)).toHaveValue('Slow push-in') @@ -255,7 +255,7 @@ test.describe('Workspace run-throughs', () => { await setup(page) let body await page.route('**/video', route => { if (route.request().method() !== 'POST') return route.fallback(); body = route.request().postDataJSON(); return route.fulfill({ json: { data: [{ url: '/generated-videos/new.mp4' }] } }) }) - await page.goto('/app/studio/video') + await page.goto('/app/addons/studio/studio/video') await prompt(page).fill('Boats drift on calm water') await page.locator('[data-testid="ws-size"]').selectOption('832x480') await page.locator('[data-testid="ws-duration"]').fill('3') @@ -275,7 +275,7 @@ test.describe('Workspace run-throughs', () => { body = route.request().postDataJSON() return route.fulfill({ status: 200, headers: { 'Content-Type': 'audio/wav', 'Content-Disposition': 'attachment; filename="line.wav"' }, body: silentWav(2) }) }) - await page.goto('/app/studio/tts') + await page.goto('/app/addons/studio/studio/tts') await prompt(page).fill('Welcome to the harbour.') await page.locator('[data-testid="ws-voice"]').fill('af_bella') await generate(page).click() @@ -293,7 +293,7 @@ test.describe('Workspace run-throughs', () => { body = route.request().postDataJSON() return route.fulfill({ status: 200, headers: { 'Content-Type': 'audio/wav', 'Content-Disposition': 'attachment; filename="pads.wav"' }, body: silentWav(2) }) }) - await page.goto('/app/studio/sound') + await page.goto('/app/addons/studio/studio/sound') await page.getByRole('tab', { name: 'Advanced' }).click() await compose(page).getByLabel('Caption').fill('slow pads') await compose(page).getByLabel('Lyrics').fill('la la') @@ -312,7 +312,7 @@ test.describe('Workspace run-throughs', () => { headers: { 'Content-Type': 'audio/wav', 'Content-Disposition': 'attachment; filename="clean.wav"', 'X-Audio-Input-Url': '/generated-audio/in.wav' }, body: silentWav(2), })) - await page.goto('/app/studio/transform') + await page.goto('/app/addons/studio/studio/transform') await expect(page.locator('[data-testid="ws-why"]')).toContainText('Add an audio file first') await page.locator('input[type="file"]').first().setInputFiles({ name: 'mic.wav', mimeType: 'audio/wav', buffer: silentWav(2) }) await expect(page.locator('[data-testid="ws-view"]')).toContainText('waiting to be transformed') @@ -326,7 +326,7 @@ test.describe('Workspace run-throughs', () => { await setup(page) await page.route('**/3d/generations', route => route.fulfill({ json: { data: [{ url: '/generated-3d/t.glb' }] } })) await page.route('**/generated-3d/t.glb', route => route.fulfill({ contentType: 'model/gltf-binary', body: tinyGlb() })) - await page.goto('/app/studio/threed') + await page.goto('/app/addons/studio/studio/threed') await expect(page.locator('[data-testid="ws-why"]')).toContainText('Add a picture first') await page.locator('#threed-image-file').setInputFiles({ name: 'in.png', mimeType: 'image/png', buffer: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64') }) await generate(page).click() @@ -344,7 +344,7 @@ test.describe('Workspace run-throughs', () => { { id: 1, speaker: 'SPEAKER_01', label: '1', start: 6, end: 10, text: 'Hello.' }, ], } })) - await page.goto('/app/studio/diarization') + await page.goto('/app/addons/studio/studio/diarization') await page.locator('#diarization-file').setInputFiles({ name: 'call.wav', mimeType: 'audio/wav', buffer: silentWav(2) }) await generate(page).click() await expect(page.locator('[data-testid="ws-timeline"] .ws-timeline__lane')).toHaveCount(2) @@ -365,7 +365,7 @@ test.describe('Workspace on a phone', () => { test(`${type} fits the width and its action stays reachable`, async ({ page }) => { await setup(page) await seedHistory(page, sampleHistory()) - await page.goto(`/app/studio/${type}`) + await page.goto(`/app/addons/studio/studio/${type}`) await expect(compose(page)).toBeVisible() const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) expect(overflow).toBeLessThanOrEqual(0) @@ -378,7 +378,7 @@ test.describe('Workspace on a phone', () => { test('the result toolbar and the strip fit after a run', async ({ page }) => { await setup(page) await seedHistory(page, sampleHistory()) - await page.goto('/app/studio/images') + await page.goto('/app/addons/studio/studio/images') await tile(page).first().locator('.ws-tile__open').click() await expect(page.locator('[data-testid="ws-result"]')).toBeVisible() const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) @@ -390,7 +390,7 @@ test('with reduced motion the job bar is still, not a moving stripe', async ({ p await page.emulateMedia({ reducedMotion: 'reduce' }) await setup(page) await page.route('**/v1/images/generations', () => new Promise(() => {})) - await page.goto('/app/studio/images') + await page.goto('/app/addons/studio/studio/images') await prompt(page).fill('a lighthouse') await generate(page).click() const bar = page.locator('[data-testid="ws-job"] .dk-progress-bar') diff --git a/core/http/react-ui/e2e/talk.spec.js b/core/http/react-ui/e2e/talk.spec.js index d051b03ff..603f5c5bb 100644 --- a/core/http/react-ui/e2e/talk.spec.js +++ b/core/http/react-ui/e2e/talk.spec.js @@ -11,7 +11,7 @@ const emit = (page, event) => page.evaluate((e) => window.__talk.emit(e), event) const connected = (page) => page.waitForFunction(() => window.__talk.dc && window.__talk.pc?.connectionState === 'connected') async function startSession(page) { - await page.goto('/app/talk') + await page.goto('/app/addons/talk/talk') await expect(page.getByTestId('talk-start')).toBeEnabled() await page.getByTestId('talk-start').click() await connected(page) @@ -21,7 +21,7 @@ async function startSession(page) { test.describe('Talk states', () => { test('idle: ready, with one way to start and a note on how the microphone is used', async ({ page }) => { await fakeRealtime(page) - await page.goto('/app/talk') + await page.goto('/app/addons/talk/talk') await expect(status(page)).toHaveAttribute('data-state', 'idle') await expect(status(page).getByRole('heading')).toHaveText('Ready') await expect(page.getByTestId('talk-start')).toHaveText('Start session') @@ -38,7 +38,7 @@ test.describe('Talk states', () => { test('connecting: the call is being set up and the button cannot be pressed twice', async ({ page }) => { await fakeRealtime(page) await page.addInitScript(() => { window.__talk.hold = true }) - await page.goto('/app/talk') + await page.goto('/app/addons/talk/talk') await page.getByTestId('talk-start').click() await expect(status(page)).toHaveAttribute('data-state', 'connecting') await expect(status(page).getByRole('heading')).toHaveText('Connecting') @@ -130,7 +130,7 @@ test.describe('Talk states', () => { test('mic blocked: the browser refused the microphone', async ({ page }) => { await fakeRealtime(page, { micError: 'NotAllowedError' }) - await page.goto('/app/talk') + await page.goto('/app/addons/talk/talk') await page.getByTestId('talk-start').click() await expect(status(page)).toHaveAttribute('data-state', 'blocked') await expect(status(page).getByRole('heading')).toHaveText('Microphone blocked') @@ -169,7 +169,7 @@ test.describe('Talk states', () => { test('no pipeline: a card names the next step instead of an orb', async ({ page }) => { await fakeRealtime(page, { pipelines: false }) - await page.goto('/app/talk') + await page.goto('/app/addons/talk/talk') const card = page.getByTestId('talk-no-pipeline') await expect(card.getByRole('heading')).toHaveText('Talk needs a pipeline model') await expect(page.locator('.talk-orb')).toHaveCount(0) @@ -212,7 +212,7 @@ test.describe('Talk controls', () => { test('the pipeline can be changed before a session and not during one', async ({ page }) => { await fakeRealtime(page) - await page.goto('/app/talk') + await page.goto('/app/addons/talk/talk') await page.getByTestId('home-model-chip').click() await page.getByRole('option', { name: /voice-it/ }).click() await expect(page.getByTestId('home-model-chip')).toContainText('voice-it') @@ -225,7 +225,7 @@ test.describe('Talk controls', () => { test('diagnostics are offered only during a session and show the audio panel', async ({ page }) => { await fakeRealtime(page) - await page.goto('/app/talk') + await page.goto('/app/addons/talk/talk') await expect(page.getByTestId('talk-diag-toggle')).toHaveCount(0) await page.getByTestId('talk-start').click() await connected(page) @@ -239,7 +239,7 @@ test.describe('Talk controls', () => { test.describe('Talk session settings', () => { test('the sheet holds voice, language and instructions, and they go out when the session starts', async ({ page }) => { await fakeRealtime(page) - await page.goto('/app/talk') + await page.goto('/app/addons/talk/talk') await page.getByTestId('talk-settings-button').click() const sheet = page.getByTestId('talk-settings') await expect(sheet).toHaveClass(/dk-sheet/) @@ -267,7 +267,7 @@ test.describe('Talk session settings', () => { // onClose. Focus has to stay in the field rather than jump to Close. test('typing in the sheet keeps focus in the field while the page re-renders', async ({ page }) => { await fakeRealtime(page) - await page.goto('/app/talk') + await page.goto('/app/addons/talk/talk') await page.getByTestId('talk-settings-button').click() const sheet = page.getByTestId('talk-settings') const instructions = sheet.getByRole('textbox', { name: 'Instructions' }) @@ -293,7 +293,7 @@ test.describe('Talk session settings', () => { test('Edit pipeline opens the model editor before a session', async ({ page }) => { await fakeRealtime(page) - await page.goto('/app/talk') + await page.goto('/app/addons/talk/talk') await page.getByTestId('talk-settings-button').click() await page.getByTestId('talk-pipeline').getByRole('button', { name: 'Edit pipeline' }).click() await expect(page).toHaveURL(/\/app\/model-editor\/voice-assistant/) @@ -316,7 +316,7 @@ test.describe('Talk with a speech-to-speech model', () => { test('the sheet names one speech-to-speech model, with no slots and no voice detection', async ({ page }) => { await fakeRealtime(page) await s2sModel(page) - await page.goto('/app/talk') + await page.goto('/app/addons/talk/talk') await page.getByTestId('talk-settings-button').click() const pipeline = page.getByTestId('talk-pipeline') await expect(pipeline).toContainText('Speech-to-speech model') @@ -353,7 +353,7 @@ test.describe('Talk with a speech-to-speech model', () => { test('idle note says to press Done speaking when no voice detection model is set', async ({ page }) => { await fakeRealtime(page) await s2sModel(page) - await page.goto('/app/talk') + await page.goto('/app/addons/talk/talk') await expect(page.getByText('press Done speaking when you finish a turn')).toBeVisible() await expect(page.getByText('The server notices when you stop talking')).toHaveCount(0) }) @@ -398,7 +398,7 @@ test.describe('Talk on a phone', () => { test('the settings sheet rises from the bottom', async ({ page }) => { await fakeRealtime(page) - await page.goto('/app/talk') + await page.goto('/app/addons/talk/talk') await page.getByTestId('talk-settings-button').click() const box = await page.getByTestId('talk-settings').boundingBox() expect(box.width).toBeLessThanOrEqual(390) diff --git a/core/http/react-ui/e2e/taste-polish.spec.js b/core/http/react-ui/e2e/taste-polish.spec.js index 31e95bd59..d99aefb6f 100644 --- a/core/http/react-ui/e2e/taste-polish.spec.js +++ b/core/http/react-ui/e2e/taste-polish.spec.js @@ -92,7 +92,7 @@ test('the Studio voice field shows its whole placeholder and stays inside a phon await stubMedia(page) for (const width of [1440, 390]) { await page.setViewportSize({ width, height: 900 }) - await page.goto('/app/studio/tts') + await page.goto('/app/addons/studio/studio/tts') const voice = page.getByTestId('ws-voice') await expect(voice).toBeVisible({ timeout: 15_000 }) const box = await voice.boundingBox() diff --git a/core/http/react-ui/e2e/threed-animation.spec.js b/core/http/react-ui/e2e/threed-animation.spec.js index 8f2ee2b05..1d8f048ee 100644 --- a/core/http/react-ui/e2e/threed-animation.spec.js +++ b/core/http/react-ui/e2e/threed-animation.spec.js @@ -48,7 +48,7 @@ test('switches inputs, generates animation, scrubs playback and restores history request = route.request().postDataJSON() return route.fulfill({ json: { data: [{ url: '/generated-3d/animation.glb' }] } }) }) - await page.goto('/app/studio/threed') + await page.goto('/app/addons/studio/studio/threed') await page.getByRole('button', { name: 'trellis-test', exact: true }).click() await page.getByRole('option', { name: 'kimodo-test', exact: true }).click() await expect(page.locator('#threed-image-file')).toHaveCount(0) @@ -87,7 +87,7 @@ test('switches inputs, generates animation, scrubs playback and restores history test('plays in real time across delayed frames, looping, pause and seek', async ({ page }) => { await page.route('**/3d/animate', route => route.fulfill({ json: { data: [{ url: '/generated-3d/animation.glb' }] } })) - await page.goto('/app/studio/threed') + await page.goto('/app/addons/studio/studio/threed') await page.getByRole('button', { name: 'trellis-test', exact: true }).click() await page.getByRole('option', { name: 'kimodo-test', exact: true }).click() await page.getByLabel('Motion prompt').fill('Walk.') @@ -136,14 +136,14 @@ test('keeps the 3D Studio category available with only an animation model', asyn await page.route('**/api/models/capabilities', route => route.fulfill({ json: { data: [ { id: 'kimodo-test', capabilities: ['FLAG_3D_ANIMATION'], three_d_operations: [animationOperation] }, ] } })) - await page.goto('/app/studio/threed') + await page.goto('/app/addons/studio/studio/threed') await expect(page.getByLabel('Motion prompt')).toBeVisible() await expect(page.getByRole('button', { name: 'kimodo-test', exact: true })).toBeVisible() }) test('validates the prompt byte limit before submitting', async ({ page }) => { // The model query is now read as a hand-off, so pick the model in the page as a person would. - await page.goto('/app/studio/threed') + await page.goto('/app/addons/studio/studio/threed') await page.getByRole('button', { name: 'trellis-test', exact: true }).click() await page.getByRole('option', { name: 'kimodo-test', exact: true }).click() await page.getByLabel('Motion prompt').fill('🦊'.repeat(1025)) @@ -165,7 +165,7 @@ test('renders media conditioning and enumerated parameters from model capabiliti request = route.request().postDataJSON() return route.fulfill({ status: 400, json: { error: { message: 'Fixture stops after request validation' } } }) }) - await page.goto('/app/studio/threed') + await page.goto('/app/addons/studio/studio/threed') await page.getByLabel('Motion prompt').fill('Wave.') await page.getByLabel('Source mesh').setInputFiles({ name: 'character.glb', mimeType: 'model/gltf-binary', buffer: animationGlb() }) await page.getByLabel('Motion mode').selectOption('once') @@ -179,7 +179,7 @@ test('renders media conditioning and enumerated parameters from model capabiliti test('reports unsupported skeleton transforms without displaying a stale pose', async ({ page }) => { await page.route('**/generated-3d/animation.glb', route => route.fulfill({ contentType: 'model/gltf-binary', body: animationGlb(document => { document.nodes[1].translation = [0, 1] }) })) await page.route('**/3d/animate', route => route.fulfill({ json: { data: [{ url: '/generated-3d/animation.glb' }] } })) - await page.goto('/app/studio/threed') + await page.goto('/app/addons/studio/studio/threed') await page.getByRole('button', { name: 'trellis-test', exact: true }).click() await page.getByRole('option', { name: 'kimodo-test', exact: true }).click() await page.getByLabel('Motion prompt').fill('Walk.') diff --git a/core/http/react-ui/e2e/threed-gen.spec.js b/core/http/react-ui/e2e/threed-gen.spec.js index c5e4408f3..0ba1f25e4 100644 --- a/core/http/react-ui/e2e/threed-gen.spec.js +++ b/core/http/react-ui/e2e/threed-gen.spec.js @@ -117,7 +117,7 @@ function mockGlbDownload(page) { } async function generateOnce(page) { - await page.goto('/app/3d') + await page.goto('/app/addons/studio/studio/threed') await expect(page.getByRole('button', { name: 'trellis-test-model' })).toBeVisible({ timeout: 10_000 }) await page.locator('#threed-image-file').setInputFiles({ name: 'input.png', @@ -213,7 +213,7 @@ test.describe('3D generation', () => { let requestBody = null await mockGeneration(page, (body) => { requestBody = body }) - await page.goto('/app/studio/threed') + await page.goto('/app/addons/studio/studio/threed') await expect(page.getByRole('button', { name: 'trellis-test-model' })).toBeVisible({ timeout: 10_000 }) await pasteImage(page) await page.locator('button[type="submit"]').click() @@ -232,7 +232,7 @@ test.describe('3D generation', () => { let requestBody = null await mockGeneration(page, (body) => { requestBody = body }) - await page.goto('/app/3d') + await page.goto('/app/addons/studio/studio/threed') await expect(page.getByRole('button', { name: 'trellis-test-model' })).toBeVisible({ timeout: 10_000 }) await page.locator('#threed-image-file').setInputFiles({ name: 'input.png', mimeType: 'image/png', buffer: TINY_PNG }) @@ -291,7 +291,7 @@ test.describe('3D generation', () => { // IndexedDB persists within the browser context — navigate away and back. await page.goto('/app') - await page.goto('/app/3d') + await page.goto('/app/addons/studio/studio/threed') await expect(page.getByTestId('media-history-item')).toHaveCount(1, { timeout: 15_000 }) // Selecting the entry loads the stored Blob back into the viewer. @@ -302,7 +302,7 @@ test.describe('3D generation', () => { test('new history is visible on the Studio overview without a reload', async ({ page }) => { await mockGeneration(page) - await page.goto('/app/studio/threed') + await page.goto('/app/addons/studio/studio/threed') await expect(page.getByRole('button', { name: 'trellis-test-model' })).toBeVisible({ timeout: 10_000 }) await page.locator('#threed-image-file').setInputFiles({ name: 'input.png', mimeType: 'image/png', buffer: TINY_PNG }) await page.locator('button[type="submit"]').click() @@ -337,7 +337,7 @@ test.describe('3D generation', () => { test('rejects oversized conditioning images before making an API request', async ({ page }, testInfo) => { let requested = false await mockGeneration(page, () => { requested = true }) - await page.goto('/app/3d') + await page.goto('/app/addons/studio/studio/threed') await expect(page.getByRole('button', { name: 'trellis-test-model' })).toBeVisible({ timeout: 10_000 }) // A file on disk, not an in-memory buffer: Playwright sends a buffer to @@ -365,14 +365,15 @@ test.describe('3D generation', () => { }) }) - await page.goto('/app/studio/threed') + await page.goto('/app/addons/studio/studio/threed') await expect(page.getByRole('button', { name: '3D', exact: true })).toHaveCount(0) // Falls back to the overview rather than Images. Landing on Images was // never a decision, only the first entry in the tab array. await expect(page.locator('.studio-tab[data-tab="overview"]')).toHaveClass(/studio-tab-active/) - await page.goto('/app/3d') - await expect(page).toHaveURL(/\/app\/?$/) + await page.goto('/app/addons/studio/studio/threed') + await expect(page.locator('.studio-tab-active[data-tab="overview"]')).toBeVisible() + await expect(page.locator('[data-tab="threed"]')).toHaveCount(0) }) test.describe('touch input', () => { diff --git a/core/http/react-ui/eslint.config.js b/core/http/react-ui/eslint.config.js index 7f2d10f4d..639138bea 100644 --- a/core/http/react-ui/eslint.config.js +++ b/core/http/react-ui/eslint.config.js @@ -4,6 +4,7 @@ import reactHooks from 'eslint-plugin-react-hooks' import reactRefresh from 'eslint-plugin-react-refresh' export default [ + { files: ['src/components/chat/ChatComposer.jsx'], rules: { 'no-undef': 'error' } }, { ignores: ['dist'] }, { files: ['**/*.{js,jsx}'], diff --git a/core/http/react-ui/i18n-baseline.json b/core/http/react-ui/i18n-baseline.json index ce10f8d8e..0004fa409 100644 --- a/core/http/react-ui/i18n-baseline.json +++ b/core/http/react-ui/i18n-baseline.json @@ -6,7 +6,7 @@ "de/chats": 272, "de/collections": 73, "de/common": 1, - "de/home": 118, + "de/home": 114, "de/importModel": 64, "de/media": 490, "de/modelEditor": 22, @@ -24,7 +24,7 @@ "es/chats": 272, "es/collections": 73, "es/common": 1, - "es/home": 118, + "es/home": 114, "es/importModel": 64, "es/media": 490, "es/modelEditor": 22, @@ -41,7 +41,7 @@ "fr/chat": 121, "fr/chats": 272, "fr/collections": 73, - "fr/home": 113, + "fr/home": 109, "fr/importModel": 62, "fr/media": 263, "fr/modelEditor": 22, @@ -58,7 +58,7 @@ "id/chat": 126, "id/chats": 272, "id/collections": 73, - "id/home": 118, + "id/home": 114, "id/importModel": 64, "id/media": 453, "id/models": 407, @@ -75,7 +75,7 @@ "it/chats": 272, "it/collections": 73, "it/common": 1, - "it/home": 118, + "it/home": 114, "it/importModel": 64, "it/media": 490, "it/modelEditor": 22, @@ -93,7 +93,7 @@ "ko/chats": 272, "ko/collections": 73, "ko/common": 1, - "ko/home": 118, + "ko/home": 114, "ko/importModel": 64, "ko/media": 490, "ko/modelEditor": 22, @@ -110,7 +110,7 @@ "pt-BR/chat": 123, "pt-BR/chats": 272, "pt-BR/collections": 73, - "pt-BR/home": 107, + "pt-BR/home": 103, "pt-BR/importModel": 64, "pt-BR/media": 300, "pt-BR/models": 403, @@ -127,7 +127,7 @@ "zh-CN/chats": 272, "zh-CN/collections": 73, "zh-CN/common": 1, - "zh-CN/home": 118, + "zh-CN/home": 114, "zh-CN/importModel": 64, "zh-CN/media": 490, "zh-CN/modelEditor": 22, diff --git a/core/http/react-ui/public/locales/de/chat.json b/core/http/react-ui/public/locales/de/chat.json index 1febd9f27..c8d29a334 100644 --- a/core/http/react-ui/public/locales/de/chat.json +++ b/core/http/react-ui/public/locales/de/chat.json @@ -584,7 +584,8 @@ "context": "Kontext", "contextOf": "{{used}} von {{limit}} Token", "contextUsed": "{{used}} Token", - "unknown": "Noch nicht bekannt" + "unknown": "Noch nicht bekannt", + "commandsUnknown": "Die Befehlsisolation ist unbekannt" }, "agentFolder": { "home": "Start", diff --git a/core/http/react-ui/public/locales/de/chats.json b/core/http/react-ui/public/locales/de/chats.json index 3ea576722..c3d37c025 100644 --- a/core/http/react-ui/public/locales/de/chats.json +++ b/core/http/react-ui/public/locales/de/chats.json @@ -285,11 +285,15 @@ "hostOff": "Die Sandbox ist für diesen Chat aus, daher laufen Befehle auf diesem Rechner, mit seinen Dateien und seinem Netzwerk, ohne Isolierung. Schalten Sie die Sandbox in diesem Menü ein, um sie zu isolieren.", "hostUnavailable": "Die Sandbox kann hier nicht laufen, daher laufen Befehle auf diesem Rechner, mit seinen Dateien und seinem Netzwerk, ohne Isolierung.", "hostUnavailableWhy": "Die Sandbox kann hier nicht laufen ({{reason}}), daher laufen Befehle auf diesem Rechner, mit seinen Dateien und seinem Netzwerk, ohne Isolierung.", - "hostChipTitle": "Befehle in diesem Chat laufen auf diesem Rechner, ohne Isolierung. Für Details auswählen." + "hostChipTitle": "Befehle in diesem Chat laufen auf diesem Rechner, ohne Isolierung. Für Details auswählen.", + "providerHost": "Befehle werden ohne Isolation auf dem Server ausgeführt.", + "unknown": "Ausführungsbereitschaft oder Isolation ist unbekannt.", + "providerUnavailable": "Der ausgewählte Ausführungsanbieter ist nicht verfügbar.", + "disabled": "Die Befehlsausführung ist für diesen Chat ausgeschaltet." }, "consent": { "title": "Diesen Befehl auf diesem Rechner ausführen?", - "body": "Befehle laufen auf diesem Rechner ohne Isolierung. Sie können Dateien lesen, die LocalAI lesen kann, und das Internet und Ihr Netzwerk erreichen. Installieren Sie die Sandbox für Isolierung.", + "body": "Befehle laufen auf diesem Rechner ohne Isolierung. Sie können Dateien lesen, die LocalAI lesen kann, und das Internet und Ihr Netzwerk erreichen.", "once": "Einmal ausführen", "always": "Immer auf dieser Instanz", "deny": "Nicht ausführen", @@ -395,7 +399,9 @@ "allowedHostsOff": "Sie gelten, wenn das Netzwerk eingeschaltet ist.", "allowedHostsNote": "{{count}} Hosts sind von Ihrem Administrator immer erlaubt.", "limitNote": "Netzwerk dieses Chats. Der Administrator legt fest, was ein Chat höchstens erreichen darf.", - "asksAbove": "Dieser Chat verlangt {{choice}}; das Limit des Administrators ist {{limit}}." + "asksAbove": "Dieser Chat verlangt {{choice}}; das Limit des Administrators ist {{limit}}.", + "unverified": "Die Durchsetzung der Netzwerkrichtlinie ist für den tatsächlichen Anbieter nicht bestätigt. Diese Einstellungen gelten nicht für seine Befehle.", + "configuredPolicy": "Konfigurierte Netzwerkrichtlinie: {{preset}} (nicht durchgesetzt)." }, "inbox": { "unread": "{{count}} ungelesen", diff --git a/core/http/react-ui/public/locales/de/home.json b/core/http/react-ui/public/locales/de/home.json index ffd6957f2..02dab04ae 100644 --- a/core/http/react-ui/public/locales/de/home.json +++ b/core/http/react-ui/public/locales/de/home.json @@ -117,6 +117,14 @@ "allChats": "Alle Chats" }, "slash": { + "commandDiagnostics": "Diagnose der App-Befehle", + "apps": { + "label": "Apps", + "desc": "Installierte Apps durchsuchen" + }, + "group": { + "addons": "Apps" + }, "agents": { "label": "Agenten", "desc": "Deine gespeicherten Agenten, Auslöser und Skills" @@ -130,5 +138,6 @@ "desc": "Sammlungen, in denen deine Chats suchen können" }, "complete": "ergänzen" - } + }, + "notice": { "appUnavailable": "Die App ist nicht mehr verfügbar." } } diff --git a/core/http/react-ui/public/locales/en/chat.json b/core/http/react-ui/public/locales/en/chat.json index 37853288b..b69a892a2 100644 --- a/core/http/react-ui/public/locales/en/chat.json +++ b/core/http/react-ui/public/locales/en/chat.json @@ -731,7 +731,8 @@ "context": "Context", "contextOf": "{{used}} of {{limit}} tokens", "contextUsed": "{{used}} tokens", - "unknown": "Not known yet" + "unknown": "Not known yet", + "commandsUnknown": "Command isolation is unknown" }, "agentFolder": { "home": "Home", diff --git a/core/http/react-ui/public/locales/en/chats.json b/core/http/react-ui/public/locales/en/chats.json index aa8b6d46a..857ecf743 100644 --- a/core/http/react-ui/public/locales/en/chats.json +++ b/core/http/react-ui/public/locales/en/chats.json @@ -409,11 +409,15 @@ "hostOff": "The Sandbox is off for this chat, so commands run on this machine, with its files and network, not isolated. Switch the Sandbox on in this menu to isolate them.", "hostUnavailable": "The Sandbox cannot run here, so commands run on this machine, with its files and network, not isolated.", "hostUnavailableWhy": "The Sandbox cannot run here ({{reason}}), so commands run on this machine, with its files and network, not isolated.", - "hostChipTitle": "Commands in this chat run on this machine, not isolated. Select for details." + "hostChipTitle": "Commands in this chat run on this machine, not isolated. Select for details.", + "providerHost": "Commands run on the server without isolation.", + "unknown": "Execution readiness or isolation is unknown.", + "providerUnavailable": "The selected execution provider is unavailable.", + "disabled": "Running commands is switched off for this chat." }, "consent": { "title": "Run this command on this machine?", - "body": "Commands run on this machine with no isolation. They can read files that LocalAI can read and reach the internet and your network. Install the Sandbox for isolation.", + "body": "Commands run on this machine with no isolation. They can read files that LocalAI can read and reach the internet and your network.", "once": "Run once", "always": "Always on this instance", "deny": "Don't run", @@ -774,7 +778,9 @@ "allowedHostsOff": "They apply when the network is on.", "allowedHostsNote": "{{count}} hosts are always allowed by your administrator.", "limitNote": "This chat’s network. The administrator sets the most a chat can reach.", - "asksAbove": "This chat asks for {{choice}}; the administrator limit is {{limit}}." + "asksAbove": "This chat asks for {{choice}}; the administrator limit is {{limit}}.", + "unverified": "Network-policy enforcement is not verified for the actual provider. These settings do not currently apply to its commands.", + "configuredPolicy": "Configured network policy: {{preset}} (not enforced)." }, "change": { "loading": "Reading the trigger change...", diff --git a/core/http/react-ui/public/locales/en/home.json b/core/http/react-ui/public/locales/en/home.json index f64f93983..a3a5e4f6e 100644 --- a/core/http/react-ui/public/locales/en/home.json +++ b/core/http/react-ui/public/locales/en/home.json @@ -177,7 +177,10 @@ "label": "Actions", "move": "move", "close": "close", + "commandDiagnostics": "App command diagnostics", + "apps": { "label": "Apps", "desc": "Browse installed apps" }, "group": { + "addons": "Apps", "chat": "Chat", "models": "Models", "go": "Go to" @@ -214,10 +217,6 @@ "label": "Stop a model", "desc": "Free memory by unloading a model" }, - "studio": { - "label": "Open Studio", - "desc": "Create images, audio and video" - }, "settings": { "label": "Settings", "desc": "Change how LocalAI runs" @@ -238,10 +237,6 @@ "label": "Memory", "desc": "Collections your chats can search" }, - "studioInstall": { - "label": "Install Studio", - "desc": "Studio makes images, audio and video. It is an add-on" - }, "complete": "complete" }, "strip": { @@ -265,6 +260,7 @@ "firstRun": "No models running. Memory use shows here once one loads." }, "notice": { + "appUnavailable": "App is no longer available.", "loadFailedTitle": "LocalAI could not load your models", "loadFailedBody": "The server did not answer. Check that it is running, then try again.", "retry": "Try again", diff --git a/core/http/react-ui/public/locales/es/chat.json b/core/http/react-ui/public/locales/es/chat.json index 878c04a02..c013fa4ce 100644 --- a/core/http/react-ui/public/locales/es/chat.json +++ b/core/http/react-ui/public/locales/es/chat.json @@ -584,7 +584,8 @@ "context": "Contexto", "contextOf": "{{used}} de {{limit}} tokens", "contextUsed": "{{used}} tokens", - "unknown": "Aún no se sabe" + "unknown": "Aún no se sabe", + "commandsUnknown": "Se desconoce el aislamiento de los comandos" }, "agentFolder": { "home": "Inicio", diff --git a/core/http/react-ui/public/locales/es/chats.json b/core/http/react-ui/public/locales/es/chats.json index 013a149e7..263e8a549 100644 --- a/core/http/react-ui/public/locales/es/chats.json +++ b/core/http/react-ui/public/locales/es/chats.json @@ -285,11 +285,15 @@ "hostOff": "El Sandbox está desactivado en este chat, así que los comandos se ejecutan en esta máquina, con sus archivos y su red, sin aislamiento. Activa el Sandbox en este menú para aislarlos.", "hostUnavailable": "El Sandbox no puede ejecutarse aquí, así que los comandos se ejecutan en esta máquina, con sus archivos y su red, sin aislamiento.", "hostUnavailableWhy": "El Sandbox no puede ejecutarse aquí ({{reason}}), así que los comandos se ejecutan en esta máquina, con sus archivos y su red, sin aislamiento.", - "hostChipTitle": "Los comandos de este chat se ejecutan en esta máquina, sin aislamiento. Selecciona para ver detalles." + "hostChipTitle": "Los comandos de este chat se ejecutan en esta máquina, sin aislamiento. Selecciona para ver detalles.", + "providerHost": "Los comandos se ejecutan en el servidor sin aislamiento.", + "unknown": "Se desconoce la disponibilidad o el aislamiento de la ejecución.", + "providerUnavailable": "El proveedor de ejecución seleccionado no está disponible.", + "disabled": "La ejecución de comandos está desactivada para este chat." }, "consent": { "title": "¿Ejecutar este comando en esta máquina?", - "body": "Los comandos se ejecutan en esta máquina sin aislamiento. Pueden leer los archivos que LocalAI puede leer y llegar a internet y a tu red. Instala el Sandbox para aislarlos.", + "body": "Los comandos se ejecutan en esta máquina sin aislamiento. Pueden leer los archivos que LocalAI puede leer y llegar a internet y a tu red.", "once": "Ejecutar una vez", "always": "Siempre en esta instancia", "deny": "No ejecutar", @@ -395,7 +399,9 @@ "allowedHostsOff": "Se aplican cuando la red está activada.", "allowedHostsNote": "Tu administrador permite siempre {{count}} hosts.", "limitNote": "Red de este chat. El administrador fija lo máximo que un chat puede alcanzar.", - "asksAbove": "Este chat pide {{choice}}; el límite del administrador es {{limit}}." + "asksAbove": "Este chat pide {{choice}}; el límite del administrador es {{limit}}.", + "unverified": "No se ha verificado la aplicación de la política de red para el proveedor real. Esta configuración no se aplica a sus comandos.", + "configuredPolicy": "Política de red configurada: {{preset}} (no aplicada)." }, "inbox": { "unread": "{{count}} sin leer", diff --git a/core/http/react-ui/public/locales/es/home.json b/core/http/react-ui/public/locales/es/home.json index 5583a4677..8afe400e0 100644 --- a/core/http/react-ui/public/locales/es/home.json +++ b/core/http/react-ui/public/locales/es/home.json @@ -117,6 +117,14 @@ "allChats": "Todos los chats" }, "slash": { + "commandDiagnostics": "Diagnóstico de comandos de aplicaciones", + "apps": { + "label": "Aplicaciones", + "desc": "Explorar aplicaciones instaladas" + }, + "group": { + "addons": "Aplicaciones" + }, "agents": { "label": "Agentes", "desc": "Tus agentes guardados, disparadores y habilidades" @@ -130,5 +138,6 @@ "desc": "Colecciones en las que pueden buscar tus chats" }, "complete": "completar" - } + }, + "notice": { "appUnavailable": "La aplicación ya no está disponible." } } diff --git a/core/http/react-ui/public/locales/fr/chat.json b/core/http/react-ui/public/locales/fr/chat.json index 96370cf17..5d7e37e72 100644 --- a/core/http/react-ui/public/locales/fr/chat.json +++ b/core/http/react-ui/public/locales/fr/chat.json @@ -582,7 +582,8 @@ "context": "Contexte", "contextOf": "{{used}} sur {{limit}} tokens", "contextUsed": "{{used}} tokens", - "unknown": "Pas encore connu" + "unknown": "Pas encore connu", + "commandsUnknown": "L’isolation des commandes est inconnue" }, "agentFolder": { "home": "Accueil", diff --git a/core/http/react-ui/public/locales/fr/chats.json b/core/http/react-ui/public/locales/fr/chats.json index c7950bf7a..05b6c4799 100644 --- a/core/http/react-ui/public/locales/fr/chats.json +++ b/core/http/react-ui/public/locales/fr/chats.json @@ -285,11 +285,15 @@ "hostOff": "Le Sandbox est désactivé pour ce chat : les commandes s'exécutent donc sur cette machine, avec ses fichiers et son réseau, sans isolation. Activez le Sandbox dans ce menu pour les isoler.", "hostUnavailable": "Le Sandbox ne peut pas s'exécuter ici : les commandes s'exécutent donc sur cette machine, avec ses fichiers et son réseau, sans isolation.", "hostUnavailableWhy": "Le Sandbox ne peut pas s'exécuter ici ({{reason}}) : les commandes s'exécutent donc sur cette machine, avec ses fichiers et son réseau, sans isolation.", - "hostChipTitle": "Les commandes de ce chat s'exécutent sur cette machine, sans isolation. Sélectionnez pour les détails." + "hostChipTitle": "Les commandes de ce chat s'exécutent sur cette machine, sans isolation. Sélectionnez pour les détails.", + "providerHost": "Les commandes s’exécutent sur le serveur sans isolation.", + "unknown": "La disponibilité ou l’isolation de l’exécution est inconnue.", + "providerUnavailable": "Le fournisseur d’exécution sélectionné est indisponible.", + "disabled": "L’exécution des commandes est désactivée pour cette discussion." }, "consent": { "title": "Exécuter cette commande sur cette machine ?", - "body": "Les commandes s'exécutent sur cette machine sans isolation. Elles peuvent lire les fichiers que LocalAI peut lire et joindre Internet et votre réseau. Installez le Sandbox pour les isoler.", + "body": "Les commandes s'exécutent sur cette machine sans isolation. Elles peuvent lire les fichiers que LocalAI peut lire et joindre Internet et votre réseau.", "once": "Exécuter une fois", "always": "Toujours sur cette instance", "deny": "Ne pas exécuter", @@ -395,7 +399,9 @@ "allowedHostsOff": "Ils s’appliquent quand le réseau est activé.", "allowedHostsNote": "{{count}} hôtes sont toujours autorisés par votre administrateur.", "limitNote": "Réseau de cette conversation. L’administrateur fixe le maximum qu’une conversation peut atteindre.", - "asksAbove": "Cette conversation demande {{choice}} ; la limite de l’administrateur est {{limit}}." + "asksAbove": "Cette conversation demande {{choice}} ; la limite de l’administrateur est {{limit}}.", + "unverified": "L’application de la politique réseau n’est pas vérifiée pour le fournisseur réel. Ces paramètres ne s’appliquent pas à ses commandes.", + "configuredPolicy": "Politique réseau configurée : {{preset}} (non appliquée)." }, "inbox": { "unread": "{{count}} non lues", diff --git a/core/http/react-ui/public/locales/fr/home.json b/core/http/react-ui/public/locales/fr/home.json index 0bd56ebc5..663711b09 100644 --- a/core/http/react-ui/public/locales/fr/home.json +++ b/core/http/react-ui/public/locales/fr/home.json @@ -120,6 +120,14 @@ "allChats": "Toutes les conversations" }, "slash": { + "commandDiagnostics": "Diagnostic des commandes d’applications", + "apps": { + "label": "Applications", + "desc": "Parcourir les applications installées" + }, + "group": { + "addons": "Applications" + }, "agents": { "label": "Agents", "desc": "Vos agents enregistrés, déclencheurs et compétences" @@ -133,5 +141,6 @@ "desc": "Collections que vos chats peuvent consulter" }, "complete": "compléter" - } + }, + "notice": { "appUnavailable": "L’application n’est plus disponible." } } diff --git a/core/http/react-ui/public/locales/id/chat.json b/core/http/react-ui/public/locales/id/chat.json index 1f7e41041..6236f294f 100644 --- a/core/http/react-ui/public/locales/id/chat.json +++ b/core/http/react-ui/public/locales/id/chat.json @@ -584,7 +584,8 @@ "context": "Konteks", "contextOf": "{{used}} dari {{limit}} token", "contextUsed": "{{used}} token", - "unknown": "Belum diketahui" + "unknown": "Belum diketahui", + "commandsUnknown": "Isolasi perintah tidak diketahui" }, "agentFolder": { "home": "Beranda", diff --git a/core/http/react-ui/public/locales/id/chats.json b/core/http/react-ui/public/locales/id/chats.json index 43fc4e396..0ca09fe66 100644 --- a/core/http/react-ui/public/locales/id/chats.json +++ b/core/http/react-ui/public/locales/id/chats.json @@ -285,11 +285,15 @@ "hostOff": "Sandbox mati untuk chat ini, jadi perintah berjalan di mesin ini, dengan berkas dan jaringannya, tanpa isolasi. Nyalakan Sandbox di menu ini untuk mengisolasinya.", "hostUnavailable": "Sandbox tidak bisa berjalan di sini, jadi perintah berjalan di mesin ini, dengan berkas dan jaringannya, tanpa isolasi.", "hostUnavailableWhy": "Sandbox tidak bisa berjalan di sini ({{reason}}), jadi perintah berjalan di mesin ini, dengan berkas dan jaringannya, tanpa isolasi.", - "hostChipTitle": "Perintah di chat ini berjalan di mesin ini, tanpa isolasi. Pilih untuk detail." + "hostChipTitle": "Perintah di chat ini berjalan di mesin ini, tanpa isolasi. Pilih untuk detail.", + "providerHost": "Perintah dijalankan di server tanpa isolasi.", + "unknown": "Kesiapan atau isolasi eksekusi tidak diketahui.", + "providerUnavailable": "Penyedia eksekusi yang dipilih tidak tersedia.", + "disabled": "Eksekusi perintah dinonaktifkan untuk percakapan ini." }, "consent": { "title": "Jalankan perintah ini di mesin ini?", - "body": "Perintah berjalan di mesin ini tanpa isolasi. Perintah dapat membaca berkas yang bisa dibaca LocalAI dan menjangkau internet serta jaringan Anda. Pasang Sandbox untuk isolasi.", + "body": "Perintah berjalan di mesin ini tanpa isolasi. Perintah dapat membaca berkas yang bisa dibaca LocalAI dan menjangkau internet serta jaringan Anda.", "once": "Jalankan sekali", "always": "Selalu di instans ini", "deny": "Jangan jalankan", @@ -395,7 +399,9 @@ "allowedHostsOff": "Berlaku saat jaringan aktif.", "allowedHostsNote": "{{count}} host selalu diizinkan oleh administrator Anda.", "limitNote": "Jaringan chat ini. Administrator menetapkan batas maksimum yang dapat dijangkau sebuah chat.", - "asksAbove": "Chat ini meminta {{choice}}; batas administrator adalah {{limit}}." + "asksAbove": "Chat ini meminta {{choice}}; batas administrator adalah {{limit}}.", + "unverified": "Penerapan kebijakan jaringan untuk penyedia sebenarnya belum diverifikasi. Pengaturan ini tidak berlaku untuk perintahnya.", + "configuredPolicy": "Kebijakan jaringan terkonfigurasi: {{preset}} (tidak diterapkan)." }, "inbox": { "unread": "{{count}} belum dibaca", diff --git a/core/http/react-ui/public/locales/id/home.json b/core/http/react-ui/public/locales/id/home.json index e31c62632..b8573dbbc 100644 --- a/core/http/react-ui/public/locales/id/home.json +++ b/core/http/react-ui/public/locales/id/home.json @@ -117,6 +117,14 @@ "allChats": "Semua obrolan" }, "slash": { + "commandDiagnostics": "Diagnostik perintah aplikasi", + "apps": { + "label": "Aplikasi", + "desc": "Jelajahi aplikasi terpasang" + }, + "group": { + "addons": "Aplikasi" + }, "agents": { "label": "Agen", "desc": "Agen tersimpan, pemicu, dan keahlian Anda" @@ -130,5 +138,6 @@ "desc": "Koleksi yang bisa dicari obrolan Anda" }, "complete": "lengkapi" - } + }, + "notice": { "appUnavailable": "Aplikasi tidak lagi tersedia." } } diff --git a/core/http/react-ui/public/locales/it/chat.json b/core/http/react-ui/public/locales/it/chat.json index 0c4b519a1..ce1b50c6c 100644 --- a/core/http/react-ui/public/locales/it/chat.json +++ b/core/http/react-ui/public/locales/it/chat.json @@ -584,7 +584,8 @@ "context": "Contesto", "contextOf": "{{used}} di {{limit}} token", "contextUsed": "{{used}} token", - "unknown": "Non ancora noto" + "unknown": "Non ancora noto", + "commandsUnknown": "L’isolamento dei comandi è sconosciuto" }, "agentFolder": { "home": "Home", diff --git a/core/http/react-ui/public/locales/it/chats.json b/core/http/react-ui/public/locales/it/chats.json index 0881da92f..1fb1b4d77 100644 --- a/core/http/react-ui/public/locales/it/chats.json +++ b/core/http/react-ui/public/locales/it/chats.json @@ -285,11 +285,15 @@ "hostOff": "Il Sandbox è spento per questa chat, quindi i comandi vengono eseguiti su questa macchina, con i suoi file e la sua rete, senza isolamento. Accendi il Sandbox in questo menu per isolarli.", "hostUnavailable": "Il Sandbox non può funzionare qui, quindi i comandi vengono eseguiti su questa macchina, con i suoi file e la sua rete, senza isolamento.", "hostUnavailableWhy": "Il Sandbox non può funzionare qui ({{reason}}), quindi i comandi vengono eseguiti su questa macchina, con i suoi file e la sua rete, senza isolamento.", - "hostChipTitle": "I comandi di questa chat vengono eseguiti su questa macchina, senza isolamento. Seleziona per i dettagli." + "hostChipTitle": "I comandi di questa chat vengono eseguiti su questa macchina, senza isolamento. Seleziona per i dettagli.", + "providerHost": "I comandi vengono eseguiti sul server senza isolamento.", + "unknown": "La disponibilità o l’isolamento dell’esecuzione non sono noti.", + "providerUnavailable": "Il provider di esecuzione selezionato non è disponibile.", + "disabled": "L’esecuzione dei comandi è disattivata per questa chat." }, "consent": { "title": "Eseguire questo comando su questa macchina?", - "body": "I comandi vengono eseguiti su questa macchina senza isolamento. Possono leggere i file che LocalAI può leggere e raggiungere internet e la tua rete. Installa il Sandbox per isolarli.", + "body": "I comandi vengono eseguiti su questa macchina senza isolamento. Possono leggere i file che LocalAI può leggere e raggiungere internet e la tua rete.", "once": "Esegui una volta", "always": "Sempre su questa istanza", "deny": "Non eseguire", @@ -395,7 +399,9 @@ "allowedHostsOff": "Valgono quando la rete è attiva.", "allowedHostsNote": "{{count}} host sono sempre consentiti dal tuo amministratore.", "limitNote": "Rete di questa chat. L’amministratore imposta il massimo che una chat può raggiungere.", - "asksAbove": "Questa chat chiede {{choice}}; il limite dell’amministratore è {{limit}}." + "asksAbove": "Questa chat chiede {{choice}}; il limite dell’amministratore è {{limit}}.", + "unverified": "L’applicazione dei criteri di rete non è verificata per il provider effettivo. Queste impostazioni non si applicano ai suoi comandi.", + "configuredPolicy": "Criterio di rete configurato: {{preset}} (non applicato)." }, "inbox": { "unread": "{{count}} non lette", diff --git a/core/http/react-ui/public/locales/it/home.json b/core/http/react-ui/public/locales/it/home.json index 086409f40..41c5e1fa5 100644 --- a/core/http/react-ui/public/locales/it/home.json +++ b/core/http/react-ui/public/locales/it/home.json @@ -117,6 +117,14 @@ "allChats": "Tutte le chat" }, "slash": { + "commandDiagnostics": "Diagnostica dei comandi delle app", + "apps": { + "label": "App", + "desc": "Sfoglia le app installate" + }, + "group": { + "addons": "App" + }, "agents": { "label": "Agenti", "desc": "I tuoi agenti salvati, trigger e skill" @@ -130,5 +138,6 @@ "desc": "Raccolte in cui le tue chat possono cercare" }, "complete": "completa" - } + }, + "notice": { "appUnavailable": "L’app non è più disponibile." } } diff --git a/core/http/react-ui/public/locales/ko/chat.json b/core/http/react-ui/public/locales/ko/chat.json index 061b0f5b9..c782bf847 100644 --- a/core/http/react-ui/public/locales/ko/chat.json +++ b/core/http/react-ui/public/locales/ko/chat.json @@ -584,7 +584,8 @@ "context": "컨텍스트", "contextOf": "{{limit}} 토큰 중 {{used}}", "contextUsed": "{{used}} 토큰", - "unknown": "아직 알 수 없음" + "unknown": "아직 알 수 없음", + "commandsUnknown": "명령 격리 상태를 알 수 없습니다" }, "agentFolder": { "home": "홈", diff --git a/core/http/react-ui/public/locales/ko/chats.json b/core/http/react-ui/public/locales/ko/chats.json index 784a72591..1686a73e3 100644 --- a/core/http/react-ui/public/locales/ko/chats.json +++ b/core/http/react-ui/public/locales/ko/chats.json @@ -285,11 +285,15 @@ "hostOff": "이 채팅에서 Sandbox가 꺼져 있어 명령이 이 컴퓨터의 파일과 네트워크를 쓰며 격리 없이 실행됩니다. 격리하려면 이 메뉴에서 Sandbox를 켜세요.", "hostUnavailable": "여기서는 Sandbox를 실행할 수 없어 명령이 이 컴퓨터의 파일과 네트워크를 쓰며 격리 없이 실행됩니다.", "hostUnavailableWhy": "여기서는 Sandbox를 실행할 수 없어({{reason}}) 명령이 이 컴퓨터의 파일과 네트워크를 쓰며 격리 없이 실행됩니다.", - "hostChipTitle": "이 채팅의 명령은 격리 없이 이 컴퓨터에서 실행됩니다. 자세히 보려면 선택하세요." + "hostChipTitle": "이 채팅의 명령은 격리 없이 이 컴퓨터에서 실행됩니다. 자세히 보려면 선택하세요.", + "providerHost": "명령은 격리 없이 서버에서 실행됩니다.", + "unknown": "실행 준비 상태 또는 격리 여부를 알 수 없습니다.", + "providerUnavailable": "선택한 실행 공급자를 사용할 수 없습니다.", + "disabled": "이 채팅의 명령 실행이 꺼져 있습니다." }, "consent": { "title": "이 명령을 이 컴퓨터에서 실행할까요?", - "body": "명령은 격리 없이 이 컴퓨터에서 실행됩니다. LocalAI가 읽을 수 있는 파일을 읽고 인터넷과 사용자 네트워크에 접속할 수 있습니다. 격리하려면 Sandbox를 설치하세요.", + "body": "명령은 격리 없이 이 컴퓨터에서 실행됩니다. LocalAI가 읽을 수 있는 파일을 읽고 인터넷과 사용자 네트워크에 접속할 수 있습니다.", "once": "한 번 실행", "always": "이 인스턴스에서 항상", "deny": "실행 안 함", @@ -395,7 +399,9 @@ "allowedHostsOff": "네트워크가 켜져 있을 때 적용됩니다.", "allowedHostsNote": "관리자가 호스트 {{count}}개를 항상 허용합니다.", "limitNote": "이 채팅의 네트워크입니다. 채팅이 접근할 수 있는 최대 범위는 관리자가 정합니다.", - "asksAbove": "이 채팅은 {{choice}}을(를) 요청합니다. 관리자 제한은 {{limit}}입니다." + "asksAbove": "이 채팅은 {{choice}}을(를) 요청합니다. 관리자 제한은 {{limit}}입니다.", + "unverified": "실제 공급자에 대한 네트워크 정책 적용이 확인되지 않았습니다. 이 설정은 해당 명령에 적용되지 않습니다.", + "configuredPolicy": "설정된 네트워크 정책: {{preset}} (적용되지 않음)." }, "inbox": { "unread": "읽지 않음 {{count}}개", diff --git a/core/http/react-ui/public/locales/ko/home.json b/core/http/react-ui/public/locales/ko/home.json index 0b51eade6..1d256d61b 100644 --- a/core/http/react-ui/public/locales/ko/home.json +++ b/core/http/react-ui/public/locales/ko/home.json @@ -117,6 +117,14 @@ "allChats": "모든 채팅" }, "slash": { + "commandDiagnostics": "앱 명령 진단", + "apps": { + "label": "앱", + "desc": "설치된 앱 찾아보기" + }, + "group": { + "addons": "앱" + }, "agents": { "label": "에이전트", "desc": "저장된 에이전트, 트리거, 스킬" @@ -130,5 +138,6 @@ "desc": "채팅이 검색할 수 있는 컬렉션" }, "complete": "완성" - } + }, + "notice": { "appUnavailable": "앱을 더 이상 사용할 수 없습니다." } } diff --git a/core/http/react-ui/public/locales/pt-BR/chat.json b/core/http/react-ui/public/locales/pt-BR/chat.json index c46d9b353..8fbe6ce52 100644 --- a/core/http/react-ui/public/locales/pt-BR/chat.json +++ b/core/http/react-ui/public/locales/pt-BR/chat.json @@ -587,7 +587,8 @@ "context": "Contexto", "contextOf": "{{used}} de {{limit}} tokens", "contextUsed": "{{used}} tokens", - "unknown": "Ainda não se sabe" + "unknown": "Ainda não se sabe", + "commandsUnknown": "O isolamento dos comandos é desconhecido" }, "agentFolder": { "home": "Início", diff --git a/core/http/react-ui/public/locales/pt-BR/chats.json b/core/http/react-ui/public/locales/pt-BR/chats.json index 59d1808f9..455155e0e 100644 --- a/core/http/react-ui/public/locales/pt-BR/chats.json +++ b/core/http/react-ui/public/locales/pt-BR/chats.json @@ -285,11 +285,15 @@ "hostOff": "O Sandbox está desligado neste chat, então os comandos rodam nesta máquina, com os arquivos e a rede dela, sem isolamento. Ligue o Sandbox neste menu para isolá-los.", "hostUnavailable": "O Sandbox não consegue rodar aqui, então os comandos rodam nesta máquina, com os arquivos e a rede dela, sem isolamento.", "hostUnavailableWhy": "O Sandbox não consegue rodar aqui ({{reason}}), então os comandos rodam nesta máquina, com os arquivos e a rede dela, sem isolamento.", - "hostChipTitle": "Os comandos deste chat rodam nesta máquina, sem isolamento. Selecione para ver detalhes." + "hostChipTitle": "Os comandos deste chat rodam nesta máquina, sem isolamento. Selecione para ver detalhes.", + "providerHost": "Os comandos são executados no servidor sem isolamento.", + "unknown": "A disponibilidade ou o isolamento da execução é desconhecido.", + "providerUnavailable": "O provedor de execução selecionado está indisponível.", + "disabled": "A execução de comandos está desativada para esta conversa." }, "consent": { "title": "Rodar este comando nesta máquina?", - "body": "Os comandos rodam nesta máquina sem isolamento. Eles podem ler os arquivos que o LocalAI lê e acessar a internet e a sua rede. Instale o Sandbox para isolá-los.", + "body": "Os comandos rodam nesta máquina sem isolamento. Eles podem ler os arquivos que o LocalAI lê e acessar a internet e a sua rede.", "once": "Rodar uma vez", "always": "Sempre nesta instância", "deny": "Não rodar", @@ -395,7 +399,9 @@ "allowedHostsOff": "Eles valem quando a rede está ligada.", "allowedHostsNote": "{{count}} hosts são sempre permitidos pelo seu administrador.", "limitNote": "Rede deste chat. O administrador define o máximo que um chat pode alcançar.", - "asksAbove": "Este chat pede {{choice}}; o limite do administrador é {{limit}}." + "asksAbove": "Este chat pede {{choice}}; o limite do administrador é {{limit}}.", + "unverified": "A aplicação da política de rede não foi verificada para o provedor real. Estas configurações não se aplicam aos comandos dele.", + "configuredPolicy": "Política de rede configurada: {{preset}} (não aplicada)." }, "inbox": { "unread": "{{count}} não lidas", diff --git a/core/http/react-ui/public/locales/pt-BR/home.json b/core/http/react-ui/public/locales/pt-BR/home.json index 19a21a43d..daf7e8ddf 100644 --- a/core/http/react-ui/public/locales/pt-BR/home.json +++ b/core/http/react-ui/public/locales/pt-BR/home.json @@ -132,6 +132,14 @@ "allChats": "Todos os chats" }, "slash": { + "commandDiagnostics": "Diagnóstico de comandos de aplicativos", + "apps": { + "label": "Aplicativos", + "desc": "Explorar aplicativos instalados" + }, + "group": { + "addons": "Aplicativos" + }, "agents": { "label": "Agentes", "desc": "Seus agentes salvos, gatilhos e habilidades" @@ -145,5 +153,6 @@ "desc": "Coleções em que seus chats podem pesquisar" }, "complete": "completar" - } + }, + "notice": { "appUnavailable": "O aplicativo não está mais disponível." } } diff --git a/core/http/react-ui/public/locales/zh-CN/chat.json b/core/http/react-ui/public/locales/zh-CN/chat.json index 5ba9c9094..b3071578f 100644 --- a/core/http/react-ui/public/locales/zh-CN/chat.json +++ b/core/http/react-ui/public/locales/zh-CN/chat.json @@ -584,7 +584,8 @@ "context": "上下文", "contextOf": "{{used}} / {{limit}} 个 token", "contextUsed": "{{used}} 个 token", - "unknown": "尚不清楚" + "unknown": "尚不清楚", + "commandsUnknown": "命令隔离状态未知" }, "agentFolder": { "home": "主页", diff --git a/core/http/react-ui/public/locales/zh-CN/chats.json b/core/http/react-ui/public/locales/zh-CN/chats.json index c4c7f1197..e9362528f 100644 --- a/core/http/react-ui/public/locales/zh-CN/chats.json +++ b/core/http/react-ui/public/locales/zh-CN/chats.json @@ -285,11 +285,15 @@ "hostOff": "此聊天已关闭 Sandbox,因此命令在这台机器上运行,可访问其文件和网络,没有隔离。在此菜单中打开 Sandbox 以隔离运行。", "hostUnavailable": "Sandbox 无法在此运行,因此命令在这台机器上运行,可访问其文件和网络,没有隔离。", "hostUnavailableWhy": "Sandbox 无法在此运行({{reason}}),因此命令在这台机器上运行,可访问其文件和网络,没有隔离。", - "hostChipTitle": "此聊天中的命令在这台机器上运行,没有隔离。选择以查看详情。" + "hostChipTitle": "此聊天中的命令在这台机器上运行,没有隔离。选择以查看详情。", + "providerHost": "命令在服务器上运行,未隔离。", + "unknown": "执行就绪状态或隔离状态未知。", + "providerUnavailable": "所选执行提供程序不可用。", + "disabled": "此聊天已关闭命令执行。" }, "consent": { "title": "在这台机器上运行此命令?", - "body": "命令在这台机器上运行,没有隔离。它们可以读取 LocalAI 能读取的文件,并访问互联网和你的网络。安装 Sandbox 以隔离运行。", + "body": "命令在这台机器上运行,没有隔离。它们可以读取 LocalAI 能读取的文件,并访问互联网和你的网络。", "once": "运行一次", "always": "在此实例上始终允许", "deny": "不运行", @@ -395,7 +399,9 @@ "allowedHostsOff": "网络开启时生效。", "allowedHostsNote": "管理员始终允许 {{count}} 个主机。", "limitNote": "此对话的网络。管理员决定对话最多能访问的范围。", - "asksAbove": "此对话请求“{{choice}}”;管理员的限制是“{{limit}}”。" + "asksAbove": "此对话请求“{{choice}}”;管理员的限制是“{{limit}}”。", + "unverified": "尚未验证实际提供程序是否实施网络策略。这些设置目前不适用于其命令。", + "configuredPolicy": "已配置的网络策略:{{preset}}(未实施)。" }, "inbox": { "unread": "{{count}} 条未读", diff --git a/core/http/react-ui/public/locales/zh-CN/home.json b/core/http/react-ui/public/locales/zh-CN/home.json index 45f5fd8e0..6290fbf32 100644 --- a/core/http/react-ui/public/locales/zh-CN/home.json +++ b/core/http/react-ui/public/locales/zh-CN/home.json @@ -117,6 +117,14 @@ "allChats": "全部对话" }, "slash": { + "commandDiagnostics": "应用命令诊断", + "apps": { + "label": "应用", + "desc": "浏览已安装的应用" + }, + "group": { + "addons": "应用" + }, "agents": { "label": "智能体", "desc": "你保存的智能体、触发器和技能" @@ -130,5 +138,6 @@ "desc": "你的对话可以搜索的集合" }, "complete": "补全" - } + }, + "notice": { "appUnavailable": "应用已不可用。" } } diff --git a/core/http/react-ui/src/addons/ModelLaunch.jsx b/core/http/react-ui/src/addons/ModelLaunch.jsx new file mode 100644 index 000000000..b7ba8bd87 --- /dev/null +++ b/core/http/react-ui/src/addons/ModelLaunch.jsx @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +/* eslint-disable no-unused-vars -- JSX components */ +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useAddonRegistry } from './useAddonRegistry' +import { projectModelActions } from './modelActions' +import ActionMenu from '../components/ActionMenu' + +// Shared by the inspector, gallery Open and actual model detail renderer. +// Acquiring the list does not hold or load any addon module. +export default function ModelLaunch({ useCase, model, label, className = 'dk-chip' }) { + const registry = useAddonRegistry() + const navigate = useNavigate() + const { acquire, allowed } = registry + const [refused, setRefused] = useState(false) + useEffect(() => allowed ? acquire() : undefined, [acquire, allowed]) + useEffect(() => setRefused(false), [model, useCase.cap]) + if (useCase.route) return + const projection = projectModelActions(registry, useCase.cap, model) + const launch = async choice => { if (!await registry.navigateModelAction(choice)) setRefused(true) } + const status = refused ? 'stale' : projection.status + return + {projection.choices.length === 1 + ? + : projection.choices.length > 1 + ? <>{label} ({ key: c.addonId, label: c.label, onClick: () => launch(c) }))} /> + : `${s.addonId}: ${s.status}`).join(', ') || status}>{label}} + +} diff --git a/core/http/react-ui/src/addons/addresses.js b/core/http/react-ui/src/addons/addresses.js new file mode 100644 index 000000000..ca432e1bd --- /dev/null +++ b/core/http/react-ui/src/addons/addresses.js @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: MIT +// Page may include a declared nested suffix. Query and hash belong to the +// caller; model is always a value, never a route segment or query template. +export function addonPageAddress({ addon, page, model, search = '', hash = '' }) { + const params = new URLSearchParams(search) + if (model !== undefined && model !== null) params.set('model', model) + const query = params.toString() + return `/app/addons/${encodeURIComponent(addon)}/${page}${query ? `?${query}` : ''}${hash}` +} diff --git a/core/http/react-ui/src/addons/homeCommands.js b/core/http/react-ui/src/addons/homeCommands.js new file mode 100644 index 000000000..719e04924 --- /dev/null +++ b/core/http/react-ui/src/addons/homeCommands.js @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: MIT +// Installed DTOs only. No module loading, catalog data, or executable targets. +import { localizedText } from '../components/addons/addonState.js' +import { sdkAccepts, ICON_NAMES } from './sdk.js' +const name = value => typeof value === 'string' && /^[a-z][a-z0-9-]*$/.test(value) && value.length <= 64 +// Apps resolves omitted labels from an 80-character UI title, then appends +// " — " and a ValidName page (64 characters) for secondary destinations. +// Explicit manifest labels retain the backend's separate 60-character cap. +const maxResolvedLabelChars = 80 + 3 + 64 +const text = value => typeof value === 'string' && value.length > 0 && value.trim() === value && [...value].length <= maxResolvedLabelChars && !/[\p{Cc}\p{Cf}<>]/u.test(value) + +// contract.ValidIconPath accepts only these ASCII package paths and image types. +// PathEscape leaves that alphabet unchanged; encoded alternatives are not DTOs. +function iconPath(value) { + return value.length <= 256 && /^[a-zA-Z0-9_./-]+$/.test(value) && + value.split('/').every(part => part !== '' && part !== '.' && part !== '..') && + /\.(png|webp|jpe?g)$/i.test(value) +} +const object = value => value && typeof value === 'object' && !Array.isArray(value) +const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0 + +function assetBase(app) { + if (!name(app.id) || typeof app.ui?.entry !== 'string') return null + const prefix = `/api/addons/${encodeURIComponent(app.id)}/assets/` + const at = app.ui.entry.indexOf(prefix) + if (at < 0 || (at > 0 && !/^\/[a-zA-Z0-9_/-]*$/.test(app.ui.entry.slice(0, at)))) return null + const rest = app.ui.entry.slice(at + prefix.length) + if (!/^[a-f0-9]{64}\/(?:[a-zA-Z0-9_.-]+\/)*[a-zA-Z0-9_.-]+$/.test(rest) || rest.split('/').some(p => p === '..' || p === '.')) return null + return app.ui.entry.slice(0, at + prefix.length + 65) +} + +export function homeAppStatus(app) { + if (!app.enabled) return 'installed-disabled' + if (app.state === 'failed') return 'installed-failed' + if (app.state !== 'running') return app.state === 'starting' ? 'loading' : 'stopped' + if (!app.ui) return 'no-ui' + if (!object(app.ui) || !object(app.ui.sdk) || !sdkAccepts(app.ui.sdk)) return 'incompatible' + if (!Number.isSafeInteger(app.generation) || app.generation < 1 || !assetBase(app)) return 'invalid-metadata' + return 'ready' +} + +function declarations(app, language) { + const ui = app.ui + const commands = ui.homeCommands || [] + if (!Array.isArray(commands) || commands.length > 8 || !Array.isArray(ui.pages) || !ui.pages.every(name) || new Set(ui.pages).size !== ui.pages.length) return null + const ids = new Set() + const out = [] + for (const c of commands) { + if (!object(c) || Object.keys(c).some(k => !['id', 'alias', 'page', 'label', 'labels', 'icon'].includes(k)) || !name(c.id) || ids.has(c.id)) return null + ids.add(c.id) + if ((c.alias !== undefined && !name(c.alias)) || !name(c.page) || !ui.pages.includes(c.page) || !text(c.label)) return null + if (c.labels !== undefined && (!object(c.labels) || Object.entries(c.labels).some(([k, v]) => !/^[a-zA-Z][a-zA-Z0-9-]*$/.test(k) || !text(v)))) return null + const base = assetBase(app) + if (typeof c.icon !== 'string' || (!ICON_NAMES.includes(c.icon) && !(c.icon.startsWith(base) && iconPath(c.icon.slice(base.length))))) return null + const canonical = `/addon:${app.id}:${c.id}` + out.push({ id: canonical.slice(1), addonId: app.id, commandId: c.id, generation: app.generation, entry: ui.entry, + page: c.page, label: localizedText({ title: c.label, titles: c.labels }, 'title', language), + icon: ICON_NAMES.includes(c.icon) ? c.icon : 'puzzle', iconUrl: ICON_NAMES.includes(c.icon) ? undefined : c.icon, + alias: c.alias, cmd: canonical, canonical, group: 'addons', kind: 'navigate', where: ['home'] }) + } + return out +} + +export function projectHomeCommands(snapshot, { nativeNames = [], skillNames = [], language = 'en' } = {}) { + const result = { status: 'ready', commands: [], diagnostics: [], states: [] } + if (!snapshot.allowed) return { ...result, status: snapshot.known === false ? 'loading' : 'forbidden' } + if (snapshot.status === 'error' || snapshot.error) return { ...result, status: 'list-error' } + if (snapshot.status !== 'ready') return { ...result, status: 'loading' } + const apps = Array.isArray(snapshot.apps) ? [...snapshot.apps] : [] + if (apps.some(a => !object(a) || !name(a.id))) return { ...result, status: 'invalid-metadata' } + apps.sort((a, b) => compare(a.id, b.id)) + const duplicate = new Set(apps.filter((a, i) => apps.some((b, j) => i !== j && a.id === b.id)).map(a => a.id)) + const all = [] + for (const app of apps) { + let status = duplicate.has(app.id) ? 'invalid-metadata' : homeAppStatus(app) + if (status === 'ready') { + const commands = declarations(app, language) + if (!commands) status = 'invalid-metadata' + else all.push(...commands) + } + result.states.push({ addonId: app.id, status }) + } + all.sort((a, b) => compare(a.addonId, b.addonId) || compare(a.commandId, b.commandId)) + const native = new Set(nativeNames), skills = new Set(skillNames) + for (const c of all) { + result.commands.push(c) + if (!c.alias) continue + const peers = all.filter(x => x.alias === c.alias) + const reason = native.has(c.alias) ? 'native-name' : skills.has(c.alias) ? 'skill-name' : peers.length > 1 ? 'addon-alias-collision' : null + if (reason) result.diagnostics.push({ alias: c.alias, addonId: c.addonId, commandId: c.commandId, reason, competing: peers.map(x => x.canonical).sort() }) + else result.commands.push({ ...c, id: `${c.id}:alias`, cmd: `/${c.alias}` }) + } + result.diagnostics.sort((a, b) => compare(a.alias, b.alias) || compare(a.addonId, b.addonId) || compare(a.commandId, b.commandId)) + if (!apps.length) result.status = 'absent' + return result +} + +// Captured menu entries are claims, never authority. Re-project the current +// installed snapshot and match its generation, immutable entry and destination. +export function resolveHomeCommand(input, snapshot, options) { + const cmd = typeof input === 'string' ? input.trim() : input?.cmd + if (typeof cmd !== 'string' || /\s/.test(cmd)) return null + const live = projectHomeCommands(snapshot, options).commands.find(c => c.cmd === cmd) + if (!live) return null + if (typeof input !== 'string' && (live.generation !== input.generation || live.entry !== input.entry || live.page !== input.page || live.addonId !== input.addonId || live.commandId !== input.commandId)) return null + return { ...live, path: `/app/addons/${encodeURIComponent(live.addonId)}/${encodeURIComponent(live.page)}` } +} + +// Shared by the registry and tests: only a fresh actor-filtered installed read +// can authorize navigation. No module is mounted by this dispatch function. +export async function dispatchHomeCommand(command, { refresh, current, navigate }, options) { + const actor = current().actor + const apps = await refresh() + const now = current() + if (!apps || !now.allowed || actor !== now.actor || now.list.apps !== apps) return false + const live = resolveHomeCommand(command, { ...now.list, allowed: true }, options) + if (!live) return false + navigate(live.path) + return true +} diff --git a/core/http/react-ui/src/addons/hostControlComponents.jsx b/core/http/react-ui/src/addons/hostControlComponents.jsx index 58afeb867..fd95f242a 100644 --- a/core/http/react-ui/src/addons/hostControlComponents.jsx +++ b/core/http/react-ui/src/addons/hostControlComponents.jsx @@ -29,7 +29,7 @@ export const HOST_CONTROLS = Object.freeze({ // machine get the host's own line, which needs no add-on. export function ChatToolsControls({ egress }) { const mode = networkMode(egress) - if (mode === 'host') return + if (mode === 'host' || mode === 'unverified') return if (mode !== 'row') return null const Row = HOST_CONTROLS[NETWORK_POLICY]['chat.tools'] return diff --git a/core/http/react-ui/src/addons/modelActions.js b/core/http/react-ui/src/addons/modelActions.js new file mode 100644 index 000000000..41f755446 --- /dev/null +++ b/core/http/react-ui/src/addons/modelActions.js @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MIT +// Installed metadata only: projecting launch actions never loads addon code. +import * as capabilities from '../utils/capabilities.js' +import { homeAppStatus } from './homeCommands.js' +import { localizedText } from '../components/addons/addonState.js' +import { addonPageAddress } from './addresses.js' +const supported = new Set(Object.entries(capabilities).filter(([k]) => k.startsWith('CAP_') && !['CAP_CHAT', 'CAP_COMPLETION'].includes(k)).map(([, v]) => v)) +const object = v => v && typeof v === 'object' && !Array.isArray(v) +const name = v => typeof v === 'string' && /^[a-z][a-z0-9-]{0,63}$/.test(v) +const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0 +function declarations(ui) { + if (!Array.isArray(ui.pages) || ui.pages.length > 32 || !ui.pages.every(name) || new Set(ui.pages).size !== ui.pages.length) return null + const actions = ui.modelActions ?? [] + if (!Array.isArray(actions) || actions.length > 27) return null + const seen = new Set() + for (const a of actions) { + if (!object(a) || Object.keys(a).some(k => !['capability', 'page', 'path'].includes(k)) || !supported.has(a.capability) || seen.has(a.capability) || !name(a.page) || !ui.pages.includes(a.page)) return null + seen.add(a.capability) + if (a.path !== undefined && (typeof a.path !== 'string' || !a.path)) return null + const path = a.page + (a.path === undefined ? '' : `/${a.path}`) + if (path.length > 256 || path.split('/').length > 8 || path.split('/').some(s => !/^[A-Za-z0-9._~-]{1,64}$/.test(s) || s === '.' || s === '..')) return null + } + return actions +} +export function projectModelActions(snapshot, capability, model) { + const result = { status: 'absent', choices: [], states: [] } + if (!snapshot.allowed) return { ...result, status: snapshot.known === false ? 'loading' : 'forbidden' } + if (snapshot.status === 'error' || snapshot.error) return { ...result, status: 'list-error' } + if (snapshot.status !== 'ready') return { ...result, status: 'loading' } + if (!supported.has(capability) || typeof model !== 'string') return result + const apps = Array.isArray(snapshot.apps) ? [...snapshot.apps] : [] + if (apps.some(a => !object(a) || !name(a.id))) return { ...result, status: 'invalid-metadata' } + apps.sort((a, b) => compare(a.id, b.id)) + for (const app of apps) { + const actions = object(app.ui) ? declarations(app.ui) : null + let status = homeAppStatus(app) + if (app.state === 'starting' && app.enabled) status = 'starting' + if (apps.filter(a => a.id === app.id).length > 1 || (app.ui && !actions)) status = 'invalid-metadata' + if (!actions?.some(a => a.capability === capability)) continue + result.states.push({ addonId: app.id, status }) + if (status !== 'ready') continue + const a = actions.find(a => a.capability === capability) + const path = a.path || '' + const title = localizedText(app, 'title', snapshot.language || 'en') + result.choices.push(Object.freeze({ actor: snapshot.actor, addonId: app.id, capability, page: a.page, path, + generation: app.generation, entry: app.ui.entry, model, label: title ? `${title} (${app.id})` : app.id, + url: addonPageAddress({ addon: app.id, page: a.page + (path ? `/${path}` : ''), model }) })) + } + result.status = result.choices.length ? 'ready' : result.states[0]?.status || 'absent' + return result +} +export async function dispatchModelAction(captured, { refresh, current, navigate, sessionCurrent = () => true }) { + const actor = current().actor + if (!sessionCurrent() || !captured || captured.actor !== actor || !current().allowed) return false + const apps = await refresh() + const now = current() + if (!sessionCurrent() || !apps || !now.allowed || actor !== now.actor || now.list.apps !== apps) return false + const live = projectModelActions({ ...now.list, allowed: true, actor }, captured.capability, captured.model).choices.find(c => c.addonId === captured.addonId) + if (!live || ['page', 'path', 'generation', 'entry', 'capability', 'model', 'url'].some(k => live[k] !== captured[k])) return false + navigate(live.url) + return true +} diff --git a/core/http/react-ui/src/addons/registry.jsx b/core/http/react-ui/src/addons/registry.jsx index 6b627b3fe..c4f71f51e 100644 --- a/core/http/react-ui/src/addons/registry.jsx +++ b/core/http/react-ui/src/addons/registry.jsx @@ -19,6 +19,9 @@ import { RegistryContext } from './registryContext' import AddonErrorBoundary from './AddonErrorBoundary' import ApprovalFrame from './ApprovalFrame' import { actionArguments, proposalIdOf, rendererFor, resultText } from './slots' +import { createRegistryRequests } from './registryRequests' +import { dispatchHomeCommand } from './homeCommands' +import { dispatchModelAction } from './modelActions' import './shell.css' // The add-on registry of the shell. It polls the apps list while something @@ -38,7 +41,7 @@ function guarded(build) { } export function AddonRegistryProvider({ children, api = addonsApi }) { - const { hasFeature, user, isAdmin } = useAuth() + const { hasFeature, user, isAdmin, sessionEpoch, captureSessionAuthority } = useAuth() const navigate = useNavigate() const location = useLocation() const { t, i18n } = useTranslation('addons') @@ -56,6 +59,14 @@ export function AddonRegistryProvider({ children, api = addonsApi }) { }, []) const allowed = hasFeature(FEATURE_ADDONS) && serverOn === true const [list, setList] = useState(EMPTY) + const requests = useMemo(() => createRegistryRequests(), []) + // The opaque auth epoch also changes on same-user refresh. Invalidate reads + // and pending Home dispatch synchronously, before effect cleanup can run. + const actor = `${user?.id || ''}:${allowed}:${isAdmin}:${sessionEpoch}` + requests.setScope(actor) + const authority = useRef({ actor, allowed, list: EMPTY }) + if (authority.current.actor !== actor) authority.current = { actor, allowed, list: EMPTY } + authority.current.allowed = allowed const [consumers, setConsumers] = useState(0) const [layers, setLayers] = useState([]) const navigateRef = useRef(navigate) @@ -113,62 +124,60 @@ export function AddonRegistryProvider({ children, api = addonsApi }) { const params = new URLSearchParams(location.search) const appsTabOwnsList = location.pathname.replace(/\/+$/, '') === '/app/addons' && !resolveManage(params, isAdmin) && resolveTab(params.get('tab'), isAdmin) === 'apps' - const ingest = useCallback((apps) => { - setList({ status: 'ready', apps, error: null }) - manager.revalidate(apps) + const publish = useCallback((next) => { + authority.current.list = next + setList(next) + manager.revalidate(next.apps) + if (next.status === 'error') { manager.disposeAll(); setLayers([]) } }, [manager]) + const ingest = useCallback((apps) => { + if (authority.current.actor === actor && authority.current.allowed) publish({ status: 'ready', apps, error: null }) + }, [publish, actor]) - // One poll chain at a time, backing off on failure and slower when hidden. - const refresh = useCallback(async () => { - try { - const apps = await api.apps.list() - setList({ status: 'ready', apps, error: null }) - manager.revalidate(apps) - return apps - } catch (error) { - // Signed out elsewhere (another tab, an expired session): nothing the - // person read may stay drawn or cached, so every add-on is withdrawn. - if (error && error.kind === 'auth') { - manager.disposeAll() - setLayers([]) - setList({ status: 'error', apps: [], error }) - return null - } - setList(prev => ({ status: prev.status === 'ready' ? 'ready' : 'error', apps: prev.apps, error })) - return null - } - }, [api, manager]) + const refresh = useCallback(async (signal) => { + if (!authority.current.allowed) return null + return requests.read(s => api.apps.list(s), publish, signal) + }, [api, publish, requests]) - // Off the Add-ons pages the chain runs only while an add-on contributes UI - // (so that its disable, crash or update is seen) and otherwise stops with no - // timer left. Entering /app/addons or a changed feature restarts it. + // Navigation always reads the authenticated installed view again. Even a + // response racing logout or a newer request cannot authorize this click. + const navigateHomeCommand = useCallback((command, options) => dispatchHomeCommand(command, { + refresh, current: () => authority.current, navigate: path => navigateRef.current(path), + }, options), [refresh]) + + const navigateModelAction = useCallback(command => dispatchModelAction(command, { + sessionCurrent: captureSessionAuthority(), refresh, current: () => authority.current, navigate: path => navigateRef.current(path), + }), [refresh, captureSessionAuthority]) + + // Mounted consumers keep a bounded chain alive even after disable/failure. + // The same reference count and cadence serve Home, slots and navigation. const pathname = location.pathname.replace(/\/+$/, '') const onAddonsPage = pathname === '/app/addons' || pathname.startsWith('/app/addons/') const polling = shouldPoll({ allowed, onAddonsPage, apps: list.apps, status: list.status, consumers }) useEffect(() => { if (!polling || appsTabOwnsList) return undefined + const ctl = new AbortController() let alive = true let failures = 0 let timer = null const run = async () => { - const apps = await refresh() + const apps = await refresh(ctl.signal) if (!alive) return failures = apps === null ? failures + 1 : 0 timer = setTimeout(run, pollDelay(failures, document.hidden)) } tick.current = () => { clearTimeout(timer); if (alive) run() } run() - return () => { alive = false; clearTimeout(timer); tick.current = null } - }, [polling, refresh, appsTabOwnsList]) + return () => { alive = false; ctl.abort(); clearTimeout(timer); tick.current = null } + }, [polling, refresh, appsTabOwnsList, actor]) - // A different user, or a user who lost the feature, never keeps another - // session's add-on code or caches. - const userId = user?.id || '' + // A changed actor or auth session never keeps old add-on code or caches. useEffect(() => { manager.disposeAll() setLayers([]) + authority.current.list = EMPTY setList(EMPTY) - }, [manager, userId, allowed]) + }, [manager, actor]) // Layers of a withdrawn add-on close with it. useEffect(() => { @@ -181,7 +190,7 @@ export function AddonRegistryProvider({ children, api = addonsApi }) { }) }, [instances]) - useEffect(() => () => manager.disposeAll(), [manager]) + useEffect(() => () => { requests.setScope(Symbol('unmounted')); manager.disposeAll() }, [manager, requests]) stateRef.current = { apps: list.apps, instances } @@ -250,13 +259,16 @@ export function AddonRegistryProvider({ children, api = addonsApi }) { const value = useMemo(() => ({ // Whether the server said if add-ons are on: until then `allowed` is false // only because the answer is not in yet. + actor, known: serverOn !== null, - status: list.status, - apps: list.apps, - error: list.error, + status: authority.current.list.status, + apps: authority.current.list.apps, + error: authority.current.list.error, instances, allowed, refresh, + navigateHomeCommand, + navigateModelAction, ingest, acquire, language: i18n.language, @@ -271,7 +283,7 @@ export function AddonRegistryProvider({ children, api = addonsApi }) { // them leaves the host with no entries, never with a broken render. navigationItems: guarded(() => buildNavigation(list.apps, { language: i18n.language })), chatActions: guarded(() => availableChatActions(instances, list.apps, { hasFeature: () => allowed })), - }), [serverOn, list, instances, allowed, refresh, ingest, acquire, manager, i18n.language, runAction, pushLayer, presentApproval]) + }), [actor, serverOn, list, instances, allowed, refresh, navigateHomeCommand, navigateModelAction, ingest, acquire, manager, i18n.language, runAction, pushLayer, presentApproval]) const boundaryLabels = { title: t('shell.error.title'), text: t('shell.error.text'), retryLabel: t('shell.error.retry') } return ( diff --git a/core/http/react-ui/src/addons/registryRequests.js b/core/http/react-ui/src/addons/registryRequests.js new file mode 100644 index 000000000..3edd56fda --- /dev/null +++ b/core/http/react-ui/src/addons/registryRequests.js @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT +// Requests belong to an actor/permission epoch. Abort is only an optimization: +// an API that ignores the signal still cannot publish into a later epoch. +export function createRegistryRequests() { + let scope, epoch = 0, sequence = 0 + const pending = new Set() + return { + setScope(next) { + if (next === scope) return + scope = next + epoch++ + for (const ctl of pending) ctl.abort() + pending.clear() + }, + async read(load, publish, signal) { + const ownEpoch = epoch, ownSequence = ++sequence + const ctl = new AbortController() + const abort = () => ctl.abort() + signal?.addEventListener('abort', abort, { once: true }) + if (signal?.aborted) ctl.abort() + pending.add(ctl) + const current = () => !ctl.signal.aborted && ownEpoch === epoch && ownSequence === sequence + try { + const apps = await load(ctl.signal) + if (!current()) return null + publish({ status: 'ready', apps, error: null }) + return apps + } catch (error) { + if (current()) publish({ status: 'error', apps: [], error }) + return null + } finally { + pending.delete(ctl) + signal?.removeEventListener('abort', abort) + } + }, + } +} diff --git a/core/http/react-ui/src/addons/registryState.js b/core/http/react-ui/src/addons/registryState.js index fef4af8b4..1bc18664e 100644 --- a/core/http/react-ui/src/addons/registryState.js +++ b/core/http/react-ui/src/addons/registryState.js @@ -48,15 +48,13 @@ export function contributesUI(apps) { }) } -// Whether the registry reads the apps list now. Never without the feature. On -// an Add-ons page always. Anywhere else only while a listed app contributes UI, -// or once, to learn that, when a consumer needs a list that was never read (a -// failed read is not retried for a consumer: the next page load asks again). -export function shouldPoll({ allowed, onAddonsPage, apps, status, consumers }) { +// Mounted consumers keep reading while disabled, failed or temporarily +// unreachable, so re-enable and recovery do not require a page reload. +export function shouldPoll({ allowed, onAddonsPage, apps, consumers }) { if (!allowed) return false if (onAddonsPage) return true if (contributesUI(apps)) return true - return consumers > 0 && status === 'idle' + return consumers > 0 } // Pages an app contributes to navigation, only while it is usable. diff --git a/core/http/react-ui/src/addons/useAddonRegistry.js b/core/http/react-ui/src/addons/useAddonRegistry.js index 678bb6cf9..cc3dc0361 100644 --- a/core/http/react-ui/src/addons/useAddonRegistry.js +++ b/core/http/react-ui/src/addons/useAddonRegistry.js @@ -138,3 +138,12 @@ export function useAddonTitle(addonId) { const app = apps.find(a => a.id === addonId) return app ? (localizedText(app, 'title', language) || app.id) : '' } + +// Home holds the existing registry, not addon modules. Disabled and failed +// installations remain live consumers so recovery is observed automatically. +export function useAddonHomeCommands() { + const registry = useAddonRegistry() + const { acquire, allowed } = registry + useEffect(() => (allowed ? acquire() : undefined), [acquire, allowed]) + return registry +} diff --git a/core/http/react-ui/src/components/addons/AppsView.jsx b/core/http/react-ui/src/components/addons/AppsView.jsx index e3e37e194..cd7712108 100644 --- a/core/http/react-ui/src/components/addons/AppsView.jsx +++ b/core/http/react-ui/src/components/addons/AppsView.jsx @@ -38,23 +38,27 @@ import { useAddonActions } from './useAddonActions' // refuses everyone else whatever this view shows. export default function AppsView({ api, adapter, onSummary }) { const { t, i18n } = useTranslation('addons') - const [state, setState] = useState({ status: 'loading', apps: [], error: null, stale: false }) + const [storedState, setState] = useState({ status: 'loading', apps: [], error: null, stale: false }) const [reload, setReload] = useState(0) const reloadRef = useRef(() => {}) - const { hasFeature, isAdmin } = useAuth() + const { hasFeature, isAdmin, user, sessionEpoch } = useAuth() // Without the feature there is no list to read and the route would answer 403. const canList = hasFeature(FEATURE_ADDONS) // The add-on registry does not read the list again while this tab is open: // every list read here is handed to it. const registry = useContext(RegistryContext) - const ingestRef = useRef(null) - ingestRef.current = registry ? registry.ingest : null + const actor = JSON.stringify([user?.id, sessionEpoch, isAdmin, canList]) + const scope = useRef(actor) + scope.current = actor + const state = storedState.actor === actor ? storedState : { status: 'loading', apps: [], error: null, stale: false } + const ingest = registry?.ingest // Poll with a timer chain: one request at a time, aborted on unmount. A // refresh asked for while one is in flight runs right after it, so there is // never more than one chain. useEffect(() => { - if (!canList) { setState({ status: 'ready', apps: [], error: null, stale: false }); return undefined } + if (!canList) { setState({ actor, status: 'ready', apps: [], error: null, stale: false }); return undefined } + const current = () => scope.current === actor const ctl = new AbortController() let timer = null let failures = 0 @@ -67,19 +71,19 @@ export default function AppsView({ api, adapter, onSummary }) { try { const apps = await api.apps.list(ctl.signal) failures = 0 - if (alive) { - setState({ status: 'ready', apps, error: null, stale: false }) - ingestRef.current?.(apps) + if (alive && current()) { + setState({ actor, status: 'ready', apps, error: null, stale: false }) + ingest?.(apps) } } catch (error) { - if (!alive || (error && error.name === 'AbortError')) return + if (!alive || !current() || (error && error.name === 'AbortError')) return failures += 1 // Keep what was on screen and say it may be out of date. - setState(s => ({ status: s.status === 'ready' ? 'ready' : 'error', apps: s.apps, error, stale: true })) + setState(s => ({ actor, status: s.actor === actor && s.status === 'ready' ? 'ready' : 'error', apps: s.actor === actor ? s.apps : [], error, stale: true })) } finally { inflight = false } - if (!alive) return + if (!alive || !current()) return const wait = queued ? 0 : pollDelay(failures, typeof document !== 'undefined' && document.hidden) queued = false clearTimeout(timer) @@ -88,11 +92,12 @@ export default function AppsView({ api, adapter, onSummary }) { reloadRef.current = () => { if (inflight) { queued = true } else { clearTimeout(timer); tick() } } tick() return () => { alive = false; ctl.abort(); clearTimeout(timer) } - }, [api, reload, canList]) + }, [api, reload, canList, actor, ingest]) // The installed record of an add-on, for the administrator's Details in the // pane. A change reads it again. - const [installedInfo, setInstalledInfo] = useState({}) + const [installedRecords, setInstalledInfo] = useState({}) + const installedInfo = installedRecords.actor === actor ? installedRecords.value : {} const [installedReload, setInstalledReload] = useState(0) const actions = useAddonActions(api, { onSettled: () => { @@ -150,12 +155,16 @@ export default function AppsView({ api, adapter, onSummary }) { // Reads the library of every installed add-on again. Only reads: it never // applies anything, and one add-on that cannot be checked does not stop the // rest. - const [checkingAll, setCheckingAll] = useState(false) + const [checkingActor, setCheckingActor] = useState(null) + const checkingAll = checkingActor === actor const checkAll = useCallback(async () => { + const current = () => scope.current === actor + if (!current()) return const signal = aborter.current.signal - setCheckingAll(true) + setCheckingActor(actor) let failed = 0 for (const addon of installedList) { + if (!current()) return try { await api.updates.check(addon.id, { operationId: newOperationId() }, signal) } catch (error) { @@ -163,10 +172,11 @@ export default function AppsView({ api, adapter, onSummary }) { failed += 1 } } - setCheckingAll(false) + if (!current()) return + setCheckingActor(null) setInstalledReload(n => n + 1) addToast?.(failed > 0 ? t('updates.checkAllFailed', { count: failed }) : t('updates.checkAllDone', { count: installedList.length }), failed > 0 ? 'error' : 'info') - }, [api, installedList, addToast, t]) + }, [api, installedList, addToast, t, actor]) const nav = useNavigation() // Under the legacy switch the Build hub row draws the app.nav slot instead. @@ -202,11 +212,12 @@ export default function AppsView({ api, adapter, onSummary }) { useEffect(() => { if (!isAdmin || !canList || !appsKey) return undefined const ctl = new AbortController() + const current = () => scope.current === actor && !ctl.signal.aborted api.installed(ctl.signal) - .then(list => setInstalledInfo(Object.fromEntries(list.map(a => [a.id, a])))) + .then(list => { if (current()) setInstalledInfo({ actor, value: Object.fromEntries(list.map(a => [a.id, a])) }) }) .catch(() => { /* the pane says the source is not known */ }) return () => ctl.abort() - }, [api, isAdmin, canList, appsKey, installedReload]) + }, [api, isAdmin, canList, appsKey, installedReload, actor]) // Everything that has an update notice, for "Upgrade all": the owner's // record when the administrator can read it, with the reason when the @@ -224,7 +235,7 @@ export default function AppsView({ api, adapter, onSummary }) { runOne: async (item) => { if (!item.addon) return { ok: false, message: upgradeFailureWords('stale_installed', t) } const out = await upgrade.upgrade(item.addon) - return out.ok ? { ok: true } : { ok: false, message: upgradeFailureWords(out.message, t) } + return out?.ok ? { ok: true } : { ok: false, message: upgradeFailureWords(out?.message, t) } }, }) diff --git a/core/http/react-ui/src/components/addons/useAddonActions.js b/core/http/react-ui/src/components/addons/useAddonActions.js index e94d0fbd5..ab8b27816 100644 --- a/core/http/react-ui/src/components/addons/useAddonActions.js +++ b/core/http/react-ui/src/components/addons/useAddonActions.js @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useAuth } from '../../context/AuthContext' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createActionBook, effectOutcome } from './actionBook' import { runLifecycle, settleOperation } from './lifecycleFlow' import { runInstallAndTurnOn } from './installAndTurnOn' @@ -20,19 +21,46 @@ import { runInstallAndTurnOn } from './installAndTurnOn' // may still be applied, so no other action on that add-on is submitted until // `checkStatus(addonId)` reads it as final or the user calls `dismiss(addonId)`. export function useAddonActions(api, { onSettled, adapter } = {}) { + + const { user, isAdmin, sessionEpoch } = useAuth() + const actor = JSON.stringify([user?.id, isAdmin, sessionEpoch]) + const authority = useRef(null) + if (authority.current?.actor !== actor) { + authority.current = { actor, controller: new AbortController() } + } + const origin = authority.current + const current = useCallback(() => authority.current === origin && !origin.controller.signal.aborted, [origin]) const book = useRef(null) - if (book.current === null) book.current = createActionBook() - const abort = useRef(null) + if (book.current?.actor !== actor) { book.current = createActionBook(); book.current.actor = actor } + const guardedApi = useMemo(() => ({ ...api, operationStatus: (...args) => { + if (!current()) throw new DOMException('Aborted', 'AbortError') + return api.operationStatus(...args) + } }), [api, current]) const settled = useRef(onSettled) settled.current = onSettled - const [state, setState] = useState({}) - const [unknown, setUnknown] = useState({}) - const [flows, setFlows] = useState({}) + const [storedState, saveState] = useState({ actor, value: {} }) + const state = storedState.actor === actor ? storedState.value : {} + const setState = useCallback(value => { + if (!current()) return + saveState(old => ({ actor, value: typeof value === 'function' ? value(old.actor === actor ? old.value : {}) : value })) + }, [actor, current]) + const [storedUnknown, saveUnknown] = useState({ actor, value: {} }) + const unknown = storedUnknown.actor === actor ? storedUnknown.value : {} + const setUnknown = useCallback(value => { + if (!current()) return + saveUnknown(old => ({ actor, value: typeof value === 'function' ? value(old.actor === actor ? old.value : {}) : value })) + }, [actor, current]) + const [storedFlows, saveFlows] = useState({ actor, value: {} }) + const flows = storedFlows.actor === actor ? storedFlows.value : {} + const setFlows = useCallback(value => { + if (!current()) return + saveFlows(old => ({ actor, value: typeof value === 'function' ? value(old.actor === actor ? old.value : {}) : value })) + }, [actor, current]) useEffect(() => { - abort.current = new AbortController() - return () => abort.current.abort() - }, []) + if (origin.controller.signal.aborted) origin.controller = new AbortController() + return () => origin.controller.abort() + }, [origin]) const setUnknownFor = useCallback((addonId, value) => { setUnknown(u => { @@ -41,10 +69,11 @@ export function useAddonActions(api, { onSettled, adapter } = {}) { else delete next[addonId] return next }) - }, []) + }, [setUnknown]) // Turns a lifecycle outcome into the final result and updates the book. const conclude = useCallback(async (addonId, key, operationId, outcome, verify, signal) => { + if (!current()) return null let verified if (outcome === 'completed' && verify) { try { verified = Boolean(await verify(signal)) } catch (e) { @@ -52,6 +81,7 @@ export function useAddonActions(api, { onSettled, adapter } = {}) { verified = undefined // an unreadable list proves nothing either way } } + if (!current()) return null const final = effectOutcome(outcome, verified) if (final === 'unknown') { book.current.markUnknown(addonId, key) @@ -61,10 +91,11 @@ export function useAddonActions(api, { onSettled, adapter } = {}) { setUnknownFor(addonId, null) } return { outcome: final, operationId } - }, [setUnknownFor]) + }, [setUnknownFor, current]) const run = useCallback(async (key, { addonId, submit, verify }) => { - const signal = abort.current.signal + if (!current()) return null + const signal = origin.controller.signal const begun = book.current.begin(addonId, key, { verify }) if (begun.blocked) { const result = { outcome: 'blocked', operationId: begun.operationId } @@ -76,45 +107,52 @@ export function useAddonActions(api, { onSettled, adapter } = {}) { setState(s => ({ ...s, [key]: { phase: 'working', operationId } })) let result try { - const out = await runLifecycle(api, { operationId, addonId, signal, submit: () => submit(operationId, signal) }) + const out = await runLifecycle(guardedApi, { operationId, addonId, signal, submit: () => submit(operationId, signal) }) + if (!current()) return null result = await conclude(addonId, key, operationId, out.outcome, verify, signal) } catch (e) { - if (e && e.name === 'AbortError') return null + if (!current() || (e && e.name === 'AbortError')) return null const kind = e && e.kind ? e.kind : 'unavailable' // A refusal is final. An owner or network failure proved nothing, so the // identity is kept and a retry replays the same operation. if (kind === 'denied' || kind === 'invalid' || kind === 'auth') book.current.resolve(addonId, key) result = { outcome: kind === 'network' ? 'unavailable' : kind, operationId, error: e } } + if (!current()) return null setState(s => ({ ...s, [key]: result })) if (settled.current) settled.current(key, result) return result - }, [api, conclude, setUnknownFor]) + }, [guardedApi, conclude, setUnknownFor, current, setState, origin]) // Reads the unknown operation once, by its own identity. Never submits. const checkStatus = useCallback(async (addonId) => { + if (!current()) return null const held = book.current.pending(addonId) if (!held || !held.unknown) return null - const signal = abort.current.signal + if (!current()) return null + const signal = origin.controller.signal const { key, operationId } = held setState(s => ({ ...s, [key]: { phase: 'working', operationId } })) let result try { - const out = await settleOperation(api, { operationId, addonId, signal, waits: [] }) + const out = await settleOperation(guardedApi, { operationId, addonId, signal, waits: [] }) + if (!current()) return null result = await conclude(addonId, key, operationId, out.outcome, held.meta && held.meta.verify, signal) } catch (e) { - if (e && e.name === 'AbortError') return null + if (!current() || (e && e.name === 'AbortError')) return null // A lost read leaves the outcome as unknown. result = { outcome: e && e.kind === 'denied' ? 'denied' : 'unknown', operationId, error: e } } + if (!current()) return null setState(s => ({ ...s, [key]: result })) if (settled.current) settled.current(key, result) return result - }, [api, conclude]) + }, [guardedApi, conclude, current, setState, origin]) // The user gives up on an unknown outcome. The next action starts a new // operation; the page says the earlier one may still be applied. const dismiss = useCallback((addonId) => { + if (!current()) return null const held = book.current.pending(addonId) if (!held) return book.current.resolve(addonId, held.key) @@ -124,7 +162,7 @@ export function useAddonActions(api, { onSettled, adapter } = {}) { delete next[held.key] return next }) - }, [setUnknownFor]) + }, [setUnknownFor, current, setState]) // Install an add-on and turn it on as one action of the user. Each step is // an ordinary action with its own operation identity, so an unknown outcome @@ -133,9 +171,10 @@ export function useAddonActions(api, { onSettled, adapter } = {}) { // that failed and the outcome. `skipInstall` turns on an add-on that is // already installed (the retry after a failed turn on). const installAndTurnOn = useCallback((entry, options = {}) => { + if (!current()) return Promise.resolve(null) const flow = `flow:${entry.key}` return runInstallAndTurnOn({ run, api, adapter, mark: (value) => setFlows(f => ({ ...f, [flow]: value })) }, entry, options) - }, [run, api, adapter]) + }, [run, api, adapter, current, setFlows]) const clearFlow = useCallback((entry) => { setFlows(f => { @@ -143,7 +182,7 @@ export function useAddonActions(api, { onSettled, adapter } = {}) { delete next[`flow:${entry.key}`] return next }) - }, []) + }, [setFlows]) const clear = useCallback((key) => { setState(s => { @@ -151,7 +190,7 @@ export function useAddonActions(api, { onSettled, adapter } = {}) { delete next[key] return next }) - }, []) + }, [setState]) return { state, unknown, flows, run, installAndTurnOn, clearFlow, checkStatus, dismiss, clear, book: book.current } } diff --git a/core/http/react-ui/src/components/addons/useAddonUpgrade.js b/core/http/react-ui/src/components/addons/useAddonUpgrade.js index 821c5b24e..75569dff6 100644 --- a/core/http/react-ui/src/components/addons/useAddonUpgrade.js +++ b/core/http/react-ui/src/components/addons/useAddonUpgrade.js @@ -1,3 +1,4 @@ +import { useAuth } from '../../context/AuthContext' import { useCallback, useEffect, useRef, useState } from 'react' import { newOperationId } from '../catalog/catalogState' import { addonUpgrades } from '../../utils/addonUpgrades' @@ -14,16 +15,34 @@ import { useAddonUpgrades } from '../../hooks/useAddonUpgrades' // `onChanged` runs after a check or an upgrade ends, so the view can read the // installed records again. export function useAddonUpgrade(api, { onChanged } = {}) { - const [confirm, setConfirm] = useState(null) - const [checks, setChecks] = useState({}) + + const { user, isAdmin, sessionEpoch, captureSessionAuthority } = useAuth() + const actor = JSON.stringify([user?.id, isAdmin, sessionEpoch]) + const authority = useRef(null) + if (authority.current?.actor !== actor) { + authority.current = { actor, controller: new AbortController(), sessionCurrent: captureSessionAuthority() } + } + const origin = authority.current + const current = useCallback(() => authority.current === origin && origin.sessionCurrent() && !origin.controller.signal.aborted, [origin]) + const [storedConfirm, saveConfirm] = useState({ actor, value: null }) + const confirm = storedConfirm.actor === actor ? storedConfirm.value : null + const setConfirm = useCallback(value => { + if (!current()) return + saveConfirm(old => ({ actor, value: typeof value === 'function' ? value(old.actor === actor ? old.value : null) : value })) + }, [actor, current]) + const [storedChecks, saveChecks] = useState({ actor, value: {} }) + const checks = storedChecks.actor === actor ? storedChecks.value : {} + const setChecks = useCallback(value => { + if (!current()) return + saveChecks(old => ({ actor, value: typeof value === 'function' ? value(old.actor === actor ? old.value : {}) : value })) + }, [actor, current]) const upgrades = useAddonUpgrades() - const aborter = useRef(null) const changed = useRef(onChanged) changed.current = onChanged useEffect(() => { - aborter.current = new AbortController() - return () => aborter.current.abort() - }, []) + if (origin.controller.signal.aborted) origin.controller = new AbortController() + return () => origin.controller.abort() + }, [origin]) // The explicit upgrade: the digest of the installed package the page showed // and the digest of the candidate the owner's check resolved. The owner @@ -31,26 +50,30 @@ export function useAddonUpgrade(api, { onChanged } = {}) { // and resolves { ok, outcome, message }, where message is the key of the // words of a failure (see upgradeFailureWords). const upgrade = useCallback(async (addon) => { - const out = await addonUpgrades.start(api, addon) + if (!current()) return null + const out = await addonUpgrades.start(api, addon, { current: origin.sessionCurrent }) + if (!current()) return null if (changed.current) changed.current(addon) return out - }, [api]) + }, [api, current, origin]) // Asks the owner to read the library again. It never applies anything. const check = useCallback(async (addon) => { - const signal = aborter.current.signal + if (!current()) return null + const signal = origin.controller.signal setChecks(c => ({ ...c, [addon.id]: { phase: 'working' } })) try { const status = await api.updates.check(addon.id, { operationId: newOperationId() }, signal) + if (!current()) return null // The answer is kept so the card can say what the check found and // when, even when the records read again show no notice any more. setChecks(c => ({ ...c, [addon.id]: { phase: 'done', throttled: Boolean(status && status.throttled), status: status && typeof status === 'object' ? status : null } })) if (changed.current) changed.current(addon) } catch (error) { - if (error && error.name === 'AbortError') return + if (!current() || (error && error.name === 'AbortError')) return setChecks(c => ({ ...c, [addon.id]: { phase: 'error', error } })) } - }, [api]) + }, [api, current, setChecks, origin]) // The upgrade of one add-on the store follows, or null. const opOf = useCallback((id) => upgrades.live.find(op => op.addonId === id) || null, [upgrades.live]) diff --git a/core/http/react-ui/src/components/chat/ChatComposer.jsx b/core/http/react-ui/src/components/chat/ChatComposer.jsx index a922b2a17..d7aa51f87 100644 --- a/core/http/react-ui/src/components/chat/ChatComposer.jsx +++ b/core/http/react-ui/src/components/chat/ChatComposer.jsx @@ -7,9 +7,7 @@ import { seesImages } from '../../utils/chatVision' import { connectorRef } from '../../utils/chatMCP' import { networkMode } from '../../utils/chatEgress' import { gbLabel, gbNumber } from '../../utils/modelLedger' -import { SANDBOX_ADDON } from '../../utils/sandboxStatus' import { useModelFit } from '../../hooks/useModelFit' -import { useSandboxStatus } from '../../hooks/useSandboxStatus' import { useCommandsNotice } from '../../hooks/useCommandsNotice' import { fillStyle, hostMemory, memoryFigure } from '../home/memory' import HomeComposer from '../home/HomeComposer' @@ -25,7 +23,7 @@ import { agentFolderLine } from '../../utils/chatAgents' import { CommandsChip, CommandsNotice } from './CommandsNotice' import { ToolFallbacks } from './ToolFallbacks' import Button from '../ui/Button' -import { availableCommands, commandGroups, COMMAND_GROUPS, filterArgs, usableSkills } from '../../utils/slashCommands' +import { availableCommands, commandGroups, COMMAND_GROUPS, filterArgs, usableSkills, reservedNames } from '../../utils/slashCommands' import { CHAT_SLASH_ACTIONS } from './chatActions' import { SLASH_ACTIONS } from '../home/homeActions' @@ -87,7 +85,7 @@ export default function ChatComposer({ }) { const { t } = useTranslation('chat') const { t: tc } = useTranslation('chats') - const { isAdmin } = useAuth() + const { isAdmin, user } = useAuth() const { drafts, draftId, switches, models, modelsLoading, pickedAgent, effectiveModel, agents, agentId, setAgentId, setDraftModel, attach, paste } = composer const { text, files, setText, setFiles } = drafts @@ -96,16 +94,7 @@ export default function ChatComposer({ const modelFit = useModelFit({ openCount: fitOpenCount, names: modelNames }) const onPickerOpen = useCallback(() => setFitOpenCount(n => n + 1), []) - const sandbox = useSandboxStatus(switches.addons.some(a => a.id === SANDBOX_ADDON), chatId) - const commands = useCommandsNotice(switches.commands.state, switches.commands.reason, isAdmin) - // What the Tools picker says under an add-on: the Sandbox's state. - const addonNote = (a) => { - if (a.id !== SANDBOX_ADDON || !sandbox || sandbox.state === 'unknown') return {} - if (sandbox.state === 'starting') return { note: tc('tools.sandbox.starting', { seconds: Math.max(1, Math.round(sandbox.retryMs / 1000)) }) } - if (sandbox.state === 'cannot') return { usable: false, reason: sandbox.reason ? tc('tools.sandbox.cannotWhy', { reason: sandbox.reason }) : tc('tools.sandbox.cannot') } - if (sandbox.state === 'host') return { note: tc('tools.sandbox.host'), warn: true } - return { note: tc('tools.sandbox.isolated', { backend: sandbox.backend }) } - } + const commands = useCommandsNotice(switches.commands, isAdmin, [user, chatId, effectiveModel, agentId]) // Who serves a group of built-in tools in this chat: the built-in itself, or // an add-on that shadows a tool of the group (the server resolves it). const builtinNote = (b) => { @@ -127,13 +116,13 @@ export default function ChatComposer({ const shellNote = (b) => { if (b.usable === false) return commands.info ? { reason: tc(`tools.commands.${commands.info.textKey}`, { reason: commands.info.reason }) } : {} const by = (b.providers || {}).bash || b.provider - if (by === 'builtin') return { chip: tc('tools.shell.machine'), note: tc('tools.sandbox.host'), warn: true } - if (by === SANDBOX_ADDON) { - return sandbox?.state === 'host' - ? { chip: tc('tools.provider.sandbox'), note: tc('tools.sandbox.host'), warn: true } - : { chip: tc('tools.provider.sandbox'), note: tc('tools.shell.isolated') } - } - return by ? { chip: tc('tools.servedBy', { provider: by }) } : {} + const selected = switches.commands + const chip = by === 'builtin' ? tc('tools.shell.machine') : by ? tc('tools.servedBy', { provider: by }) : undefined + // Group discovery is not evidence of the selected provider's isolation. + if (!by || selected.provider !== by) return { chip, note: tc('tools.commands.unknown') } + if (['host', 'host-off', 'host-unavailable', 'provider-host'].includes(selected.state)) return { chip, note: tc('tools.commands.providerHost'), warn: true } + if (selected.state === 'available') return { chip, note: tc('tools.shell.isolated') } + return { chip, note: tc('tools.commands.unknown') } } // What a row of the model list says: whether it is loaded, what it can do @@ -196,7 +185,7 @@ export default function ChatComposer({ }, { key: 'addons', title: tc('tools.addons'), icon: 'puzzle', onSet: switches.setAddonsOn, - rows: switches.addons.map((a) => ({ key: `a-${a.id}`, id: a.id, label: a.id, meta: t('tools.toolCount', { count: a.tools }), on: !a.off, onToggle: (on) => switches.toggleAddon(a.id, on), ...addonNote(a) })), + rows: switches.addons.map((a) => ({ key: `a-${a.id}`, id: a.id, label: a.id, meta: t('tools.toolCount', { count: a.tools }), on: !a.off, onToggle: (on) => switches.toggleAddon(a.id, on) })), }, { key: 'servers', title: tc('tools.servers'), icon: 'server', onSet: switches.setMCPOn, @@ -248,8 +237,8 @@ export default function ChatComposer({ actions: withModelArgs(availableCommands(slashSurface, slashCtx)), groups: commandGroups(slashSurface), ctx: slashCtx, - label: (a) => words(a)(`slash.${a.id}.label`), - desc: (a) => words(a)(`slash.${a.id}.desc`), + label: (a) => a.label || words(a)(`slash.${a.id}.label`), + desc: (a) => a.addonId ? a.label : words(a)(`slash.${a.id}.desc`), groupLabel: (g) => (COMMAND_GROUPS.includes(g) ? t(`slash.group.${g}`) : own(`slash.group.${g}`)), argsLabel: (c) => t(`slash.args.${c.id === 'models' ? 'model' : c.id}`, { defaultValue: c.cmd }), argLabel: (c, it) => (it.action === 'enableAgents' ? t('slash.agent.enable') : it.action === 'loopStopAll' ? t('slash.loop.stopAll') : ''), @@ -281,12 +270,15 @@ export default function ChatComposer({ const on = (rows) => rows.filter((r) => !r.off && r.usable !== false).length const tools = on(switches.builtins) + switches.addons.filter((a) => !a.off).reduce((n, a) => n + (a.tools || 0), 0) + on(switches.servers) + on(switches.connectors) - const runs = switches.commands.state === 'available' ? (sandbox?.state === 'host' ? 'host' : 'isolated') : 'none' + const selected = switches.commands?.state + const runs = selected === 'available' ? 'isolated' + : ['host', 'host-off', 'host-unavailable', 'provider-host'].includes(selected) ? 'host' + : ['none', 'off', 'switched_off', 'unavailable', 'provider-unavailable'].includes(selected) ? 'none' : 'unknown' onRunAction?.(id, { about: { tools, skills: usableSkills(switches.skills).length, commands: runs } }) return } - onRunAction?.(id, item) - }, [chatId, onPickModel, setDraftModel, pickerRef, switches, setText, onRunAction, slash, sandbox]) + onRunAction?.(id, item?.addonId ? { ...item, addonOptions: { nativeNames: reservedNames(), skillNames: usableSkills(switches.skills).map(s => s.name) } } : item) + }, [chatId, onPickModel, setDraftModel, pickerRef, switches, setText, onRunAction, slash]) const effectivePlaceholder = placeholder || (effectiveModel ? t('input.placeholderModel', { model: effectiveModel }) diff --git a/core/http/react-ui/src/components/chat/CommandCards.jsx b/core/http/react-ui/src/components/chat/CommandCards.jsx index e9087c836..4bd00b8f7 100644 --- a/core/http/react-ui/src/components/chat/CommandCards.jsx +++ b/core/http/react-ui/src/components/chat/CommandCards.jsx @@ -86,7 +86,7 @@ export function ChatAboutCard({ about, onClose }) { if (about.backend) rows.push([t('cmdcard.backend'), about.backend]) rows.push([t('cmdcard.tools'), t('cmdcard.toolsOn', { count: about.tools || 0 })]) rows.push([t('cmdcard.skills'), t('cmdcard.skillsOn', { count: about.skills || 0 })]) - rows.push([t('cmdcard.commands'), about.commands === 'isolated' ? t('cmdcard.isolated') : about.commands === 'host' ? t('cmdcard.host') : t('cmdcard.noCommands')]) + rows.push([t('cmdcard.commands'), about.commands === 'isolated' ? t('cmdcard.isolated') : about.commands === 'host' ? t('cmdcard.host') : about.commands === 'none' ? t('cmdcard.noCommands') : t('cmdcard.commandsUnknown')]) rows.push([t('cmdcard.context'), about.limit > 0 ? t('cmdcard.contextOf', { used: tokenCount(about.used || 0), limit: tokenCount(about.limit) }) : (about.used > 0 ? t('cmdcard.contextUsed', { used: tokenCount(about.used) }) : t('cmdcard.unknown'))]) diff --git a/core/http/react-ui/src/components/chat/CommandsConsentCard.jsx b/core/http/react-ui/src/components/chat/CommandsConsentCard.jsx index b20cec3f6..828bc568e 100644 --- a/core/http/react-ui/src/components/chat/CommandsConsentCard.jsx +++ b/core/http/react-ui/src/components/chat/CommandsConsentCard.jsx @@ -1,12 +1,11 @@ /* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */ import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { Link } from 'react-router-dom' import Button from '../ui/Button' import Callout from '../ui/Callout' import { useAuth } from '../../context/AuthContext' import { chatHostExecApi, settingsApi } from '../../utils/api' -import { SANDBOX_INSTALL_LINK, consentExpired } from '../../utils/chatCommands' +import { consentExpired } from '../../utils/chatCommands' // The card a command of the built-in bash waits on, when the server asks before // each command (chats_host_exec ask). The command runs directly on this @@ -74,7 +73,7 @@ export default function CommandsConsentCard({ consent }) {

{t('tools.consent.body')}

{consent.command}
{isAdmin - ?

{t('tools.consent.alwaysHint')} {t('tools.commands.install')}

+ ?

{t('tools.consent.alwaysHint')}

:

{t('tools.consent.askAdmin')}

} {error &&

{error}

} diff --git a/core/http/react-ui/src/components/chat/CommandsNotice.jsx b/core/http/react-ui/src/components/chat/CommandsNotice.jsx index b58068eaa..b136bd265 100644 --- a/core/http/react-ui/src/components/chat/CommandsNotice.jsx +++ b/core/http/react-ui/src/components/chat/CommandsNotice.jsx @@ -36,6 +36,7 @@ export function CommandsNotice({ notice, testId = 'chat-commands-notice' }) { )} > {t(`tools.commands.${info.textKey}`, { reason: info.reason })} + {info.reason && ['provider-host', 'unknown', 'provider-unavailable'].includes(info.state) &&

{info.reason}

} {info.askKey && <> {t(`tools.commands.${info.askKey}`)}} ) diff --git a/core/http/react-ui/src/components/chat/NetworkRow.jsx b/core/http/react-ui/src/components/chat/NetworkRow.jsx index ebca78a28..fbf87b829 100644 --- a/core/http/react-ui/src/components/chat/NetworkRow.jsx +++ b/core/http/react-ui/src/components/chat/NetworkRow.jsx @@ -19,7 +19,12 @@ export default function NetworkRow({ egress }) { const [open, setOpen] = useState(false) const mode = networkMode(egress) if (mode === 'hidden') return null - if (mode === 'host') return

{t('egress.hostNetwork')}

+ if (mode === 'host' || mode === 'unverified') return ( +
+

{t(mode === 'host' ? 'egress.hostNetwork' : 'egress.unverified')}

+

{t('egress.configuredPolicy', { preset: t(`egress.preset.${egress.preset || 'off'}`) })}

+
+ ) const choices = presetChoices(egress) const kind = ceilingKind(egress) const asked = askedAboveLimit(egress) diff --git a/core/http/react-ui/src/components/home/HomeComposer.jsx b/core/http/react-ui/src/components/home/HomeComposer.jsx index ab28a901e..b075ae11b 100644 --- a/core/http/react-ui/src/components/home/HomeComposer.jsx +++ b/core/http/react-ui/src/components/home/HomeComposer.jsx @@ -74,7 +74,7 @@ export default function HomeComposer({ // a command with an argument provider lists its arguments instead. const groupList = slash?.groups || SLASH_GROUPS const labelOf = useCallback((a) => (a.label || (slash ? slash.label(a) : t(`slash.${a.id}.label`))), [t, slash]) - const descOf = (a) => (a.label ? a.desc : (slash ? slash.desc(a) : t(`slash.${a.id}.desc`))) + const descOf = (a) => (a.label ? (a.desc || a.label) : (slash ? slash.desc(a) : t(`slash.${a.id}.desc`))) const groupLabel = (g) => (slash ? slash.groupLabel(g) : t(`slash.group.${g}`)) const actions = useMemo( () => (slash ? slash.actions : availableActions(slashContext)), @@ -138,7 +138,7 @@ export default function HomeComposer({ if (entry.insert) { onMessage(entry.insert); return } if (complete) { onMessage(entry.args ? `${entry.cmd} ` : entry.cmd); return } onMessage('') - if (!runLocal(entry.id)) onRunAction(entry.id) + if (!runLocal(entry.id)) onRunAction(entry.id, entry) } const onKeyDown = (e) => { @@ -148,7 +148,13 @@ export default function HomeComposer({ const n = items.length if (e.key === 'ArrowDown') { e.preventDefault(); setActive((safeActive + 1) % n); return } if (e.key === 'ArrowUp') { e.preventDefault(); setActive((safeActive + n - 1) % n); return } - if (e.key === 'Enter') { e.preventDefault(); run(items[safeActive]); return } + if (e.key === 'Enter') { + const entry = items[safeActive] + // Addon navigation is never prefix execution. Tab can complete it; + // Enter on a partial command leaves the draft untouched. + if (entry.addonId && message.trim() !== entry.cmd) { e.preventDefault(); return } + e.preventDefault(); run(entry); return + } if (e.key === 'Tab') { e.preventDefault(); run(items[safeActive], true); return } if (e.key === 'Escape') { e.preventDefault(); setDismissed(true); return } } @@ -157,10 +163,16 @@ export default function HomeComposer({ const bare = !strictEnter || (!e.ctrlKey && !e.metaKey && !e.altKey && e.keyCode !== 229) if (e.key === 'Enter' && !e.shiftKey && bare) { e.preventDefault() - onSubmit() + submit() } } + const submit = () => { + const exact = actions.find(c => c.addonId && c.cmd === message.trim()) + if (exact) { onMessage(''); onRunAction(exact.id, exact); return } + onSubmit() + } + const activeId = open ? `home-slash-opt-${safeActive}` : undefined // The rows of the menu: commands under their group labels, or the arguments @@ -171,13 +183,13 @@ export default function HomeComposer({ .filter((grp) => grp.rows.length > 0) const rowView = (it) => (argCmd ? { testId: `home-slash-arg-${it.key}`, icon: it.icon || argCmd.icon, name: it.name || slash?.argLabel?.(argCmd, it) || '', desc: it.desc || slash?.argDesc?.(argCmd, it) || '', mono: !!it.name, badge: it.badge, note: it.note, upto: 0 } - : { testId: `home-slash-${it.id}`, icon: it.icon, name: it.cmd, desc: descOf(it), mono: true, badge: it.badge, note: it.note, + : { testId: `home-slash-${it.id}`, icon: it.icon, iconUrl: it.iconUrl, name: it.cmd, desc: descOf(it), mono: true, badge: it.badge, note: it.note, upto: query && it.cmd.slice(1).toLowerCase().startsWith(query) ? 1 + query.length : 0 }) return (
{ e.preventDefault(); onSubmit() }} + onSubmit={(e) => { e.preventDefault(); submit() }} autoComplete="off" data-testid={testId} // The toast stack stays above this bar on a chat page (components/Toast). @@ -241,7 +253,7 @@ export default function HomeComposer({ onMouseMove={() => setActive(i)} onClick={() => run(it)} > - + {v.iconUrl ? : } {v.desc && {v.desc}} diff --git a/core/http/react-ui/src/components/home/HomeFirstRun.jsx b/core/http/react-ui/src/components/home/HomeFirstRun.jsx index 734501d8e..7e7002209 100644 --- a/core/http/react-ui/src/components/home/HomeFirstRun.jsx +++ b/core/http/react-ui/src/components/home/HomeFirstRun.jsx @@ -1,45 +1,14 @@ -import { useContext, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useNavigate } from 'react-router-dom' -import { RegistryContext, EMPTY_LIST } from '../../addons/registryContext' -import { SANDBOX_INSTALL_LINK, SANDBOX_ADDON_ID } from '../../utils/chatCommands' // eslint-disable-next-line no-unused-vars import StarterModels from '../StarterModels' // eslint-disable-next-line no-unused-vars import Button from '../ui/Button' -const SANDBOX_SKIP_KEY = 'localai.firstRun.sandboxSkipped' - -function readSkipped() { - try { return localStorage.getItem(SANDBOX_SKIP_KEY) === '1' } catch { return false } -} - -// The optional step "Run code safely": offered while the Sandbox add-on is not -// installed and the person has not skipped it. Nothing is shown before the -// add-on list has loaded, so an installed Sandbox never flashes the step. -function useSandboxStep() { - const registry = useContext(RegistryContext) - const acquire = registry ? registry.acquire : null - const allowed = registry ? registry.allowed : false - useEffect(() => (acquire && allowed ? acquire() : undefined), [acquire, allowed]) - const [skipped, setSkipped] = useState(readSkipped) - const skip = () => { - try { localStorage.setItem(SANDBOX_SKIP_KEY, '1') } catch { /* private mode: skip for this visit only */ } - setSkipped(true) - } - const apps = registry ? registry.apps : EMPTY_LIST - const installed = apps.some(a => a.id === SANDBOX_ADDON_ID) - const show = Boolean(registry) && allowed && registry.status === 'ready' && !installed && !skipped - return { show, skip } -} - // First run: no model is installed yet. Three steps, with the recommended // models for the detected hardware inside the second one, so the way forward // is on the page without a separate wizard. export default function HomeFirstRun({ addToast, onInstallStarted, onGallery, onImport }) { const { t } = useTranslation('home') - const navigate = useNavigate() - const sandbox = useSandboxStep() return (
  1. @@ -70,19 +39,7 @@ export default function HomeFirstRun({ addToast, onInstallStarted, onGallery, on {t('wizard.steps.step3Body')}
  2. - {sandbox.show && ( -
  3. - -
    - {t('wizard.steps.sandboxTitle')} - {t('wizard.steps.sandboxBody')} -
    - - -
    -
    -
  4. - )} +
) } diff --git a/core/http/react-ui/src/components/home/homeActions.js b/core/http/react-ui/src/components/home/homeActions.js index 9c1aa6a16..8f38ec451 100644 --- a/core/http/react-ui/src/components/home/homeActions.js +++ b/core/http/react-ui/src/components/home/homeActions.js @@ -1,9 +1,8 @@ // The slash actions on the Home command bar. Every entry has a destination in // the product today; the page decides what each one does (navigate, open the // model list, start a chat). `needs` hides an action the person cannot use: -// the admin pages, stopping a model when none is loaded, the LocalAI -// assistant when it is off, or -// Studio while that add-on is not installed. +// the admin pages, stopping a model when none is loaded, or the assistant. + export const SLASH_GROUPS = ['chat', 'models', 'go'] @@ -19,24 +18,20 @@ export const SLASH_ACTIONS = [ { id: 'agents', cmd: '/agents', icon: 'robot', group: 'go', needs: 'chats' }, { id: 'mcp', cmd: '/mcp', icon: 'plug', group: 'go', needs: 'chats' }, { id: 'memory', cmd: '/memory', icon: 'database', group: 'go', needs: 'collections' }, - { id: 'studio', cmd: '/studio', icon: 'palette', group: 'go', needs: 'studio' }, - // The same command for an administrator while Studio is not installed. - { id: 'studioInstall', cmd: '/studio', icon: 'palette', group: 'go', needs: 'studio-missing' }, + { id: 'apps', cmd: '/apps', icon: 'puzzle', group: 'go', needs: 'addons' }, { id: 'settings', cmd: '/settings', icon: 'settings', group: 'go', needs: 'admin' }, { id: 'docs', cmd: '/docs', icon: 'book', group: 'go' }, ] -// `ctx`: { isAdmin, hasModels, loadedCount, studioInstalled, assistantAvailable, // hasChats, hasCollections } export function availableActions(ctx) { return SLASH_ACTIONS.filter(a => { + if (a.needs === 'addons') return ctx.hasAddons === true if (a.needs === 'admin') return ctx.isAdmin if (a.needs === 'models') return ctx.hasModels if (a.needs === 'loaded') return ctx.isAdmin && ctx.loadedCount > 0 // The server says whether the LocalAI assistant is on; only an admin may use it. if (a.needs === 'assistant') return ctx.isAdmin && ctx.assistantAvailable === true - if (a.needs === 'studio') return ctx.studioInstalled === true - if (a.needs === 'studio-missing') return ctx.isAdmin && ctx.studioInstalled === false if (a.needs === 'chats') return ctx.hasChats === true if (a.needs === 'collections') return ctx.hasCollections === true return true diff --git a/core/http/react-ui/src/components/models/page/OverviewTab.jsx b/core/http/react-ui/src/components/models/page/OverviewTab.jsx index 2f9bc6d59..596745301 100644 --- a/core/http/react-ui/src/components/models/page/OverviewTab.jsx +++ b/core/http/react-ui/src/components/models/page/OverviewTab.jsx @@ -1,4 +1,6 @@ // eslint-disable-next-line no-unused-vars +import ModelLaunch from '../../../addons/ModelLaunch' +// eslint-disable-next-line no-unused-vars import { Link } from 'react-router-dom' import { useCleanupFacts } from '../../../hooks/useCleanupFacts' import { renderMarkdown, stripMarkdown } from '../../../utils/markdown' @@ -236,9 +238,7 @@ export default function OverviewTab({ view }) {
{t('lifecycle.open.title')}
- {useCases.map(u => u.route - ? {t(`lifecycle.open.${u.labelKey}`)} - : {t(`lifecycle.open.${u.labelKey}`)})} + {useCases.map(u => )}
diff --git a/core/http/react-ui/src/components/settings/BuiltinToolsSection.jsx b/core/http/react-ui/src/components/settings/BuiltinToolsSection.jsx index 9348ff057..1a2123db0 100644 --- a/core/http/react-ui/src/components/settings/BuiltinToolsSection.jsx +++ b/core/http/react-ui/src/components/settings/BuiltinToolsSection.jsx @@ -6,7 +6,6 @@ import { Link } from 'react-router-dom' import { Callout, LoadError, SkeletonRows, Switch } from '../ui' import { chatBuiltinToolsApi } from '../../utils/api' import { FIELD_BY_KEY, getValue, setValue } from '../../utils/settingsSchema' -import { SANDBOX_INSTALL_LINK } from '../../utils/chatCommands' import { addonSettingsAddress } from '../../utils/addonSettings' // Settings > Tools: the built-in chat tools (bash, web_search, web_fetch), each @@ -222,9 +221,7 @@ export default function BuiltinToolsSection({ settings, onChange, only, addonLin

)} - {tool.id === 'bash' && tool.candidates.length === 0 && ( -

{t('settingsTools.installSandbox')}

- )} + ) diff --git a/core/http/react-ui/src/context/AuthContext.jsx b/core/http/react-ui/src/context/AuthContext.jsx index 408ab08b4..6303475e0 100644 --- a/core/http/react-ui/src/context/AuthContext.jsx +++ b/core/http/react-ui/src/context/AuthContext.jsx @@ -1,10 +1,11 @@ -import { useContext, useState, useEffect } from 'react' +import { useContext, useState, useEffect, useRef, useCallback } from 'react' import { apiUrl } from '../utils/basePath' import { AuthContext } from './authState' import { addonUpgrades } from '../utils/addonUpgrades' export function AuthProvider({ children }) { const [state, setState] = useState({ + sessionEpoch: 0, loading: true, authEnabled: false, staticApiKeyRequired: false, @@ -12,27 +13,41 @@ export function AuthProvider({ children }) { permissions: {}, }) + // Background work can outlive its initiating view. Keep its non-secret + // authority here, and invalidate before React schedules consumer renders. + const authority = useRef({}) + const captureSessionAuthority = useCallback(() => { + const origin = authority.current + return () => origin !== null && authority.current === origin + }, []) + const invalidateAuthority = () => { if (authority.current !== null) authority.current = {} } + const fetchStatus = () => { return fetch(apiUrl('/api/auth/status')) .then(r => r.json()) .then(data => { + invalidateAuthority() const user = data.user || null const permissions = user?.permissions || {} - setState({ + setState(previous => ({ + sessionEpoch: previous.sessionEpoch + 1, loading: false, authEnabled: data.authEnabled || false, staticApiKeyRequired: data.staticApiKeyRequired || false, user, permissions, - }) + })) }) .catch(() => { - setState({ loading: false, authEnabled: false, staticApiKeyRequired: false, user: null, permissions: {} }) + invalidateAuthority() + setState(previous => ({ sessionEpoch: previous.sessionEpoch + 1, loading: false, authEnabled: false, staticApiKeyRequired: false, user: null, permissions: {} })) }) } useEffect(() => { + authority.current = {} fetchStatus() + return () => { authority.current = null } }, []) const logout = async () => { @@ -41,6 +56,9 @@ export function AuthProvider({ children }) { let persistence try { persistence = window.sessionStorage } catch { /* storage blocked */ } addonUpgrades.clearSession(persistence) + // Invalidate in-flight observations before waiting for logout or navigation. + invalidateAuthority() + setState(previous => ({ ...previous, sessionEpoch: previous.sessionEpoch + 1, user: null, permissions: {} })) try { await fetch(apiUrl('/api/auth/logout'), { method: 'POST' }) } catch (_) { /* ignore */ } @@ -60,6 +78,9 @@ export function AuthProvider({ children }) { } const value = { + // A local, non-secret epoch: even same-user authentication refreshes invalidate observations. + sessionEpoch: state.sessionEpoch, + captureSessionAuthority, loading: state.loading, authEnabled: state.authEnabled, staticApiKeyRequired: state.staticApiKeyRequired, diff --git a/core/http/react-ui/src/hooks/useChatComposer.js b/core/http/react-ui/src/hooks/useChatComposer.js index 10dd736d5..9b81789d0 100644 --- a/core/http/react-ui/src/hooks/useChatComposer.js +++ b/core/http/react-ui/src/hooks/useChatComposer.js @@ -3,6 +3,7 @@ import { chatAgentsApi } from '../utils/api' import { CAP_CHAT } from '../utils/capabilities' import { useChatDrafts } from './useChatDrafts' import { useChatSwitches } from './useChatSwitches' +import { useAuth } from '../context/AuthContext' import { useModels } from './useModels' // The state of the chat composer, shared by the chat page and Home: the draft @@ -19,6 +20,8 @@ import { useModels } from './useModels' // filesOn files work on this server: attaching and the skills need it // initialModel, initialAgent the model and the agent a new chat starts on export function useChatComposer({ chatId = null, draftKey = '', userId, activeChat = null, filesOn = true, canAgents = false, initialModel = '', initialAgent = '' }) { + const { user, sessionEpoch } = useAuth() + const actorSession = JSON.stringify([user?.id ?? userId ?? null, sessionEpoch]) // The id the draft is kept under (useChatDrafts): the chat, or draftKey. const draftId = chatId || draftKey || '' const drafts = useChatDrafts(draftId || null, userId) @@ -38,7 +41,7 @@ export function useChatComposer({ chatId = null, draftKey = '', userId, activeCh const pickedAgent = agents.find((a) => a.id === (activeChat ? activeChat.agent_id : agentId)) // The model of a new chat comes from the agent when it names one. const effectiveModel = activeChat ? activeChat.model : (pickedAgent?.model || draftModel) - const switches = useChatSwitches(chatId, filesOn, chatId ? null : { model: effectiveModel, agentId }) + const switches = useChatSwitches(chatId, filesOn, chatId ? null : { model: effectiveModel, agentId }, actorSession) // Files go to the server with the message that starts a turn and are saved // in the chat's folder of the workspace. diff --git a/core/http/react-ui/src/hooks/useChatSwitches.js b/core/http/react-ui/src/hooks/useChatSwitches.js index f7a06b859..a6aa63124 100644 --- a/core/http/react-ui/src/hooks/useChatSwitches.js +++ b/core/http/react-ui/src/hooks/useChatSwitches.js @@ -3,14 +3,14 @@ import { chatAddonsApi, chatSkillsApi, chatToolsApi } from '../utils/api' import { connectorRef, toolsCounts } from '../utils/chatMCP' import { offWith, withOn } from '../utils/chatSwitchRows' import { changedIds, offList, pickerSkills, revertSkills, skillKey, withSkillsSet } from '../utils/chatSkills' -import { commandsState } from '../utils/chatCommands' +import { commandsState, noticeIdentity } from '../utils/chatCommands' import { applyChoices, draftSwitches as switchesOf, emptyChoices, withChoice } from '../utils/chatDraftSwitches' const noMCP = { servers: [], connectors: [], off: [] } const mcpOf = (res) => ({ servers: res.servers || [], connectors: res.connectors || [], off: res.off || [] }) -const noCommands = { state: '', reason: '' } +const noCommands = { state: 'unknown', reason: '', identity: '' } // The commands state of the list: see commandsState in utils/chatCommands.js. -const commandsOf = (res) => commandsState(res) +const commandsOf = (res) => ({ ...commandsState(res), provider: typeof res?.commands_provider === 'string' ? res.commands_provider : '', identity: noticeIdentity(res, '', '') }) export { skillKey } @@ -24,7 +24,7 @@ export { skillKey } // the new chat: the lists come from the draft endpoints, a switch changes only // the screen, and draftSwitches() gives the choices for the create request // (utils/chatDraftSwitches.js). Choices outlive a change of model or agent. -export function useChatSwitches(chatId, filesOn, draft = null) { +export function useChatSwitches(chatId, filesOn, draft = null, actorSession = '') { const draftOn = !chatId && !!draft const draftModel = draft?.model || '' const draftAgent = draft?.agentId || '' @@ -32,7 +32,12 @@ export function useChatSwitches(chatId, filesOn, draft = null) { const choose = useCallback((kind, key, value) => { choicesRef.current = withChoice(choicesRef.current, kind, key, value) }, []) const [addons, setAddons] = useState([]) // Whether the chat can run commands, from the same list (see utils/chatCommands.js). - const [commands, setCommands] = useState({ state: '', reason: '' }) + const commandEpoch = useRef(0) + const context = JSON.stringify([actorSession, chatId, draftModel, draftAgent, filesOn]) + const commandContext = useRef(context) + if (commandContext.current !== context) { commandContext.current = context; commandEpoch.current++ } + const [commands, storeCommands] = useState(noCommands) + const setCommands = (value) => storeCommands({ ...value, context: commandContext.current }) const [mcp, setMcp] = useState(noMCP) const [builtins, setBuiltins] = useState([]) // The built-in tools an installed add-on replaces but cannot serve here now. @@ -55,11 +60,13 @@ export function useChatSwitches(chatId, filesOn, draft = null) { setSkillsError('') if (!chatId && !draftOn) { setAddons([]); setCommands(noCommands); setMcp(noMCP); setBuiltins([]); setFallbacks([]); setSkills([]); setReplaced([]); return undefined } let live = true + const epoch = ++commandEpoch.current + setCommands(noCommands) // A draft list keeps the choices made so far for the rows it still has. const chosen = (lists) => (draftOn ? applyChoices(lists, choicesRef.current, connectorRef) : lists) ;(draftOn ? chatAddonsApi.draft(draftModel, draftAgent) : chatAddonsApi.list(chatId)) - .then((res) => { if (live) { setAddons(chosen({ addons: res.addons || [] }).addons); setCommands(commandsOf(res)) } }) - .catch(() => { if (live) { setAddons([]); setCommands(noCommands) } }) + .then((res) => { if (live && epoch === commandEpoch.current) { setAddons(chosen({ addons: res.addons || [] }).addons); setCommands(commandsOf(res)) } }) + .catch(() => { if (live && epoch === commandEpoch.current) { setAddons([]); setCommands(noCommands) } }) ;(draftOn ? chatToolsApi.draft(draftModel, draftAgent) : chatToolsApi.list(chatId)) .then((res) => { if (!live) return @@ -71,7 +78,7 @@ export function useChatSwitches(chatId, filesOn, draft = null) { }) .catch(() => { if (live) { setMcp(noMCP); setBuiltins([]); setFallbacks([]) } }) return () => { live = false } - }, [chatId, filesOn, draftOn, draftModel, draftAgent]) + }, [chatId, filesOn, draftOn, draftModel, draftAgent, actorSession]) useEffect(() => { if ((!chatId && !draftOn) || !filesOn) { setSkills([]); setReplaced([]); return undefined } @@ -109,9 +116,12 @@ export function useChatSwitches(chatId, filesOn, draft = null) { if (ids.length === 0) return putAddons(next) setError('') + const epoch = ++commandEpoch.current + setCommands(noCommands) if (draftOn) { ids.forEach((id) => choose('addons', id, !on)); return } try { const res = await chatAddonsApi.setOff(chatId, next.filter((a) => a.off).map((a) => a.id).sort()) + if (epoch !== commandEpoch.current) return if (res?.addons) { putAddons(res.addons) setCommands(commandsOf(res)) @@ -123,6 +133,8 @@ export function useChatSwitches(chatId, filesOn, draft = null) { .then((r) => { putBuiltins(r.builtins || []); setFallbacks(r.fallbacks || []) }) .catch(() => {}) } catch (e) { + if (epoch !== commandEpoch.current) return + setCommands(noCommands) putAddons(before) setError(e.message || 'error') } @@ -160,16 +172,23 @@ export function useChatSwitches(chatId, filesOn, draft = null) { const next = withOn(before, (b) => b.group, new Set(groups), on) putBuiltins(next) setError('') + const epoch = ++commandEpoch.current + setCommands(noCommands) if (draftOn) { groups.forEach((group) => choose('builtins', group, !on)); return } try { const off = next.filter((b) => b.off && !b.opt_in).map((b) => b.group).sort() const optIn = next.filter((b) => b.opt_in) const onList = optIn.length ? optIn.filter((b) => !b.off).map((b) => b.group).sort() : undefined const res = await chatToolsApi.setBuiltinsOff(chatId, off, onList) + const status = await chatAddonsApi.list(chatId) + if (epoch !== commandEpoch.current) return + setCommands(commandsOf(status)) putBuiltins(res.builtins || []) putMcp(mcpOf(res)) setFallbacks(res.fallbacks || []) } catch (e) { + if (epoch !== commandEpoch.current) return + setCommands(noCommands) putBuiltins(before) setError(e.message || 'error') } @@ -224,7 +243,7 @@ export function useChatSwitches(chatId, filesOn, draft = null) { return { draftSwitches, resetDraft, draft: draftOn, - addons, builtins, fallbacks, commands, servers: mcp.servers, connectors: mcp.connectors, skills, replacedSkills: replaced, error, skillsError, skillsBusy: skillsBusy > 0, + addons, builtins, fallbacks, commands: commands.context === context ? commands : noCommands, servers: mcp.servers, connectors: mcp.connectors, skills, replacedSkills: replaced, error, skillsError, skillsBusy: skillsBusy > 0, counts: toolsCounts({ builtins, addons, servers: mcp.servers, connectors: mcp.connectors }), toggleAddon, toggleBuiltin, toggleMCP, toggleSkill, setSkillsOn, setAddonsOn, setBuiltinsOn, setMCPOn, } diff --git a/core/http/react-ui/src/hooks/useCommandsNotice.js b/core/http/react-ui/src/hooks/useCommandsNotice.js index 3b30da42e..1c775bc1c 100644 --- a/core/http/react-ui/src/hooks/useCommandsNotice.js +++ b/core/http/react-ui/src/hooks/useCommandsNotice.js @@ -1,17 +1,15 @@ -import { useCallback, useState } from 'react' -import { commandsNotice, readHidden, writeHidden } from '../utils/chatCommands' +import { useCallback, useEffect, useState } from 'react' +import { commandsNotice } from '../utils/chatCommands' -const storage = () => (typeof window === 'undefined' ? null : window.localStorage) - -// The notice of a chat that cannot run commands, from the "commands" field of -// the chat's add-on list, and whether the person hid it in this browser. -export function useCommandsNotice(commands, reason, isAdmin) { - const info = commandsNotice(commands, { isAdmin, reason }) - const [hiddenState, setHiddenState] = useState(() => readHidden(storage())) +// Dismissal is deliberately mount-local: logout/remount never retains it. +// Exact binding, observation and actor/chat context must match, not just state. +export function useCommandsNotice(commands, isAdmin, context) { + const info = commandsNotice(commands.state, { isAdmin, reason: commands.reason }) + const identity = JSON.stringify([context, commands.identity, commands.state, commands.reason]) + const [hiddenIdentity, setHiddenIdentity] = useState('') + useEffect(() => { setHiddenIdentity('') }, [identity]) const hide = useCallback(() => { - if (!info) return - writeHidden(storage(), info.state) - setHiddenState(info.state) - }, [info]) - return { info, hidden: !!info && hiddenState === info.state, hide } + if (info && commands.identity) setHiddenIdentity(identity) + }, [info, commands.identity, identity]) + return { info, hidden: !!commands.identity && !!info && hiddenIdentity === identity, hide } } diff --git a/core/http/react-ui/src/hooks/useOperationActions.js b/core/http/react-ui/src/hooks/useOperationActions.js index 19c9debf7..05922bd92 100644 --- a/core/http/react-ui/src/hooks/useOperationActions.js +++ b/core/http/react-ui/src/hooks/useOperationActions.js @@ -1,3 +1,4 @@ +import { useAuth } from '../context/AuthContext' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { backendsApi, modelsApi, nodesApi } from '../utils/api' @@ -17,6 +18,7 @@ export function isRetryable(op) { // Activity page and the Status page's "Needs you" row, so both do the same // thing with the same calls. export function useOperationActions(addToast) { + const { captureSessionAuthority } = useAuth() const { t } = useTranslation('operate') const { t: ta } = useTranslation('addons') const { dismissFailedOp } = useOperations() @@ -26,7 +28,9 @@ export function useOperationActions(addToast) { // service. The store reads the add-on again, so a changed candidate is // honoured, and an unknown outcome is only read, never submitted twice. if (op.isAddon) { - const out = await addonUpgrades.retry(addonsApi, op.jobID) + const current = captureSessionAuthority() + const out = await addonUpgrades.retry(addonsApi, op.jobID, { current }) + if (!current() || !out) return if (!out.ok && out.outcome !== 'cancelled') addToast?.(t('activity.retryFailed', { message: upgradeFailureWords(out.message, ta) }), 'error') return } @@ -61,7 +65,7 @@ export function useOperationActions(addToast) { } catch (err) { addToast?.(t('activity.retryFailed', { message: err.message }), 'error') } - }, [dismissFailedOp, addToast, t, ta]) + }, [dismissFailedOp, addToast, t, ta, captureSessionAuthority]) const dismiss = useCallback((jobID) => { if (String(jobID).startsWith('addon:')) { diff --git a/core/http/react-ui/src/hooks/useSandboxStatus.js b/core/http/react-ui/src/hooks/useSandboxStatus.js deleted file mode 100644 index f20666836..000000000 --- a/core/http/react-ui/src/hooks/useSandboxStatus.js +++ /dev/null @@ -1,60 +0,0 @@ -import { useContext, useEffect, useState } from 'react' -import { RegistryContext, EMPTY_LIST } from '../addons/registryContext' -import { addonsApi } from '../utils/addonsClient' -import { newOperationId } from '../components/catalog/catalogState' -import { unavailableWait } from '../components/addons/lifecycleFlow' -import { SANDBOX_ADDON, sandboxState, startingState } from '../utils/sandboxStatus' - -// When the add-on service does not answer (it starts, or restarts after an -// upgrade), the next read waits longer each time. The wait is kept for the -// page, not for one chat: opening another chat, or the same one again, does -// not ask before it is over. A new generation of the add-on starts afresh. -const backoff = { generation: 0, down: 0, nextAt: 0 } - -const isUnavailable = (e) => e && (e.kind === 'unavailable' || e.kind === 'network') - -// The Sandbox add-on's state for the Tools picker, read once per chat while -// the chat lists the add-on. null when the chat does not offer it. While the -// add-on service starts the state is 'starting', and the hook reads again by -// itself, backing off. -export function useSandboxStatus(listed, chatId) { - const registry = useContext(RegistryContext) - const acquire = registry ? registry.acquire : null - const allowed = registry ? registry.allowed : false - useEffect(() => (listed && acquire && allowed ? acquire() : undefined), [listed, acquire, allowed]) - const apps = registry ? registry.apps : EMPTY_LIST - const app = apps.find(a => a.id === SANDBOX_ADDON) - const generation = app?.generation || 0 - const [status, setStatus] = useState(null) - useEffect(() => { - if (!listed || !generation) { setStatus(null); return undefined } - const ctl = new AbortController() - let timer = null - if (backoff.generation !== generation) Object.assign(backoff, { generation, down: 0, nextAt: 0 }) - const read = () => { - const wait = backoff.nextAt - Date.now() - if (wait > 0) { - setStatus(startingState(wait)) - timer = setTimeout(read, wait) - return - } - addonsApi.dispatch({ operationId: newOperationId(), action: 'read', addonId: SANDBOX_ADDON, generation, args: { tool: 'status' } }, ctl.signal) - .then(payload => { - Object.assign(backoff, { down: 0, nextAt: 0 }) - setStatus(sandboxState(payload)) - }) - .catch(e => { - if (ctl.signal.aborted) return - if (!isUnavailable(e)) { setStatus(sandboxState(null)); return } - backoff.down += 1 - const next = unavailableWait(backoff.down) - backoff.nextAt = Date.now() + next - setStatus(startingState(next)) - timer = setTimeout(read, next) - }) - } - read() - return () => { ctl.abort(); clearTimeout(timer) } - }, [listed, generation, chatId]) - return status -} diff --git a/core/http/react-ui/src/pages/Home.jsx b/core/http/react-ui/src/pages/Home.jsx index 76177c340..9b20c561a 100644 --- a/core/http/react-ui/src/pages/Home.jsx +++ b/core/http/react-ui/src/pages/Home.jsx @@ -34,7 +34,9 @@ import { widgetList } from '../utils/wellKnown' import { useResources } from '../hooks/useResources' import { usePolling } from '../hooks/usePolling' import { useOperations } from '../hooks/useOperations' -import { useAddonPage } from '../addons/useAddonPage' +import { useAddonHomeCommands } from '../addons/useAddonRegistry' +import { projectHomeCommands } from '../addons/homeCommands' +import { reservedNames, usableSkills } from '../utils/slashCommands' import { backendControlApi, chatsApi, modelsApi, systemApi, nodesApi } from '../utils/api' import { greetingKey } from '../utils/greeting' import { resumeItems } from '../utils/homeConversations' @@ -109,9 +111,8 @@ export default function Home() { const pickerRef = useRef(null) const stripRef = useRef(null) const location = useLocation() - const [addonsOn, setAddonsOn] = useState(true) // An add-on address opened while add-ons are off on the server lands here - // (see AddonsShell); so does /studio without Studio installed. The notice + // (see AddonsShell). The notice // says why, and how to get the add-on. const [addonNotice, setAddonNotice] = useState(() => location.state?.addonsOff || '') @@ -123,7 +124,6 @@ export default function Home() { setDistributedMode(!!data.distributed) setAssistantOn(!!data.localai_assistant) setCollectionsOn(data.collections !== false) - setAddonsOn(data.addons !== false) }) .catch(() => {}) }, []) @@ -204,6 +204,7 @@ export default function Home() { // What a message that left for its chat clears on Home, run as Home closes. // A reload or a closed tab can come first while the chat page still loads, // so leaving the page runs it too: the sent text must not come back. + const homeCommandRef = useRef(null) const sentRef = useRef(null) useEffect(() => { const run = () => { @@ -221,6 +222,7 @@ export default function Home() { let text = (own ? message : override).trim() const attached = own ? files : [] if ((!text && attached.length === 0) || sending) return + if (own && await homeCommandRef.current?.(text)) return // A message typed as a command goes with its skill or saved agent (the // drafted switches of the new chat decide which it has). const savedGroup = switches.builtins.find(b => b.group === 'saved_agents') @@ -387,17 +389,31 @@ export default function Home() { const stripBusy = !!stagingOp || !!failedOp useEffect(() => { if (stripBusy) setStripOpen(true) }, [stripBusy]) - // Studio is an add-on: its command is offered only while it is installed. - const studioPage = useAddonPage('studio', 'studio') + const addonRegistry = useAddonHomeCommands() + const addonOptions = { nativeNames: reservedNames(), skillNames: usableSkills(switches.skills).map(s => s.name), language: addonRegistry.language } + const addonProjection = projectHomeCommands(addonRegistry, addonOptions) + const addonExamples = addonProjection.commands.filter(c => c.cmd === c.canonical).slice(0, 1).map(c => c.cmd) + const [addonUnavailable, setAddonUnavailable] = useState(false) + homeCommandRef.current = async text => { + const exact = addonProjection.commands.find(c => c.cmd === text) + if (!exact && !/^\/addon:/.test(text)) return false + const ok = exact ? await addonRegistry.navigateHomeCommand(exact, addonOptions) : false + setAddonUnavailable(!ok) + return true + } const slashContext = useMemo( () => ({ - isAdmin, hasModels: hasModels && !firstRun, loadedCount, studioInstalled: Boolean(studioPage), assistantAvailable: assistantOn, + isAdmin, hasModels: hasModels && !firstRun, loadedCount, addonRegistry, hasAddons: hasFeature('addons'), assistantAvailable: assistantOn, hasChats: hasFeature('chats'), hasCollections: hasFeature('collections') && collectionsOn, }), - [isAdmin, hasModels, firstRun, loadedCount, studioPage, assistantOn, hasFeature, collectionsOn], + [isAdmin, hasModels, firstRun, loadedCount, addonRegistry, assistantOn, hasFeature, collectionsOn], ) const runAction = useCallback((id, item) => { + if (item?.addonId) { + addonRegistry.navigateHomeCommand(item, item.addonOptions || { nativeNames: reservedNames(), skillNames: usableSkills(switches.skills).map(s => s.name) }).then(ok => setAddonUnavailable(!ok)) + return + } switch (id) { case 'model': pickerRef.current?.open(); break case 'new': openNewChat(); break @@ -414,17 +430,13 @@ export default function Home() { case 'mcp': navigate('/app/mcp-connectors'); break case 'memory': navigate('/app/collections'); break case 'skills': navigate('/app/skills'); break - case 'studio': if (studioPage) navigate(studioPage); break - case 'studioInstall': - if (addonsOn) navigate('/app/addons?tab=browse&q=studio') - else setAddonNotice('studio') - break + case 'apps': navigate('/app/addons'); break case 'settings': navigate('/app/settings'); break case 'docs': window.open(DOCS_URL, '_blank', 'noopener,noreferrer'); break case 'help': setHelp(item || { entries: [], skills: [] }); break default: break } - }, [navigate, openNewChat, openAssistantChat, studioPage, addonsOn]) + }, [navigate, openNewChat, openAssistantChat, addonRegistry, switches.skills]) // "/" from anywhere that is not a text field starts a command. useEffect(() => { @@ -513,6 +525,13 @@ export default function Home() { textareaTestId="home-input" sendTestId="home-send" /> + {isAdmin && addonProjection.diagnostics.length > 0 && ( +
+ {t('slash.commandDiagnostics')} +
{JSON.stringify(addonProjection.diagnostics, null, 2)}
+
+ )} + {addonUnavailable && {t('notice.appUnavailable')}} {help && setHelp(null)} />} {!firstRun && } {examplePicked && ( @@ -550,7 +569,7 @@ export default function Home() { )} > - {t(isAdmin ? 'notice.addonsOffAdmin' : 'notice.addonsOffReader', { name: addonNotice === 'studio' ? 'Studio' : addonNotice === 'addons' ? t('notice.thisPage') : addonNotice })} + {t(isAdmin ? 'notice.addonsOffAdmin' : 'notice.addonsOffReader', { name: t('notice.thisPage') })} )} @@ -629,7 +648,7 @@ export default function Home() { onAll={() => navigate('/app/chats/all')} emptyHint={(
- {(isAdmin ? ['/gallery', '/import'] : []).concat(studioPage ? ['/studio'] : []).map(c => ( + {(isAdmin ? ['/gallery', '/import'] : []).concat(addonExamples).map(c => ( {c} ))}
diff --git a/core/http/react-ui/src/pages/InstalledModels.jsx b/core/http/react-ui/src/pages/InstalledModels.jsx index 4cba9b04f..f6f2e78ad 100644 --- a/core/http/react-ui/src/pages/InstalledModels.jsx +++ b/core/http/react-ui/src/pages/InstalledModels.jsx @@ -1,3 +1,4 @@ +import ModelLaunch from '../addons/ModelLaunch' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useLocation, useNavigate } from 'react-router-dom' import { useTranslation } from 'react-i18next' @@ -33,13 +34,13 @@ import Icon from '../components/Icon' const USE_CASES = [ { cap: CAP_CHAT, labelKey: 'chat', route: id => `/app/chats?model=${encodeURIComponent(id)}` }, { cap: CAP_COMPLETION, labelKey: 'completion', route: id => `/app/chats?model=${encodeURIComponent(id)}`, hideIf: CAP_CHAT }, - { cap: CAP_IMAGE, labelKey: 'image', route: id => `/app/image/${encodeURIComponent(id)}` }, - { cap: CAP_VIDEO, labelKey: 'video', route: id => `/app/video/${encodeURIComponent(id)}` }, - { cap: CAP_TTS, labelKey: 'tts', route: id => `/app/tts/${encodeURIComponent(id)}` }, - { cap: CAP_TRANSCRIPT, labelKey: 'transcribe', route: () => '/app/talk' }, - { cap: CAP_SOUND_GENERATION, labelKey: 'sound', route: id => `/app/sound/${encodeURIComponent(id)}` }, - { cap: CAP_FACE_RECOGNITION, labelKey: 'face', route: id => `/app/face/${encodeURIComponent(id)}` }, - { cap: CAP_SPEAKER_RECOGNITION, labelKey: 'voice', route: id => `/app/voice/${encodeURIComponent(id)}` }, + { cap: CAP_IMAGE, labelKey: 'image' }, + { cap: CAP_VIDEO, labelKey: 'video' }, + { cap: CAP_TTS, labelKey: 'tts' }, + { cap: CAP_TRANSCRIPT, labelKey: 'transcribe' }, + { cap: CAP_SOUND_GENERATION, labelKey: 'sound' }, + { cap: CAP_FACE_RECOGNITION, labelKey: 'face' }, + { cap: CAP_SPEAKER_RECOGNITION, labelKey: 'voice' }, { cap: CAP_EMBEDDINGS, labelKey: 'embeddings' }, { cap: CAP_RERANK, labelKey: 'rerank' }, { cap: CAP_VAD, labelKey: 'vad' }, @@ -486,18 +487,7 @@ export default function InstalledModels({
{t('lifecycle.open.title')}
- {useCases.map(useCase => useCase.route ? ( - - ) : ( - {t(`lifecycle.open.${useCase.labelKey}`)} - ))} + {useCases.map(useCase => )}
)} diff --git a/core/http/react-ui/src/pages/Models.jsx b/core/http/react-ui/src/pages/Models.jsx index 147a5d6c6..db902748d 100644 --- a/core/http/react-ui/src/pages/Models.jsx +++ b/core/http/react-ui/src/pages/Models.jsx @@ -1,3 +1,4 @@ +import ModelLaunch from '../addons/ModelLaunch' // eslint-disable-next-line no-unused-vars import { useState, useCallback, useEffect, useLayoutEffect, useRef, Suspense } from 'react' // eslint-disable-next-line no-unused-vars @@ -1702,7 +1703,7 @@ function DiscoverDetail({ const ledgerFit = fitFor(vramBytes, budget, ramAvailable) const contextLabel = CONTEXT_LABELS[CONTEXT_SIZES.indexOf(contextSize)] const headroom = totalGpuMemory > 0 && vramBytes ? totalGpuMemory * 0.95 - vramBytes : null - const openUseCase = modelUseCases(installedProfile).find(useCase => useCase.route) + const openUseCases = modelUseCases(installedProfile) const leaves = leavesFree(disk, estimate?.sizeBytes) const canInstall = !installing && !model.installed @@ -1736,12 +1737,7 @@ function DiscoverDetail({ ) : model.installed ? ( <> - {openUseCase && ( - - )} + {openUseCases.map(useCase => )} diff --git a/core/http/react-ui/src/router.jsx b/core/http/react-ui/src/router.jsx index ba5f3cb5d..aac445621 100644 --- a/core/http/react-ui/src/router.jsx +++ b/core/http/react-ui/src/router.jsx @@ -169,7 +169,7 @@ import HubLayout from './components/hub/HubLayout' import { AddonRegistryProvider } from './addons/registry' import { useAuth } from './context/AuthContext' import { NavigationProvider, useNavigation } from './context/NavigationContext' -import { addonPageAddress, bareChatsRedirect, chatsRedirect, studioAddress, studioRedirect, talkAddress } from './utils/legacyRoutes' +import { bareChatsRedirect, chatsRedirect } from './utils/legacyRoutes' import { legacyAddonsHub, operateHub } from './components/hub/hubConfig' import { useHubAuth } from './components/hub/useHubAuth' @@ -199,63 +199,16 @@ function AgentRedirect() { return } -// An old generator link opens the same generator inside the Studio add-on. The -// model, the query string and the hash are kept. -function StudioTabRedirect({ legacy }) { - const { model } = useParams() - const { search, hash } = useLocation() - return -} - -// /app/studio[/tab[/model]] is a page of the Studio add-on now. -function StudioRedirect() { - const { tab, model } = useParams() - const { search, hash } = useLocation() - return -} - -// /app/talk is a page of the Talk add-on now. -function TalkRedirect() { - const { search, hash } = useLocation() - return -} - function BrowseRedirect() { const { '*': splat } = useParams() return } -// The pages of Face, Voice, Fine-tuning and Quantization live only in their -// add-ons. The old address opens the add-on page; when the add-on is not -// installed or not ready, the add-on shell says so. withModel carries the -// :model segment of the old route as ?model=: an add-on path segment -// refuses "/", ":" and "%" and long ids (resolveSubpath), so a model id cannot -// travel in the path. -function AddonRedirect({ addon, page, withModel }) { - const { search, hash } = useLocation() - const { model } = useParams() - return -} - function Admin({ children }) { return {children} } -// The layout of the add-on routes: the Build hub bar under the legacy switch, -// the bare page otherwise. -// With add-ons off (features.addons === false) the add-on addresses and the -// old fine-tuning and quantization addresses lead back to /app. An absent key -// counts as on. -// Home then says that add-ons are off and how to turn them on (state.addonsOff -// names the add-on the address was for). -function AddonsOffRedirect({ addon, children }) { - const { features, featuresLoaded } = useHubAuth() - if (!featuresLoaded) return null - if (features.addons === false) return - return children -} - function AddonsShell() { const { features, featuresLoaded } = useHubAuth() const outletContext = useOutletContext() @@ -323,27 +276,6 @@ const appChildren = [ { path: 'triggers/:id/edit', element: }, { path: 'triggers/:id', element: }, { path: 'agent-tasks', element: }, - { path: 'image', element: }, - { path: 'image/:model', element: }, - { path: 'video', element: }, - { path: 'video/:model', element: }, - { path: '3d', element: }, - { path: '3d/:model', element: }, - { path: 'tts', element: }, - { path: 'tts/:model', element: }, - { path: 'sound', element: }, - { path: 'sound/:model', element: }, - { path: 'diarization', element: }, - { path: 'diarization/:model', element: }, - { path: 'transform', element: }, - { path: 'transform/:model', element: }, - // Studio and Talk are add-ons; the old addresses open them. When the add-on - // is not installed the add-on page says so. - { path: 'studio', element: }, - { path: 'studio/:tab', element: }, - // A generator opened on one model: /app/studio/images/. - { path: 'studio/:tab/:model', element: }, - { path: 'talk', element: }, { path: 'account', element: }, // The Add-ons drawer. A viewer without the addons feature still reaches it // when a built-in tile is theirs to open (the built-in list is empty today). @@ -364,13 +296,6 @@ const appChildren = [ { path: 'build', element: }, { path: 'collections', element: }, { path: 'collections/:name', element: }, - { path: 'fine-tune', element: }, - { path: 'quantize', element: }, - { path: 'face', element: }, - { path: 'face/:model', element: }, - { path: 'voice', element: }, - { path: 'voice/:model', element: }, - { path: 'voice-library', element: }, { path: 'import-model', element: }, // The user's MCP connectors have a page of their own; the old address was a // tab of the messaging connectors. @@ -420,7 +345,6 @@ const appChildren = [ children: [{ path: ':id', element: }], }, { path: 'manage', element: }, - { path: 'voice-library/new', element: }, { path: 'model-editor', element: }, { path: 'model-editor/:name', element: }, { path: 'ui-catalog', element: }, diff --git a/core/http/react-ui/src/utils/addonUpgrades.js b/core/http/react-ui/src/utils/addonUpgrades.js index 139d643fa..e02222bb1 100644 --- a/core/http/react-ui/src/utils/addonUpgrades.js +++ b/core/http/react-ui/src/utils/addonUpgrades.js @@ -70,6 +70,16 @@ export function createUpgradeStore({ now = Date.now, waits, sleep, book = create }, }) + const expireAttempt = (current, { jobID, addon, key, operationId }) => { + if (current()) return false + // A later session may already hold the same add-on and generation key. + if (book.pending(addon.id)?.operationId === operationId) book.resolve(addon.id, key) + if (snapshot.live.some(op => op.jobID === jobID)) { + emit({ ...snapshot, live: snapshot.live.filter(op => op.jobID !== jobID) }) + } + return true + } + const store = { bind(api, principal, persistence) { if (scope === principal && storage === persistence) return @@ -116,7 +126,8 @@ export function createUpgradeStore({ now = Date.now, waits, sleep, book = create // Upgrades one installed add-on (the record of `addons-installed`). Never // throws: resolves { ok, message, outcome } where message is the key of the // words of a failure (see updateErrorKey) and is empty on success. - async start(api, addon) { + async start(api, addon, { current = () => true } = {}) { + if (!current()) return null const view = updateView(addon && addon.update) if (!view.canUpgrade || !addon.digest) return { ok: false, outcome: 'invalid', message: 'incompatible' } if (snapshot.live.some(op => op.addonId === addon.id && op.state !== 'failed')) return { ok: false, outcome: 'busy', message: 'operation_conflict' } @@ -150,15 +161,21 @@ export function createUpgradeStore({ now = Date.now, waits, sleep, book = create ...snapshot, live: [...withoutOld, { ...base, jobID, operationId, state: 'running', phase: 'submitting', progress: 0, cancellable: false, error: '', errorKey: '' }], }) - return store.follow(api, { jobID, addon, operationId, key, controller, submit: true }) + return store.follow(api, { jobID, addon, operationId, key, controller, submit: true, current }) }, // Runs the submit (or, with submit false, only reads the earlier operation) // and turns its end into the entry of a list. - async follow(api, { jobID, addon, operationId, key, controller, submit }) { + async follow(api, { jobID, addon, operationId, key, controller, submit, current = () => true }) { const followingEpoch = epoch const signal = controller.signal - const tracked = trackPhases(api, jobID) + const tracked = trackPhases({ ...api, operationStatus: async (...args) => { + if (!current()) throw new DOMException('Aborted', 'AbortError') + const status = await api.operationStatus(...args) + if (!current()) throw new DOMException('Aborted', 'AbortError') + return status + } }, jobID) + const expired = () => expireAttempt(current, { jobID, addon, key, operationId }) try { let outcome // While the add-on service does not answer, the entry says that it @@ -180,6 +197,7 @@ export function createUpgradeStore({ now = Date.now, waits, sleep, book = create } else { outcome = (await settleOperation(tracked, { operationId, addonId: addon.id, signal, waits: pollWaits, sleep: sleepOf(jobID), onWait })).outcome } + if (expired()) return null let verified if (outcome === 'completed') { try { @@ -191,10 +209,12 @@ export function createUpgradeStore({ now = Date.now, waits, sleep, book = create } } if (followingEpoch !== epoch) return { ok: false, outcome: "detached", message: "" } + if (expired()) return null return store.conclude(jobID, addon, key, effectOutcome(outcome, verified), operationId) } catch (e) { + if (expired()) return null if (followingEpoch !== epoch) return { ok: false, outcome: "detached", message: "" } - if (e && e.name === 'AbortError' && cancelled.has(jobID)) return store.afterCancel(api, { jobID, addon, operationId, key }) + if (e && e.name === 'AbortError' && cancelled.has(jobID)) return store.afterCancel(api, { jobID, addon, operationId, key, current }) const kind = e && e.kind ? e.kind : 'unavailable' if (kind === 'denied' || kind === 'invalid' || kind === 'auth') book.resolve(addon.id, key) const errorKey = updateErrorKey(e && e.kind ? e : { kind }) @@ -224,12 +244,15 @@ export function createUpgradeStore({ now = Date.now, waits, sleep, book = create }, // After the request was aborted by a cancel: what did the owner end up with? - async afterCancel(api, { jobID, addon, operationId, key }) { + async afterCancel(api, { jobID, addon, operationId, key, current = () => true }) { cancelled.delete(jobID) + const expired = () => expireAttempt(current, { jobID, addon, key, operationId }) + if (expired()) return null let outcome = 'unknown' try { outcome = (await settleOperation(api, { operationId, addonId: addon.id, waits: [] })).outcome } catch { /* an unreadable status stays unknown */ } + if (expired()) return null const op = snapshot.live.find(o => o.jobID === jobID) if (outcome === 'completed') { book.resolve(addon.id, key) @@ -267,7 +290,8 @@ export function createUpgradeStore({ now = Date.now, waits, sleep, book = create // Try a failed upgrade again. An unknown outcome is only read, never // submitted a second time; anything else starts from the installed record // as it is now, so a changed generation or candidate is honoured. - async retry(api, jobID) { + async retry(api, jobID, { current = () => true } = {}) { + if (!current()) return null const op = snapshot.live.find(o => o.jobID === jobID) if (!op || op.state !== 'failed') return { ok: false, outcome: 'invalid', message: 'operation_conflict' } const held = book.pending(op.addonId) @@ -276,7 +300,7 @@ export function createUpgradeStore({ now = Date.now, waits, sleep, book = create controllers.set(jobID, controller) patch(jobID, { state: 'running', cancellable: false, error: '', errorKey: '' }) const addon = op.addonRecord || { id: op.addonId, generation: Number(held.key.split(':')[2]) || 0 } - return store.follow(api, { jobID, addon, operationId: held.operationId, key: held.key, controller, submit: false }) + return store.follow(api, { jobID, addon, operationId: held.operationId, key: held.key, controller, submit: false, current }) } let fresh try { @@ -284,8 +308,9 @@ export function createUpgradeStore({ now = Date.now, waits, sleep, book = create } catch (e) { return { ok: false, outcome: 'unavailable', message: updateErrorKey(e) } } + if (!current()) return null if (!fresh) return { ok: false, outcome: 'invalid', message: 'stale_installed' } - return store.start(api, fresh) + return store.start(api, fresh, { current }) }, dismiss(jobID) { diff --git a/core/http/react-ui/src/utils/chatCommands.js b/core/http/react-ui/src/utils/chatCommands.js index c02686c27..602e0ba12 100644 --- a/core/http/react-ui/src/utils/chatCommands.js +++ b/core/http/react-ui/src/utils/chatCommands.js @@ -1,23 +1,5 @@ -// Whether a chat can run commands and code, from the "commands" field of the -// chat's add-on list (GET /api/chats/:id/addons). The server computes it from -// the tools the model gets at the next turn: -// available a tool runs commands -// off the Sandbox add-on is installed but off for this chat -// unavailable the Sandbox add-on is installed but cannot run commands here -// none no add-on that runs commands is installed for the user -// When commands_provider is builtin, bash runs directly on this machine (no -// Sandbox serves it); commandsState turns that into one of three states of its -// own, so the notice says that commands run here, not isolated: -// host no Sandbox -// host-off the Sandbox is off for this chat -// host-unavailable the Sandbox cannot run (commands_fallback_reason) -// An older server sends no field: then nothing is shown. - -export const SANDBOX_ADDON_ID = 'sandbox' - -// Where an administrator installs the Sandbox. The first-start wizard step -// "Run code safely" links here too. -export const SANDBOX_INSTALL_LINK = `/app/addons?tab=browse&q=${SANDBOX_ADDON_ID}` +// Command availability and isolation come from the selected provider DTO, not +// an add-on's private discovery response or its name. // commandsState reads the commands fields of an add-on list as one state and // its reason. @@ -30,6 +12,15 @@ export function commandsState(res) { if (res.commands_fallback) return { state: 'host-unavailable', reason: why } return { state: 'host', reason: '' } } + const observation = res?.execution_status + if (state === 'available') { + const isolation = observation?.isolation || res?.commands_isolation + if (observation && observation.readiness !== 'available') return { state: 'unknown', reason: plainExplanation(observation.explanation) } + if (isolation === 'host') return { state: 'provider-host', reason: plainExplanation(observation?.explanation) } + if (isolation !== 'isolated') return { state: 'unknown', reason: plainExplanation(observation?.explanation) } + } + if (state === 'none' && observation) return { state: 'unknown', reason: plainExplanation(observation.explanation) } + if (state === 'unavailable' && (observation || res?.commands_provider)) return { state: 'provider-unavailable', reason: plainExplanation(observation?.explanation || reason) } return { state, reason } } @@ -38,14 +29,21 @@ export function commandsState(res) { // person who is not one is told to ask one, unless the fix is theirs. export function commandsNotice(commands, { isAdmin = false, reason = '' } = {}) { switch (commands) { + case 'provider-host': + case 'unknown': + case 'loading': + case 'provider-unavailable': + case 'switched_off': + return { state: commands, titleKey: commands === 'provider-host' ? 'hostTitle' : 'title', textKey: commands === 'switched_off' ? 'disabled' : commands === 'provider-host' ? 'providerHost' : commands === 'provider-unavailable' ? 'providerUnavailable' : 'unknown', reason: plainExplanation(reason), tone: 'warn', link: null, askKey: null } + case 'off': - return { state: 'off', textKey: 'off', link: null, askKey: null, reason: '' } + return { state: 'off', textKey: 'disabled', link: null, askKey: null, reason: '' } case 'unavailable': return { state: 'unavailable', - textKey: reason ? 'unavailableWhy' : 'unavailable', + textKey: 'providerUnavailable', reason: typeof reason === 'string' ? reason.slice(0, 200) : '', - link: isAdmin ? { to: `/app/addons?detail=${SANDBOX_ADDON_ID}`, labelKey: 'openSandbox' } : null, + link: null, askKey: isAdmin ? null : 'askFix', } case 'none': @@ -53,25 +51,14 @@ export function commandsNotice(commands, { isAdmin = false, reason = '' } = {}) state: 'none', textKey: 'none', reason: '', - link: isAdmin ? { to: SANDBOX_INSTALL_LINK, labelKey: 'install' } : null, - askKey: isAdmin ? null : 'askInstall', + link: null, + askKey: null, } - // Commands run, but on this machine: a quiet notice that stays. + // Actual host execution never implies that one particular add-on is the fix. case 'host': - return { - state: 'host', titleKey: 'hostTitle', textKey: 'host', reason: '', tone: 'warn', - link: isAdmin ? { to: SANDBOX_INSTALL_LINK, labelKey: 'install' } : null, - askKey: isAdmin ? null : 'askInstall', - } case 'host-off': - return { state: 'host-off', titleKey: 'hostTitle', textKey: 'hostOff', reason: '', tone: 'warn', link: null, askKey: null } case 'host-unavailable': - return { - state: 'host-unavailable', titleKey: 'hostTitle', textKey: reason ? 'hostUnavailableWhy' : 'hostUnavailable', - reason: typeof reason === 'string' ? reason.slice(0, 200) : '', tone: 'warn', - link: isAdmin ? { to: `/app/addons?detail=${SANDBOX_ADDON_ID}`, labelKey: 'openSandbox' } : null, - askKey: isAdmin ? null : 'askFix', - } + return { state: commands, titleKey: 'hostTitle', textKey: 'providerHost', reason: plainExplanation(reason), tone: 'warn', link: null, askKey: null } default: return null } @@ -106,7 +93,7 @@ export function writeHidden(storage, state) { export function providerTag(card) { if (!card || typeof card !== 'object') return null if (card.kind === 'tool_provider' && card.id) { - return card.id === SANDBOX_ADDON_ID ? { key: 'sandbox' } : { key: 'addon', values: { name: String(card.id) } } + return { key: 'addon', values: { name: String(card.id) } } } if (card.kind === 'host_exec') { switch (card.id) { @@ -126,3 +113,13 @@ export function providerTag(card) { export function consentExpired(e) { return e?.status === 410 || e?.body?.error?.type === 'consent_expired' } + +// Plain text only. React text children escape markup; never pass this to HTML/Markdown. +export function plainExplanation(value) { + return typeof value === 'string' ? [...value.replace(/[\u0000-\u001f\u007f\u202a-\u202e\u2066-\u2069]/g, '')].slice(0, 200).join('') : '' +} + +export function noticeIdentity(res, chat, actor) { + const v = res?.execution_status || {} + return JSON.stringify([actor, chat, res?.commands_provider, v.generation, v.incarnation, res?.commands, v.readiness, v.isolation || res?.commands_isolation]) +} diff --git a/core/http/react-ui/src/utils/chatConnectors.js b/core/http/react-ui/src/utils/chatConnectors.js index 3d1185c21..88f16ea8c 100644 --- a/core/http/react-ui/src/utils/chatConnectors.js +++ b/core/http/react-ui/src/utils/chatConnectors.js @@ -1,5 +1,5 @@ // Helpers of the connectors page. No React, so a node test drives them. -import { addonPageAddress } from './legacyRoutes.js' +import { addonPageAddress } from '../addons/addresses.js' // Groups the bindings of the user by the connector add-on that serves them. A // kind that a running add-on serves gets a group with the address of the diff --git a/core/http/react-ui/src/utils/chatEgress.js b/core/http/react-ui/src/utils/chatEgress.js index 2d1716f46..59e228cc1 100644 --- a/core/http/react-ui/src/utils/chatEgress.js +++ b/core/http/react-ui/src/utils/chatEgress.js @@ -37,7 +37,7 @@ export function presetChoices(egress) { export function networkMode(egress) { if (!egress || !egress.loaded) return 'hidden' if (egress.applies) return egress.addon ? 'row' : 'hidden' - return egress.applies_reason === 'host' ? 'host' : 'hidden' + return egress.applies_reason === 'host' ? 'host' : 'unverified' } export const hostPort = (c) => `${c.host}:${c.port}` diff --git a/core/http/react-ui/src/utils/legacyRoutes.js b/core/http/react-ui/src/utils/legacyRoutes.js index 9e4208567..8c27a2812 100644 --- a/core/http/react-ui/src/utils/legacyRoutes.js +++ b/core/http/react-ui/src/utils/legacyRoutes.js @@ -1,52 +1,3 @@ -// The old routes of the migration table and where they go now. Redirects stay -// in the app until a separate decision removes them. - -// Old generator route segment -> Studio tab key (the tabs of the Studio add-on). -export const STUDIO_TABS = Object.freeze({ - image: 'images', - video: 'video', - '3d': 'threed', - tts: 'tts', - sound: 'sound', - diarization: 'diarization', - transform: 'transform', -}) - -// Studio and Talk are add-ons. Their pages live under /app/addons//; -// the tab is one path segment and the model travels as ?model=, because -// an add-on path segment cannot hold "/" or ":" (see resolveSubpath in the SDK). -export const STUDIO_PAGE = '/app/addons/studio/studio' -export const TALK_PAGE = '/app/addons/talk/talk' - -function addonAddress(base, { tab, model, search = '', hash = '' } = {}) { - const params = new URLSearchParams(search) - if (model) params.set('model', model) - const query = params.toString() - return `${base}${tab ? `/${encodeURIComponent(tab)}` : ''}${query ? `?${query}` : ''}${hash}` -} - -// Where an old address of a page that moved to an add-on opens now: the page -// path is kept as is (it may hold "/"), the model travels as ?model=. -export function addonPageAddress({ addon, page, model, search = '', hash = '' }) { - return addonAddress(`/app/addons/${addon}/${page}`, { model, search, hash }) -} - -// Where an old /app/studio[/tab[/model]] link opens now. The model, the query -// string and the hash are kept. -export function studioAddress(args) { - return addonAddress(STUDIO_PAGE, args) -} - -// Where an old /app/talk link opens now. -export function talkAddress(args) { - return addonAddress(TALK_PAGE, args) -} - -// The target of an old generator route. -export function studioRedirect(legacy, model, search = '', hash = '') { - return studioAddress({ tab: STUDIO_TABS[legacy], model, search, hash }) -} - // The old chat page became Home, the front door where a chat starts, and the // old agent-jobs pages became the triggers. The pages of the chats that moved // back to the agents, skills and triggers addresses redirect too. This is @@ -83,21 +34,7 @@ export const MIGRATION_ROUTES = Object.freeze([ { from: '/app/skills', to: '/app/skills', kind: 'same' }, { from: '/app/models', to: '/app/models', kind: 'same' }, { from: '/app/settings', to: '/app/settings', kind: 'same' }, - { from: '/app/studio', to: '/app/addons/studio/studio', kind: 'addon-page' }, - { from: '/app/talk', to: '/app/addons/talk/talk', kind: 'addon-page' }, - { from: '/app/image', to: '/app/addons/studio/studio/images', kind: 'studio-tab' }, - { from: '/app/video', to: '/app/addons/studio/studio/video', kind: 'studio-tab' }, - { from: '/app/3d', to: '/app/addons/studio/studio/threed', kind: 'studio-tab' }, - { from: '/app/tts', to: '/app/addons/studio/studio/tts', kind: 'studio-tab' }, - { from: '/app/sound', to: '/app/addons/studio/studio/sound', kind: 'studio-tab' }, - { from: '/app/diarization', to: '/app/addons/studio/studio/diarization', kind: 'studio-tab' }, - { from: '/app/transform', to: '/app/addons/studio/studio/transform', kind: 'studio-tab' }, - { from: '/app/face', to: '/app/addons/face/recognition', kind: 'addon-page' }, - { from: '/app/voice', to: '/app/addons/voice/voice/recognition', kind: 'addon-page' }, - { from: '/app/voice-library', to: '/app/addons/voice/voice/library', kind: 'addon-page' }, - { from: '/app/voice-library/new', to: '/app/addons/voice/voice/library/new', kind: 'addon-page' }, - { from: '/app/fine-tune', to: '/app/addons/fine-tuning/jobs', kind: 'addon-page' }, - { from: '/app/quantize', to: '/app/addons/quantization/jobs', kind: 'addon-page' }, + ]) // /app/chats without an id. Opened from inside the app with navigation state diff --git a/core/http/react-ui/src/utils/sandboxStatus.js b/core/http/react-ui/src/utils/sandboxStatus.js deleted file mode 100644 index f75364164..000000000 --- a/core/http/react-ui/src/utils/sandboxStatus.js +++ /dev/null @@ -1,26 +0,0 @@ -// The state of the Sandbox add-on for the Tools picker of a chat. The add-on's -// status read (open to every user) says whether it can run commands, with -// which backend, and whether it runs them on the host with no isolation. The -// picker says it in plain words next to the add-on's switch. - -export const SANDBOX_ADDON = 'sandbox' - -// { state: 'isolated' | 'host' | 'cannot' | 'starting' | 'unknown', backend, reason } from -// the payload of the status read; anything unreadable is 'unknown'. -export function sandboxState(status) { - if (!status || typeof status !== 'object' || status.ok !== true) return { state: 'unknown', backend: '', reason: '' } - const text = (v) => (typeof v === 'string' ? v.slice(0, 200) : '') - const backend = text(status.active) - if (status.canRun !== true) { - const why = Array.isArray(status.backends) ? status.backends.find(b => b && b.usable !== true && text(b.reason)) : null - return { state: 'cannot', backend: '', reason: text(status.reason) || (why ? text(why.reason) : '') } - } - if (status.hostNotice === true || backend === 'host') return { state: 'host', backend, reason: '' } - return { state: 'isolated', backend, reason: '' } -} - -// startingState is the state while the add-on service does not answer yet: -// the page reads again by itself in retryMs. -export function startingState(retryMs) { - return { state: 'starting', backend: '', reason: '', retryMs: Math.max(0, Math.round(retryMs || 0)) } -} diff --git a/core/http/react-ui/src/utils/slashCommands.js b/core/http/react-ui/src/utils/slashCommands.js index 70fadd1db..4e0b30284 100644 --- a/core/http/react-ui/src/utils/slashCommands.js +++ b/core/http/react-ui/src/utils/slashCommands.js @@ -1,3 +1,4 @@ +import { projectHomeCommands } from '../addons/homeCommands.js' // The slash commands of the composer, on Home and in a chat: one registry. // Pure functions, tested in tests/unit/slashcommands.test.mjs. // @@ -138,7 +139,7 @@ const RESERVED = reservedNames() // The skills a chat can run now: switched on and not replaced. export function usableSkills(skills) { - return (skills || []).filter((s) => s && s.name && s.enabled !== false && !s.shadowed && !s.invalid) + return (skills || []).filter((s) => s && typeof s.name === 'string' && /^[a-z][a-z0-9-]{0,63}$/.test(s.name) && s.enabled !== false && !s.shadowed && !s.invalid) } // The items of /skill: every usable skill. One whose name is a command is @@ -198,12 +199,14 @@ export function availableCommands(surface, ctx = {}) { }) } const shared = SHARED_COMMANDS.filter((a) => (!a.where || a.where.includes(surface)) && (!a.needs || a.needs(ctx))) - return [...own, ...shared, ...skillCommands(ctx.skills)] + const addons = surface === 'home' && ctx.addonRegistry + ? projectHomeCommands(ctx.addonRegistry, { nativeNames: RESERVED, skillNames: usableSkills(ctx.skills).map(s => s.name), language: ctx.addonRegistry.language }).commands : [] + return [...own, ...shared, ...addons, ...skillCommands(ctx.skills)] } // The groups of a surface, in menu order. export function commandGroups(surface) { - return [...(surface === 'home' ? SLASH_GROUPS : CHAT_SLASH_GROUPS), ...COMMAND_GROUPS] + return [...(surface === 'home' ? [...SLASH_GROUPS, 'addons'] : CHAT_SLASH_GROUPS), ...COMMAND_GROUPS] } // What the box asks of the menu. diff --git a/core/http/react-ui/tests/unit/addon-session.test.mjs b/core/http/react-ui/tests/unit/addon-session.test.mjs new file mode 100644 index 000000000..359485e73 --- /dev/null +++ b/core/http/react-ui/tests/unit/addon-session.test.mjs @@ -0,0 +1,244 @@ +// SPDX-License-Identifier: MIT +import test from 'node:test' +import assert from 'node:assert/strict' +import { hookHarness } from './chatcommands-lifecycle.mjs' +import { createActionBook, effectOutcome } from '../../src/components/addons/actionBook.js' +import { runLifecycle, settleOperation } from '../../src/components/addons/lifecycleFlow.js' +import { newOperationId } from '../../src/components/catalog/catalogState.js' +const deferred = () => { let resolve; const promise = new Promise(r => { resolve = r }); return { promise, resolve } } + +for (const verb of ['start', 'stop']) test(`pending ${verb} cannot verify or publish after actual auth epoch changes before cleanup`, async () => { + let auth = { user: { id: 'admin' }, isAdmin: true, sessionEpoch: 1, captureSessionAuthority: () => () => true } + const pending = deferred() + let submitted = 0, verified = 0, settled = 0 + const h = hookHarness('../../src/components/addons/useAddonActions.js', 'useAddonActions', { + useAuth: () => auth, createActionBook, effectOutcome, runLifecycle, settleOperation, runInstallAndTurnOn() {}, + }) + const render = () => h.render({}, { onSettled: () => settled++ }) + const old = render(); h.flush() + const args = { addonId: 'notes', submit: () => { submitted++; return pending.promise }, verify: async () => { verified++; return true } } + const work = old.run(`${verb}:notes:1`, args) + auth = { ...auth, sessionEpoch: 2 } + const next = render() // deliberately do not flush cleanup + assert.deepEqual(next.state, {}) + assert.equal(await old.run('late', args), null) + pending.resolve({ phase: 'completed' }); await work + assert.equal(submitted, 1) + assert.equal(verified, 0) + assert.equal(settled, 0) + assert.deepEqual(render().state, {}) +}) + +test('pending update check and upgrade cannot publish to the next actor before cleanup', async () => { + let auth = { user: { id: 'admin' }, isAdmin: true, sessionEpoch: 1, captureSessionAuthority: () => () => true } + const pending = deferred(), updating = deferred() + let checked = 0, changed = 0, started = 0 + const api = { updates: { check: () => { checked++; return pending.promise } } } + const h = hookHarness('../../src/components/addons/useAddonUpgrade.js', 'useAddonUpgrade', { + useAuth: () => auth, newOperationId, useAddonUpgrades: () => ({ live: [], records: [] }), + addonUpgrades: { start: () => { started++; return updating.promise } }, + }) + const render = () => h.render(api, { onChanged: () => changed++ }) + const old = render(); h.flush() + const work = old.check({ id: 'notes' }), upgrade = old.upgrade({ id: 'notes' }) + auth = { ...auth, user: { id: 'other' }, isAdmin: true, sessionEpoch: 2 } + assert.deepEqual(render().checks, {}) + await old.check({ id: 'notes' }); await old.upgrade({ id: 'notes' }) + pending.resolve({ state: 'update-available' }); updating.resolve({ ok: true }) + await Promise.all([work, upgrade]) + assert.equal(checked, 1); assert.equal(started, 1); assert.equal(changed, 0) + assert.deepEqual(render().checks, {}) +}) + +test('upgrade store drops an expired completion without installed verification or cached record', async () => { + const { createUpgradeStore } = await import('../../src/utils/addonUpgrades.js') + const pending = deferred() + let live = true, verified = 0, submitted = 0 + const store = createUpgradeStore() + const api = { updates: { upgrade: () => { submitted++; return pending.promise } }, installed: async () => { verified++; return [] } } + const addon = { id: 'notes', digest: 'old', generation: 1, update: { state: 'update-available', digest: 'new' } } + const work = store.start(api, addon, { current: () => live }) + assert.equal(submitted, 1) + live = false + assert.equal(await store.start(api, addon, { current: () => live }), null) + pending.resolve({ phase: 'completed' }); await work + assert.equal(verified, 0) + assert.deepEqual(store.getSnapshot(), { live: [], records: [] }) +}) + +for (const switchUser of [false, true]) test(`installed records reject late admin read on ${switchUser ? 'account change' : 'actual auth refresh'} before cleanup`, async () => { + const { readFileSync } = await import('node:fs') + const ts = (await import('typescript')).default + let auth = { user: { id: 'admin' }, isAdmin: true, sessionEpoch: 1, hasFeature: () => true } + const pending = deferred() + let reads = 0 + const api = { apps: { list: async () => [{ id: 'notes', generation: 1 }] }, installed: () => { reads++; return pending.promise } } + const text = readFileSync(new URL('../../src/components/addons/AppsView.jsx', import.meta.url), 'utf8') + // Full Apps body/effects; JSX is compiled, leaf hooks are external boundaries. + const names = [...text.matchAll(/^import \{ ([^}]+) \} from/gm)].flatMap(m => m[1].split(',').map(n => n.trim())) + const defaults = [...text.matchAll(/^import (\w+) from/gm)].map(m => m[1]) + const h = hookHarness('../../src/components/addons/AppsView.jsx', 'AppsView', { + ...Object.fromEntries([...names, ...defaults].filter(n => !n.startsWith('use')).map(n => [n, () => null])), + useAuth: () => auth, useContext: () => null, useTranslation: () => ({ t: k => k, i18n: {} }), + useAddonActions: () => ({}), useAddonUpgrade: () => ({ checks: {}, records: [] }), useBulkUpgrade: () => ({}), + useOutletContext: () => ({}), useNavigation: () => ({ pins: [] }), useHubAuth: () => ({ features: {} }), + useSearchParams: () => [new URLSearchParams(), () => {}], + buildTiles: () => [], resolvePins: () => [], filterTiles: () => [], filterByState: () => [], sortByAttention: () => [], + updateView: () => ({ state: '' }), + FEATURE_ADDONS: 'addons', RegistryContext: {}, setTimeout: () => 1, clearTimeout() {}, + }, source => ts.transpileModule(source.replace('export default function', 'function').split(" if (state.status === 'loading')")[0] + 'return { installedInfo }\n}', { compilerOptions: { jsx: ts.JsxEmit.React, target: ts.ScriptTarget.ES2022 } }).outputText) + h.render({ api }); h.flush() + await new Promise(r => setImmediate(r)) + h.render({ api }); h.flush() + assert.equal(reads, 1) + auth = { ...auth, user: { id: switchUser ? 'other' : 'admin' }, sessionEpoch: 2 } + assert.deepEqual(h.render({ api }).installedInfo, {}) + pending.resolve([{ id: 'notes', privateValue: 'old-admin-record' }]) + await new Promise(r => setImmediate(r)) + assert.deepEqual(h.render({ api }).installedInfo, {}) +}) + +for (const phase of ['completed', 'failed', 'intent', 'unreadable']) test(`observer drops ${phase} after auth epoch changes during status read`, async () => { + const { createUpgradeStore } = await import('../../src/utils/addonUpgrades.js') + const status = deferred(), reading = deferred() + let epoch = 1, verified = 0, reads = 0 + const store = createUpgradeStore({ waits: [] }) + const api = { + updates: { upgrade: async () => ({ phase: 'accepted' }) }, + operationStatus: () => { reads++; reading.resolve(); return status.promise.then(value => { if (phase === 'unreadable') throw new Error('offline'); return value }) }, + installed: async () => { verified++; return [] }, + } + const addon = { id: 'notes', digest: 'old', generation: 1, update: { state: 'update-available', digest: 'new' } } + const work = store.start(api, addon, { current: () => epoch === 1 }) + await reading.promise + assert.equal(store.cancel(store.forAddon('notes').jobID), false, 'accepted owner work cannot be cancelled') + epoch = 2 + const publications = [] + store.subscribe(() => publications.push(store.getSnapshot())) + status.resolve({ phase }) + assert.equal(await work, null) + assert.equal(reads, 1) + assert.equal(verified, 0) + assert.deepEqual(store.getSnapshot(), { live: [], records: [] }) + assert.ok(publications.every(s => s.records.length === 0 && s.live.length === 0)) +}) + +test('observer does not read status when auth expires before acceptance returns', async () => { + const { createUpgradeStore } = await import('../../src/utils/addonUpgrades.js') + const store = createUpgradeStore({ waits: [] }), accepted = deferred() + let epoch = 1, reads = 0 + const api = { + updates: { upgrade: () => accepted.promise }, + operationStatus: async () => { reads++; return { phase: 'completed' } }, + } + const addon = { id: 'notes', digest: 'old', generation: 1, update: { state: 'update-available', digest: 'new' } } + const work = store.start(api, addon, { current: () => epoch === 1 }) + assert.equal(store.cancel(store.forAddon('notes').jobID), false, 'disconnect cannot prove preacceptance') + epoch = 2 + accepted.resolve({ phase: 'accepted' }) + assert.equal(await work, null) + assert.equal(reads, 0) + assert.deepEqual(store.getSnapshot(), { live: [], records: [] }) +}) + +test('expired observer cleanup preserves a newer attempt with the same action key', async () => { + const { createUpgradeStore } = await import('../../src/utils/addonUpgrades.js') + let id = 0, epoch = 1 + const book = createActionBook(() => `attempt-${++id}`) + const store = createUpgradeStore({ book, waits: [] }) + const status = deferred(), reading = deferred(), nextSubmit = deferred() + const addon = { id: 'notes', digest: 'old', generation: 1, update: { state: 'update-available', digest: 'new' } } + const api = { + updates: { upgrade: async () => ({ phase: 'accepted' }) }, + operationStatus: () => { reading.resolve(); return status.promise }, + } + const oldWork = store.start(api, addon, { current: () => epoch === 1 }) + const old = store.forAddon(addon.id) + await reading.promise + // Another follower concludes the old entry, allowing a new authorized action. + store.conclude(old.jobID, addon, 'upgrade:notes:1', 'failed', old.operationId) + epoch = 2 + const nextWork = store.start({ + updates: { upgrade: () => nextSubmit.promise }, + installed: async () => [{ ...addon, generation: 2 }], + }, addon, { current: () => epoch === 2 }) + const next = store.forAddon(addon.id), held = book.pending(addon.id) + assert.notEqual(next.operationId, old.operationId) + status.resolve({ phase: 'completed' }) + assert.equal(await oldWork, null) + assert.deepEqual(store.forAddon(addon.id), next) + assert.deepEqual(book.pending(addon.id), held) + assert.deepEqual(store.getSnapshot().records, []) + nextSubmit.resolve({ phase: 'completed' }) + assert.equal((await nextWork).ok, true) + assert.equal(store.getSnapshot().records[0].operationId, next.operationId) +}) + +// Execute the actual AuthProvider state transitions, not a test-owned epoch getter. +// Only its JSX boundary is replaced; fetch is the controlled network boundary. +async function providerAuthority() { + const ts = (await import('typescript')).default + let status = { authEnabled: true, user: { id: 'admin', role: 'admin' } } + const logoutReply = deferred() + const h = hookHarness('../../src/context/AuthContext.jsx', 'AuthProvider', { + apiUrl: x => x, addonUpgrades: (await import('../../src/utils/addonUpgrades.js')).createUpgradeStore(), + fetch: url => url.endsWith('/logout') ? logoutReply.promise : Promise.resolve({ json: async () => status }), + document: { cookie: '' }, window: { location: {} }, + }, source => ts.transpileModule(source.replace(/return \(\s* setImmediate(r)) + auth = h.render({ children: null }) + return { + get auth() { return auth }, + async change(kind) { + if (kind === 'unmount') { h.unmount(); return } + if (kind === 'logout') { void auth.logout(); return } // network remains pending + if (kind === 'account') status = { ...status, user: { id: 'other', role: 'admin' } } + if (kind === 'feature') status = { ...status, user: { id: 'admin', role: 'user', permissions: {} } } + await auth.refresh() + // Do not render the provider or any consumer: expiry must be synchronous. + }, + } +} + +for (const path of ['observer', 'completion', 'verification']) { + for (const change of ['same-auth', 'refresh', 'account', 'feature', 'logout', 'unmount']) { + test(`background ${path} after origin unmount uses actual provider authority on ${change}`, async () => { + const { createUpgradeStore } = await import('../../src/utils/addonUpgrades.js') + const provider = await providerAuthority() + const store = createUpgradeStore({ waits: [] }) + const reply = deferred(), reading = deferred(), verification = deferred(), verifying = deferred() + let verified = 0, changed = 0 + const addon = { id: 'notes', digest: 'old', generation: 1, update: { state: 'update-available', digest: 'new' } } + const api = { + updates: { upgrade: () => path === 'observer' ? Promise.resolve({ phase: 'accepted' }) : reply.promise }, + operationStatus: () => { reading.resolve(); return reply.promise }, + installed: () => { verified++; verifying.resolve(); return verification.promise }, + } + const view = hookHarness('../../src/components/addons/useAddonUpgrade.js', 'useAddonUpgrade', { + useAuth: () => provider.auth, newOperationId, addonUpgrades: store, + useAddonUpgrades: () => store.getSnapshot(), + }) + const origin = view.render(api, { onChanged: () => changed++ }); view.flush() + const work = origin.upgrade(addon) + view.unmount() // navigation must not revoke a valid background upgrade + if (path === 'observer') { + await reading.promise + assert.equal(store.cancel(store.forAddon(addon.id).jobID), false) + } + if (path === 'verification') { + reply.resolve({ phase: 'completed' }); await verifying.promise + } + if (change !== 'same-auth') await provider.change(change) + reply.resolve({ phase: 'completed' }) + verification.resolve([{ ...addon, generation: 2 }]) + await work + assert.equal(changed, 0, 'unmounted view must not receive callbacks') + assert.equal(verified, path === 'verification' || change === 'same-auth' ? 1 : 0) + assert.equal(store.getSnapshot().live.length, 0) + assert.equal(store.getSnapshot().records.length, change === 'same-auth' ? 1 : 0) + }) + } +} diff --git a/core/http/react-ui/tests/unit/addonhomecommands.test.mjs b/core/http/react-ui/tests/unit/addonhomecommands.test.mjs new file mode 100644 index 000000000..6ecd2c5a2 --- /dev/null +++ b/core/http/react-ui/tests/unit/addonhomecommands.test.mjs @@ -0,0 +1,277 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { projectHomeCommands, resolveHomeCommand } from '../../src/addons/homeCommands.js' +import { shouldPoll } from '../../src/addons/registryState.js' +const entry = '/api/addons/paint/assets/' + 'a'.repeat(64) + '/ui.js' +const app = (id = 'paint', commands = [{ id: 'open', alias: 'paint', page: 'canvas', label: 'Paint', icon: 'palette' }]) => ({ id, generation: 3, enabled: true, state: 'running', ui: { entry: entry.replace('/paint/', `/${id}/`), sdk: { min: 2, max: 2 }, pages: ['canvas', 'history'], homeCommands: commands } }) +const snapshot = (apps = [app()], extra = {}) => ({ apps, allowed: true, status: 'ready', ...extra }) +test('Home addons: renamed addon and second creative command need no core branch', () => { + const p = projectHomeCommands(snapshot([app('creative', [{ id: 'make', page: 'canvas', label: 'Make', icon: 'palette' }, { id: 'past', page: 'history', label: 'Past', icon: 'clock' }])])) + assert.deepEqual(p.commands.map(c => c.cmd), ['/addon:creative:make', '/addon:creative:past']) + assert.equal(resolveHomeCommand('/addon:creative:make', snapshot([app('creative', [{ id: 'make', page: 'canvas', label: 'Make', icon: 'palette' }])]))?.page, 'canvas') +}) +test('Home addons: aliases reserve native and skills and suppress all colliders deterministically', () => { + const apps = [app('a'), app('b')] + const opts = { nativeNames: new Set(['paint']) } + assert.deepEqual(projectHomeCommands(snapshot(apps), opts).diagnostics.map(d => d.reason), ['native-name', 'native-name']) + assert.equal(projectHomeCommands(snapshot([app()]), { skillNames: ['paint'] }).commands.length, 1) + const p = projectHomeCommands(snapshot(apps)) + assert.equal(p.commands.length, 2) + assert.deepEqual(p, projectHomeCommands(snapshot([...apps].reverse()))) + assert.equal(p.diagnostics[0].reason, 'addon-alias-collision') +}) +test('Home addons: metadata unknown nested duplicate and unsafe destinations fail closed', () => { + for (const patch of [{ target: 'https://evil' }, { page: '../canvas' }, { label: {} }, { labels: { en: {} } }, { icon: 'javascript:bad' }, { page: 'missing' }]) { + const a = app(); Object.assign(a.ui.homeCommands[0], patch) + assert.equal(projectHomeCommands(snapshot([a])).commands.length, 0) + } + const a = app(); a.ui.homeCommands.push({ ...a.ui.homeCommands[0] }) + assert.equal(projectHomeCommands(snapshot([a])).commands.length, 0) +}) +test('Home addons: installed lifecycle error and absence remain distinct and polling recovers', () => { + for (const [patch, state] of [[{enabled:false}, 'installed-disabled'], [{state:'failed'}, 'installed-failed'], [{state:'starting'}, 'loading'], [{ui:null}, 'no-ui']]) { + assert.equal(projectHomeCommands(snapshot([{...app(), ...patch}])).states[0].status, state) + assert.equal(projectHomeCommands(snapshot([{...app(), ...patch}])).commands.length, 0) + } + assert.equal(projectHomeCommands(snapshot([], {status:'error'})).status, 'list-error') + assert.equal(projectHomeCommands(snapshot([])).status, 'absent') + for (const status of ['idle','ready','error']) assert.equal(shouldPoll({allowed:true, consumers:1, apps:[],status}), true) + assert.equal(projectHomeCommands(snapshot()).status, 'ready') +}) +test('Home addons: exact typed and captured clicks reject stale generation permissions and arguments', () => { + const c = projectHomeCommands(snapshot()).commands[0] + assert.ok(resolveHomeCommand(c, snapshot())) + assert.equal(resolveHomeCommand(c, snapshot([{...app(),generation:4}])), null) + assert.equal(resolveHomeCommand(c, snapshot([], {allowed:false})), null) + assert.equal(resolveHomeCommand(c, snapshot([], {status:'error'})), null) + assert.equal(resolveHomeCommand('/paint args', snapshot()), null) + assert.equal(resolveHomeCommand('/pai', snapshot()), null) +}) + +import { readFileSync } from 'node:fs' +import { createRegistryRequests } from '../../src/addons/registryRequests.js' +import { dispatchHomeCommand } from '../../src/addons/homeCommands.js' +import { availableCommands, parseInvocation, reservedNames } from '../../src/utils/slashCommands.js' +const deferred = () => { let resolve; const promise = new Promise(r => { resolve = r }); return { promise, resolve } } + +test('Home addons: outstanding logout poll aborts and cannot overwrite the next actor', async () => { + const requests = createRegistryRequests(), old = deferred(), published = [] + requests.setScope('alice:allowed') + let signal + const pending = requests.read(s => { signal = s; return old.promise }, s => published.push(s)) + requests.setScope('bob:forbidden') + assert.ok(signal.aborted) + old.resolve([app()]) + assert.equal(await pending, null) + assert.deepEqual(published, []) + requests.setScope('bob:allowed') + await requests.read(async () => [app('bob')], s => published.push(s)) + assert.equal(published[0].apps[0].id, 'bob') +}) +test('Home addons: cancellation newer reads and registry errors withdraw stale authority', async () => { + const requests = createRegistryRequests(), old = deferred(), published = [], ctl = new AbortController() + const pending = requests.read(() => old.promise, s => published.push(s), ctl.signal) + ctl.abort(); old.resolve([app()]); assert.equal(await pending, null) + const late = deferred() + const first = requests.read(() => late.promise, s => published.push(s)) + await requests.read(async () => { throw new Error('offline') }, s => published.push(s)) + late.resolve([app()]); assert.equal(await first, null) + assert.equal(published.length, 1) + assert.equal(projectHomeCommands({ ...published[0], allowed:true }).status, 'list-error') + assert.deepEqual(published[0].apps, []) +}) +test('Home addons: live authenticated dispatch navigates only the current installed generation', async () => { + const command = projectHomeCommands(snapshot()).commands[0], paths = [] + let now = { actor:'alice', allowed:true, list:snapshot() } + const requests = createRegistryRequests() + requests.setScope(now.actor) + const invoke = load => dispatchHomeCommand(command, { + current: () => now, navigate: path => paths.push(path), + refresh: () => requests.read(load, list => { now.list = list }), + }) + assert.equal(await invoke(async () => [app()]), true) + assert.deepEqual(paths, ['/app/addons/paint/canvas']) + for (const patch of [{enabled:false}, {state:'failed'}, {generation:4}, {ui:{...app().ui, pages:['history']}}]) { + assert.equal(await invoke(async () => [{...app(), ...patch}]), false) + } + const late = deferred(), clicking = invoke(() => late.promise) + now = { actor:'bob', allowed:false, list:snapshot([]) }; requests.setScope(now.actor) + late.resolve([app()]); assert.equal(await clicking, false) + assert.equal(paths.length, 1) +}) +test('Home addons: disabled failed ready transitions remain pollable and restart after permission returns', async () => { + const requests = createRegistryRequests() + let state = snapshot([]) + for (const patch of [{enabled:false}, {state:'failed'}, {}]) { + assert.ok(shouldPoll({...state, consumers:1})) + await requests.read(async () => [{...app(), ...patch}], next => { state = {...next,allowed:true} }) + } + assert.ok(projectHomeCommands(state).commands.length > 0) + assert.equal(shouldPoll({...state, allowed:false, consumers:1}), false) + assert.equal(shouldPoll({...state, allowed:true, consumers:1}), true) +}) +test('Home addons: Studio manifest is consumed generically and live Home has no identity branches', () => { + const manifest = JSON.parse(readFileSync(new URL('../../../../../addons/studio/manifest.json', import.meta.url))) + const ui = manifest.capabilities.ui + const commands = ui.homeCommands.map(c => ({...c, page:c.page || ui.mainPage, label:c.label || ui.title, icon:c.icon || 'puzzle'})) + const installed = app(manifest.id, commands); installed.ui.pages = ui.pages + const menu = availableCommands('home', {addonRegistry:snapshot([installed])}) + assert.ok(menu.some(c => c.cmd === '/studio')) + assert.ok(menu.some(c => c.cmd === '/addon:studio:open')) + assert.ok(!availableCommands('chat', {addonRegistry:snapshot([installed])}).some(c => c.addonId)) + assert.ok(!availableCommands('home', {addonRegistry:snapshot([])}).some(c => c.addonId)) + for (const path of ['../../src/pages/Home.jsx','../../src/components/home/homeActions.js']) { + assert.doesNotMatch(readFileSync(new URL(path, import.meta.url), 'utf8'), /studio/i) + } + const composer = readFileSync(new URL('../../src/components/home/HomeComposer.jsx', import.meta.url), 'utf8') + assert.match(composer, /onRunAction\(entry.id, entry\)/) + assert.match(composer, /onRunAction\(exact.id, exact\)/) + const registry = readFileSync(new URL('../../src/addons/registry.jsx', import.meta.url), 'utf8') + assert.match(registry, /dispatchHomeCommand\(command/) + assert.match(registry, /requests.read\(s => api.apps.list\(s\)/) +}) +test('Home addons: native hidden names and usable skills are reserved on the actual menu', () => { + const apps = [app('creative', [{id:'open',alias:'settings',page:'canvas',label:'Create',icon:'palette'}])] + assert.ok(!availableCommands('home', {addonRegistry:snapshot(apps)}).some(c => c.addonId && c.cmd === '/settings')) + assert.ok(reservedNames().has('settings')) + const menu = availableCommands('home', {addonRegistry:snapshot(),skills:[{name:'paint',enabled:true}]}) + assert.equal(menu.filter(c => c.cmd === '/paint').length, 1) + assert.ok(!menu.find(c => c.cmd === '/paint').addonId) + assert.equal(parseInvocation('/addon:paint:open', {skills:[{name:'addon:paint:open',enabled:true}]}), null) +}) + +test('Home addons: resolved inherited titles accept 61 through 80 Unicode characters and localized titles', () => { + for (let length = 61; length <= 80; length++) { + const label = 'é'.repeat(length), localized = '界'.repeat(length) + const a = app('paint', [{id:'open',page:'canvas',label,labels:{it:localized},icon:'palette'}]) + const projected = projectHomeCommands(snapshot([a]), {language:'it'}) + assert.equal(projected.states[0].status, 'ready', `title length ${length}`) + assert.equal(projected.commands[0]?.label, localized) + assert.equal(projectHomeCommands(snapshot([a])).commands[0]?.label, label) + } +}) +test('Home addons: resolved secondary page fallback accepts the exact maximum and rejects overflow', () => { + const page = 'p'.repeat(64), label = 'é'.repeat(80) + ' — ' + page + const localized = '界'.repeat(80) + ' — ' + page + assert.equal([...label].length, 147) + const a = app('paint', [{id:'open',page,label,labels:{it:localized},icon:'palette'}]) + a.ui.pages.push(page) + assert.equal(projectHomeCommands(snapshot([a])).commands[0]?.label, label) + assert.equal(projectHomeCommands(snapshot([a]), {language:'it'}).commands[0]?.label, localized) + for (const patch of [{label:label + 'x'}, {labels:{it:localized + 'x'}}, {label:'safe\u0085unsafe'}, {labels:{it:'safe\u202eunsafe'}}, {label:''}]) { + const bad = structuredClone(a); Object.assign(bad.ui.homeCommands[0], patch) + const projected = projectHomeCommands(snapshot([bad])) + assert.equal(projected.commands.length, 0) + assert.equal(projected.states[0].status, 'invalid-metadata') + } +}) +test('Home addons: immutable package icons accept dotted segments and all contract image extensions', () => { + for (const path of ['ui.v1/icon.png','ui/icon.dark.png','ui.v1/icon.dark.JPEG','ui/icon.webp','ui/icon.jpg', 'a'.repeat(252) + '.png']) { + const a = app(); a.ui.homeCommands[0].icon = entry.slice(0, -5) + path + const projected = projectHomeCommands(snapshot([a])) + assert.equal(projected.states[0].status, 'ready', path) + assert.equal(projected.commands[0]?.iconUrl, a.ui.homeCommands[0].icon, path) + } +}) +test('Home addons: package icons reject traversal encodings foreign prefixes and non-image extensions', () => { + const base = entry.slice(0, -5) + const paths = ['../icon.png','./icon.png','ui/../icon.png','ui/./icon.png','ui//icon.png','/icon.png','ui/icon.png/', 'ui\\icon.png', '%2e%2e/icon.png','ui%2ficon.png','ui%5cicon.png','ui/icon%2epng','ui/icon.png?x=1','ui/icon.png#x','ui/icon.svg','ui/icon.gif','ui/icon.js','ui/icon.png\u0000', 'a'.repeat(253) + '.png'] + const urls = paths.map(p => base + p).concat(['https://evil/icon.png','//evil/icon.png','javascript:bad', base.replace('/paint/', '/other/') + 'icon.png', base.replace('a'.repeat(64), 'b'.repeat(64)) + 'icon.png']) + for (const icon of urls) { + const a = app(); a.ui.homeCommands[0].icon = icon + const projected = projectHomeCommands(snapshot([a])) + assert.equal(projected.commands.length, 0, icon) + assert.equal(projected.states[0].status, 'invalid-metadata', icon) + } +}) + +// Run the production provider, including its request admission and Home dispatch. +// Effects stay explicitly controlled so refresh races cannot rely on cleanup. +async function registrySessionHarness() { + const { hookHarness } = await import('./chatcommands-lifecycle.mjs') + const { transformWithOxc } = await import('vite') + const source = readFileSync(new URL('../../src/addons/registry.jsx', import.meta.url), 'utf8') + const provider = source.slice(0, source.indexOf(' const boundaryLabels')) + ' return value\n}' + const { code } = await transformWithOxc(provider, 'registry.jsx', { jsx: { runtime:'classic' } }) + let auth = { user:{id:'alice'}, isAdmin:true, hasFeature:() => true, sessionEpoch:1 } + const reads = [], paths = [], revalidated = [] + const api = { apps:{list(signal) { const response = deferred(); reads.push({signal, ...response}); return response.promise }} } + const manager = { snapshot:() => [], revalidate:apps => revalidated.push(apps), disposeAll() {} } + const h = hookHarness('../../src/addons/registry.jsx', 'AddonRegistryProvider', { + useAuth:() => auth, useNavigate:() => path => paths.push(path), useLocation:() => ({pathname:'/app/home',search:''}), + useTranslation:() => ({t:key => key,i18n:{language:'en'}}), FEATURE_ADDONS:'addons', addonsApi:api, + apiUrl:path => path, fetch:async () => ({json:async () => ({addons:true})}), + createRegistryRequests, dispatchHomeCommand, createRuntimeManager:() => manager, + resolveManage:() => false, resolveTab:() => 'apps', shouldPoll, pollDelay:() => 1000, + buildNavigation:() => [], availableChatActions:() => [], + document:{hidden:false}, setTimeout:() => 1, clearTimeout:() => {}, + }, () => code.replace(/^import .*\n/gm, '').replace('export function', 'function')) + const render = () => h.render({api}) + render(); h.flush() + await new Promise(resolve => setImmediate(resolve)) + render(); h.flush(); render() + return { h, render, reads, paths, revalidated, refreshSession(user = auth.user) { auth = {...auth,user,sessionEpoch:auth.sessionEpoch + 1}; return render() } } +} + +test('Home addons: actual registry rejects same-user refresh late poll before effect cleanup', async () => { + const r = await registrySessionHarness() + const value = r.render() + value.acquire(); r.render(); r.h.flush() // Start the actual production polling effect. + assert.equal(r.reads.length, 1) + const poll = r.reads[0] + r.refreshSession() // Same ID, feature and admin; deliberately do not flush effects. + poll.resolve([app()]) + await new Promise(resolve => setImmediate(resolve)) + assert.deepEqual(r.revalidated, []) + assert.ok(poll.signal.aborted) + assert.deepEqual(r.render().apps, []) + // Now cleanup may run; permission and polling recovery still work. + r.h.flush() + assert.equal(r.reads.length, 2) + r.reads[1].resolve([app()]) + await new Promise(resolve => setImmediate(resolve)) + assert.equal(r.render().apps[0].id, 'paint') +}) + +test('Home addons: actual registry pending NAV cannot cross same-user session refresh', async () => { + const r = await registrySessionHarness() + const command = projectHomeCommands(snapshot()).commands[0] + const pending = r.render().navigateHomeCommand(command) + assert.equal(r.reads.length, 1) // Fresh authenticated refetch already began. + r.refreshSession() // No metadata, ID or permissions change, and no effect cleanup. + r.reads[0].resolve([app()]) + assert.equal(await pending, false) + assert.deepEqual(r.paths, []) + assert.deepEqual(r.revalidated, []) + const fresh = r.render().navigateHomeCommand(command) + r.reads[1].resolve([app()]) + assert.equal(await fresh, true) + assert.deepEqual(r.paths, ['/app/addons/paint/canvas']) +}) + +for (const accountSwitch of [false, true]) test(`Apps registry: late list cannot cross ${accountSwitch ? 'account switch' : 'same-user refresh'} before cleanup`, async () => { + const { hookHarness } = await import('./chatcommands-lifecycle.mjs') + const r = await registrySessionHarness() + let registry = r.render() + let auth = { user:{id:'alice'}, sessionEpoch:1, isAdmin:true, hasFeature:() => true } + const reads = [] + const api = { apps:{ list:() => { const d = deferred(); reads.push(d); return d.promise } } } + const h = hookHarness('../../src/components/addons/AppsView.jsx', 'AppsView', { + useAuth:() => auth, useContext:() => registry, RegistryContext:{}, useTranslation:() => ({t:x=>x,i18n:{}}), + FEATURE_ADDONS:'addons', pollDelay:() => 1000, setTimeout:()=>1, clearTimeout:()=>{}, + }, s => s.replace('export default function', 'function').split(' // The installed record')[0] + 'return state\n}') + const render = () => h.render({api}) + render(); h.flush() + const staleIngest = registry.ingest + auth = {...auth, user:{id:accountSwitch ? 'bob' : 'alice'},sessionEpoch:2} + registry = r.refreshSession(auth.user) + render() // Deliberately withhold Apps effect cleanup. + reads[0].resolve([app()]); await new Promise(resolve=>setImmediate(resolve)) + assert.deepEqual(render().apps, []) + assert.deepEqual(r.render().apps, []) + staleIngest([app()]) // Registry independently rejects the originating authority. + assert.deepEqual(r.render().apps, []) + h.flush(); reads[1].resolve([app()]); await new Promise(resolve=>setImmediate(resolve)) + assert.equal(render().apps[0].id, 'paint') + assert.equal(r.render().apps[0].id, 'paint') +}) diff --git a/core/http/react-ui/tests/unit/addonmodelactions.test.mjs b/core/http/react-ui/tests/unit/addonmodelactions.test.mjs new file mode 100644 index 000000000..f3f1bc972 --- /dev/null +++ b/core/http/react-ui/tests/unit/addonmodelactions.test.mjs @@ -0,0 +1,49 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { projectModelActions, dispatchModelAction } from '../../src/addons/modelActions.js' +const app = (id = 'paint') => ({ id, title: 'Creative', enabled: true, state: 'running', generation: 3, 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' }] } }) +const snapshot = (apps = [app()], extra = {}) => ({ allowed: true, known: true, status: 'ready', apps, actor: 'owner:1', ...extra }) +test('Model actions: arbitrary providers deterministic collisions and selected model encoding', () => { + const a = projectModelActions(snapshot([app('zebra'), app('arbitrary')]), 'FLAG_IMAGE', 'org/a:b雪?&') + assert.deepEqual(a.choices.map(c => c.addonId), ['arbitrary', 'zebra']) + assert.deepEqual(a, projectModelActions(snapshot([app('arbitrary'), app('zebra')]), 'FLAG_IMAGE', 'org/a:b雪?&')) + assert.equal(new URL(a.choices[0].url, 'http://host').searchParams.get('model'), 'org/a:b雪?&') + assert.equal(projectModelActions(snapshot(), 'FLAG_CHAT', 'm').choices.length, 0) +}) +test('Model actions: absence forbidden loading errors and installed lifecycle are distinct', () => { + for (const [extra, state] of [[{allowed:false}, 'forbidden'], [{allowed:false,known:false},'loading'], [{status:'idle'},'loading'], [{status:'error'},'list-error']]) assert.equal(projectModelActions(snapshot([], extra), 'FLAG_IMAGE', 'm').status, state) + assert.equal(projectModelActions(snapshot([]), 'FLAG_IMAGE', 'm').status, 'absent') + for (const [patch, state] of [[{enabled:false},'installed-disabled'], [{state:'starting'},'starting'], [{state:'failed'},'installed-failed'], [{ui:{...app().ui,sdk:{min:99,max:99}}},'incompatible']]) { + const p = projectModelActions(snapshot([{...app(),...patch}]), 'FLAG_IMAGE', 'm') + assert.equal(p.choices.length, 0); assert.equal(p.states[0].status,state) + } +}) +test('Model actions: strict SDK bounded declaration validation fails closed', () => { + for (const patch of [{page:'missing'}, {path:'../x'}, {path:'/x'}, {path:'x?model=evil'}, {path:'x#hash'}, {path:''}, {path:'x/'.repeat(9)}, {path:'a'.repeat(65)}, {capability:'FLAG_CHAT'}, {url:'https://evil'}]) { + const a = app(); Object.assign(a.ui.modelActions[0],patch) + assert.equal(projectModelActions(snapshot([a]), 'FLAG_IMAGE','m').choices.length,0) + } + const a = app(); a.ui.modelActions.push({...a.ui.modelActions[0]}) + assert.equal(projectModelActions(snapshot([a]), 'FLAG_IMAGE','m').choices.length,0) + assert.equal(projectModelActions(snapshot([app(),app()]), 'FLAG_IMAGE','m').choices.length,0) +}) +test('Model actions: fresh dispatch refuses changed actor generation immutable entry and destination', async () => { + const s = snapshot(); const choice = projectModelActions(s,'FLAG_IMAGE','selected').choices[0] + for (const mutate of [a=>({...a,generation:4}),a=>({...a,ui:{...a.ui,entry:a.ui.entry.replace('a'.repeat(64),'b'.repeat(64))}}),a=>({...a,ui:{...a.ui,modelActions:[{capability:'FLAG_IMAGE',page:'canvas',path:'other'}]}}), a=>({...a,enabled:false})]) { + let now = {...s,list:s}; let visited = false + assert.equal(await dispatchModelAction(choice,{current:()=>now,refresh:async()=>{const apps=[mutate(app())]; now={...now,list:snapshot(apps)};return apps},navigate:()=>{visited=true}}),false) + assert.equal(visited,false) + } + let now = {...s,list:s}; let visited + assert.equal(await dispatchModelAction(choice,{current:()=>now,refresh:async()=>{const apps=[app()];now={...now,list:snapshot(apps)};return apps},navigate:p=>{visited=p}}),true) + assert.equal(new URL(visited,'http://host').searchParams.get('model'),'selected') + assert.equal(await dispatchModelAction(choice,{current:()=>now,refresh:async()=>{now={...now,actor:'owner:2'};return now.list.apps},navigate:()=>assert.fail()}),false) + assert.equal(await dispatchModelAction({...choice,actor:'previous'}, {current:()=>now,refresh:async()=>now.list.apps,navigate:()=>assert.fail()}),false) +}) + +test('Model actions: revoked session authority refuses a pending refresh before consumer render', async () => { + const s = snapshot(), choice = projectModelActions(s,'FLAG_IMAGE','m').choices[0] + let valid = true + const now = {...s,list:s} + assert.equal(await dispatchModelAction(choice, {current:()=>now,sessionCurrent:()=>valid,refresh:async()=>{valid=false;return s.apps},navigate:()=>assert.fail('revoked session navigated')}),false) +}) diff --git a/core/http/react-ui/tests/unit/chatcommands-lifecycle.mjs b/core/http/react-ui/tests/unit/chatcommands-lifecycle.mjs new file mode 100644 index 000000000..507072ace --- /dev/null +++ b/core/http/react-ui/tests/unit/chatcommands-lifecycle.mjs @@ -0,0 +1,44 @@ +// Executes source hooks with persistent slots and explicitly controlled effect flushes. +// Keeping effects separate lets tests inspect the render before cleanup runs. +import { readFileSync } from 'node:fs' + +export function hookHarness(file, name, imports, transform = source => source) { + const slots = [] + let cursor = 0 + let pending = new Map() + const same = (a, b) => a && b && a.length === b.length && a.every((v, i) => Object.is(v, b[i])) + const hooks = { + useState(initial) { + const i = cursor++ + if (!(i in slots)) slots[i] = typeof initial === 'function' ? initial() : initial + return [slots[i], value => { slots[i] = typeof value === 'function' ? value(slots[i]) : value }] + }, + useRef(initial) { + const i = cursor++ + return slots[i] ||= { current: initial } + }, + useEffect(fn, deps) { + const i = cursor++ + if (!same(slots[i]?.deps, deps)) pending.set(i, () => { + slots[i]?.cleanup?.() + slots[i] = { deps, cleanup: fn() } + }) + }, + useMemo(fn, deps) { + const i = cursor++ + if (!same(slots[i]?.deps, deps)) slots[i] = { deps, value: fn() } + return slots[i].value + }, + useSyncExternalStore(subscribe, snapshot) { cursor++; return snapshot() }, + useCallback(fn) { cursor++; return fn }, + } + const source = transform(readFileSync(new URL(file, import.meta.url), 'utf8') + .replace(/^import .*\n/gm, '').replace(/^export \{.*\}\n/gm, '').replace(/export function/g, 'function')) + const scope = { ...hooks, ...imports } + const hook = new Function(...Object.keys(scope), `${source}; return ${name}`)(...Object.values(scope)) + return { + render(...args) { cursor = 0; return hook(...args) }, + unmount() { pending.clear(); slots.forEach(slot => slot?.cleanup?.()) }, + flush() { const effects = pending; pending = new Map(); effects.forEach(fn => fn()) }, + } +} diff --git a/core/http/react-ui/tests/unit/chatcommands.test.mjs b/core/http/react-ui/tests/unit/chatcommands.test.mjs index 2c51bcf2e..8c7704d50 100644 --- a/core/http/react-ui/tests/unit/chatcommands.test.mjs +++ b/core/http/react-ui/tests/unit/chatcommands.test.mjs @@ -9,29 +9,29 @@ test('chat commands: no notice when commands run or the server does not say', () assert.equal(commandsNotice('something-new', { isAdmin: true }), null) }) -test('chat commands: with no Sandbox an administrator gets the install link and others are told to ask', () => { +test('chat commands: with no provider there is no specific installation recommendation', () => { assert.deepEqual(commandsNotice('none', { isAdmin: true }), { - state: 'none', textKey: 'none', reason: '', link: { to: '/app/addons?tab=browse&q=sandbox', labelKey: 'install' }, askKey: null, + state: 'none', textKey: 'none', reason: '', link: null, askKey: null, }) assert.deepEqual(commandsNotice('none', { isAdmin: false }), { - state: 'none', textKey: 'none', reason: '', link: null, askKey: 'askInstall', + state: 'none', textKey: 'none', reason: '', link: null, askKey: null, }) }) -test('chat commands: a Sandbox that cannot run gives its reason and the add-on page to an administrator', () => { +test('chat commands: an unavailable provider gives its reason without a specific add-on recommendation', () => { const admin = commandsNotice('unavailable', { isAdmin: true, reason: 'no sandbox backend works on this host' }) - assert.equal(admin.textKey, 'unavailableWhy') + assert.equal(admin.textKey, 'providerUnavailable') assert.equal(admin.reason, 'no sandbox backend works on this host') - assert.deepEqual(admin.link, { to: '/app/addons?detail=sandbox', labelKey: 'openSandbox' }) + assert.equal(admin.link, null) const user = commandsNotice('unavailable', { isAdmin: false }) - assert.equal(user.textKey, 'unavailable') + assert.equal(user.textKey, 'providerUnavailable') assert.equal(user.link, null) assert.equal(user.askKey, 'askFix') assert.equal(commandsNotice('unavailable', { reason: 'x'.repeat(500) }).reason.length, 200) }) test('chat commands: a Sandbox switched off in the chat is the user\'s own fix, with no link', () => { - assert.deepEqual(commandsNotice('off', { isAdmin: false }), { state: 'off', textKey: 'off', link: null, askKey: null, reason: '' }) + assert.deepEqual(commandsNotice('off', { isAdmin: false }), { state: 'off', textKey: 'disabled', link: null, askKey: null, reason: '' }) }) test('chat commands: hiding remembers the state, and storage that throws is ignored', () => { @@ -59,23 +59,20 @@ test('chat commands: the built-in bash on this machine is a state of its own', a assert.deepEqual(commandsState(undefined), { state: '', reason: '' }) }) -test('chat commands: a chat that runs commands here keeps a warning notice with the install link', async () => { - const { SANDBOX_INSTALL_LINK } = await import('../../src/utils/chatCommands.js') - const admin = commandsNotice('host', { isAdmin: true }) - assert.equal(admin.titleKey, 'hostTitle') - assert.equal(admin.textKey, 'host') - assert.equal(admin.tone, 'warn') - assert.deepEqual(admin.link, { to: SANDBOX_INSTALL_LINK, labelKey: 'install' }) - assert.equal(commandsNotice('host', { isAdmin: false }).askKey, 'askInstall') - assert.equal(commandsNotice('host-off', { isAdmin: true }).link, null, 'the person switches the Sandbox on in the menu') - const why = commandsNotice('host-unavailable', { isAdmin: true, reason: 'no backend works' }) - assert.equal(why.textKey, 'hostUnavailableWhy') - assert.deepEqual(why.link, { to: '/app/addons?detail=sandbox', labelKey: 'openSandbox' }) +test('chat commands: actual host execution keeps a generic isolation warning', () => { + for (const state of ['host', 'host-off', 'host-unavailable']) { + const info = commandsNotice(state, { isAdmin: true, reason: 'no backend works' }) + assert.equal(info.titleKey, 'hostTitle') + assert.equal(info.textKey, 'providerHost') + assert.equal(info.tone, 'warn') + assert.equal(info.link, null) + assert.equal(info.askKey, null) + } }) test('chat commands: the tool line says who served a call', async () => { const { providerTag } = await import('../../src/utils/chatCommands.js') - assert.deepEqual(providerTag({ kind: 'tool_provider', id: 'sandbox' }), { key: 'sandbox' }) + assert.deepEqual(providerTag({ kind: 'tool_provider', id: 'sandbox' }), { key: 'addon', values: { name: 'sandbox' } }) assert.deepEqual(providerTag({ kind: 'tool_provider', id: 'other' }), { key: 'addon', values: { name: 'other' } }) assert.deepEqual(providerTag({ kind: 'host_exec', id: 'ran' }), { key: 'host' }) assert.deepEqual(providerTag({ kind: 'host_exec', id: 'once' }), { key: 'hostOnce' }) @@ -91,3 +88,218 @@ test('chat commands: a refused answer reads as an expired card only for 410 cons assert.equal(consentExpired(new Error('network')), false) assert.equal(consentExpired(undefined), false) }) + +test('typed safety: any actual addon host warns and unknown never appears isolated', async () => { + const { commandsState } = await import('../../src/utils/chatCommands.js') + assert.equal(commandsState({ commands: 'available', commands_provider: 'renamed', commands_isolation: 'host' }).state, 'provider-host') + assert.equal(commandsState({ commands: 'available', commands_provider: 'renamed' }).state, 'unknown') + assert.equal(commandsNotice('unknown').tone, 'warn') + assert.equal(commandsNotice('switched_off').textKey, 'disabled') +}) + +test('typed safety: dismissal tracks binding, status, chat and actor epoch', async () => { + const { noticeIdentity } = await import('../../src/utils/chatCommands.js') + const base = { commands: 'available', commands_provider: 'renamed', execution_status: { readiness: 'available', isolation: 'host', generation: '1', incarnation: 'a' } } + const id = noticeIdentity(base, 'chat', 'actor-session') + for (const field of ['generation', 'incarnation', 'isolation', 'readiness']) { + assert.notEqual(noticeIdentity({ ...base, execution_status: { ...base.execution_status, [field]: 'changed' } }, 'chat', 'actor-session'), id) + } + assert.notEqual(noticeIdentity(base, 'other', 'actor-session'), id) + assert.notEqual(noticeIdentity(base, 'chat', 'new-session'), id) +}) + +test('typed safety: explanation is bounded plain data with no install advice', async () => { + const { commandsState } = await import('../../src/utils/chatCommands.js') + const state = commandsState({ commands: 'unavailable', commands_provider: 'renamed', execution_status: { readiness: 'unknown', isolation: 'unknown', explanation: ''.repeat(30) } }) + const notice = commandsNotice(state.state, { reason: state.reason }) + assert.equal(notice.link, null) + assert.equal(notice.askKey, null) + assert.ok([...notice.reason].length <= 200) + assert.equal(typeof notice.reason, 'string') +}) + +test('typed safety: actual notice hook resets dismissal through loading, identity changes and logout', async () => { + const { readFileSync } = await import('node:fs') + const source = readFileSync(new URL('../../src/hooks/useCommandsNotice.js', import.meta.url), 'utf8').replace(/^import .*\n/gm, '').replace('export function', 'function') + let hidden = '', previous, effect + const hook = new Function('useState', 'useEffect', 'useCallback', 'commandsNotice', `${source}; return useCommandsNotice`)( + () => [hidden, v => { hidden = v }], + (fn, deps) => { if (previous !== deps[0]) { effect = fn; previous = deps[0] } }, + fn => fn, commandsNotice) + const render = (state, identity = 'binding-1', actor = 'session-1') => { + const value = hook({ state, reason: '', identity }, false, [actor, 'chat']) + if (effect) { effect(); effect = null } + return value + } + render('provider-host').hide() + assert.equal(render('provider-host').hidden, true) + assert.equal(render('unknown', '').hidden, false) + assert.equal(render('provider-host').hidden, false, 'loading invalidates even the same binding') + render('provider-host').hide() + assert.equal(render('provider-host', 'binding-2').hidden, false) + assert.equal(render('provider-host', 'binding-1').hidden, false, 'replacement ABA does not restore dismissal') + render('provider-host').hide() + assert.equal(render('provider-host', 'binding-1', 'logged-out').hidden, false) +}) + +test('typed safety: actual explanation JSX escapes provider markup as React text', async () => { + const { readFileSync } = await import('node:fs') + const React = await import('react') + const { renderToStaticMarkup } = await import('react-dom/server') + const { transformWithOxc } = await import('vite') + const source = readFileSync(new URL('../../src/components/chat/CommandsNotice.jsx', import.meta.url), 'utf8') + assert.ok(!source.includes('dangerouslySetInnerHTML')) + const jsx = source.slice(source.indexOf('

{info.reason}

'), source.indexOf('

{info.reason}

') + '

{info.reason}

'.length) + const { code } = await transformWithOxc(`return (${jsx})`, 'notice.jsx', { jsx: { runtime: 'classic' } }) + const element = new Function('React', 'info', code)(React, { reason: '' }) + const html = renderToStaticMarkup(element) + assert.match(html, /<img/) + assert.ok(!html.includes(' { + const { commandsState } = await import('../../src/utils/chatCommands.js') + for (const commands of ['none', 'unavailable', 'available']) { + const projected = commandsState({ commands, execution_status: { readiness: 'unknown', isolation: 'isolated' } }) + const notice = commandsNotice(projected.state, { isAdmin: true }) + assert.ok(notice) + assert.equal(notice.link, null) + assert.equal(notice.askKey, null) + } +}) + +test('typed safety: builtin and all fallback notices have no Sandbox advice for any role', async () => { + const { commandsState } = await import('../../src/utils/chatCommands.js') + const { readFileSync } = await import('node:fs') + const translations = JSON.parse(readFileSync(new URL('../../public/locales/en/chats.json', import.meta.url))).tools.commands + for (const isAdmin of [true, false]) { + for (const fallback of [undefined, 'off', 'unavailable']) { + const projected = commandsState({ commands: 'available', commands_provider: 'builtin', commands_fallback: fallback && 'renamed', commands_fallback_reason: fallback }) + const notice = commandsNotice(projected.state, { isAdmin, reason: projected.reason }) + assert.equal(notice.tone, 'warn') + assert.equal(notice.link, null) + assert.equal(notice.askKey, null) + assert.match(translations[notice.textKey], /without isolation/) + assert.doesNotMatch(translations[notice.textKey], /sandbox|install|repair|switch.*on/i) + } + } +}) + +test('typed safety: actual switches reject late actor replies and hide old status before effects', async () => { + const { hookHarness } = await import('./chatcommands-lifecycle.mjs') + const names = ['chatMCP', 'chatSwitchRows', 'chatSkills', 'chatCommands', 'chatDraftSwitches'] + const imports = Object.assign({}, ...await Promise.all(names.map(name => import(`../../src/utils/${name}.js`)))) + const requests = [] + const list = () => new Promise(resolve => requests.push(resolve)) + const h = hookHarness('../../src/hooks/useChatSwitches.js', 'useChatSwitches', { + ...imports, chatAddonsApi: { list }, chatToolsApi: { list: async () => ({}) }, chatSkillsApi: { list: async () => ({}) }, + }) + const render = actor => h.render('same-chat', false, null, actor) + const isolated = { commands: 'available', commands_provider: 'renamed', commands_isolation: 'isolated' } + render('A:1'); h.flush() + assert.equal(requests.length, 1) + assert.equal(render('B:1').commands.state, 'unknown', 'render invalidates A before effect cleanup') + requests[0](isolated); await new Promise(resolve => setImmediate(resolve)) + assert.equal(render('B:1').commands.state, 'unknown', 'late A cannot publish in B even before effects') + h.flush() + assert.equal(requests.length, 2, 'actor change starts a new request') + requests[1](isolated); await new Promise(resolve => setImmediate(resolve)) + assert.equal(render('B:1').commands.state, 'available') + assert.equal(render('logged-out:2').commands.state, 'unknown') + h.flush() + assert.equal(render('B:3').commands.state, 'unknown', 'same user login does not reuse the old session') + h.flush() + requests[2](isolated); await new Promise(resolve => setImmediate(resolve)) + assert.equal(render('B:3').commands.state, 'unknown', 'logout reply cannot populate new login') + requests[3](isolated); await new Promise(resolve => setImmediate(resolve)) + assert.equal(render('B:3').commands.state, 'available') +}) + +test('typed safety: actual auth epoch reaches composer and changes on same-user logout/login', async () => { + const { hookHarness } = await import('./chatcommands-lifecycle.mjs') + const originalFetch = globalThis.fetch + const originalDocument = globalThis.document + const originalWindow = globalThis.window + try { + globalThis.fetch = async () => ({ json: async () => ({ authEnabled: true, user: { id: 'A' } }) }) + globalThis.document = { cookie: '' } + globalThis.window = { location: {} } + const auth = hookHarness('../../src/context/AuthContext.jsx', 'AuthProvider', { apiUrl: path => path, addonUpgrades: (await import('../../src/utils/addonUpgrades.js')).createUpgradeStore() }, source => source.replace(/return \(\s* setImmediate(resolve)) + let currentAuth = auth.render({}) + const seen = [] + const composer = hookHarness('../../src/hooks/useChatComposer.js', 'useChatComposer', { + useAuth: () => currentAuth, + useChatDrafts: () => ({ setFiles() {} }), + useModels: () => ({ models: [] }), CAP_CHAT: 'chat', + useChatSwitches: (...args) => { seen.push(args); return {} }, + }) + composer.render({ chatId: 'same-chat', userId: 'A' }) + const initial = seen.at(-1)[3] + assert.ok(initial) + const beforeLogout = currentAuth.sessionEpoch + const logout = currentAuth.logout() + currentAuth = auth.render({}) + assert.equal(currentAuth.user, null, 'logout invalidates before network completion') + assert.ok(currentAuth.sessionEpoch > beforeLogout) + composer.render({ chatId: 'same-chat', userId: 'A' }) + assert.notEqual(seen.at(-1)[3], initial) + await logout + await currentAuth.refresh() + currentAuth = auth.render({}) + assert.equal(currentAuth.user.id, 'A') + composer.render({ chatId: 'same-chat', userId: 'A' }) + assert.notEqual(seen.at(-1)[3], initial, 'same actor has a new epoch after login') + } finally { + globalThis.fetch = originalFetch + globalThis.document = originalDocument + globalThis.window = originalWindow + } +}) + +for (const provider of ['copper-runner', 'violet-executor']) test(`typed safety: production Composer uses current ${provider} DTO, never discovery isolation`, async () => { + const { hookHarness } = await import('./chatcommands-lifecycle.mjs') + const names = ['chatMCP', 'chatSwitchRows', 'chatSkills', 'chatCommands', 'chatDraftSwitches'] + const imports = Object.assign({}, ...await Promise.all(names.map(name => import(`../../src/utils/${name}.js`)))) + const requests = [] + const switchesHook = hookHarness('../../src/hooks/useChatSwitches.js', 'useChatSwitches', { + ...imports, chatAddonsApi:{list:() => new Promise(resolve => requests.push(resolve))}, + chatToolsApi:{list:async () => ({})}, chatSkillsApi:{list:async () => ({})}, + }) + const composer = hookHarness('../../src/components/chat/ChatComposer.jsx', 'ChatComposer', { + useTranslation:() => ({t:key => key}), useAuth:() => ({isAdmin:true,user:{id:'alice'}}), + useModelFit:() => ({}), useCommandsNotice:() => ({info:null}), + // Only the original code needs these; keeping them permits a genuine RED run. + SANDBOX_ADDON:'sandbox', useSandboxStatus:() => null, + }, source => source.replace('export default function', 'function').split(' // What a row of the model list')[0] + 'return shellNote\n}') + const group = {group:'shell',providers:{bash:provider},provider,usable:true} + const render = (actor = 'alice:1', override) => { + const switches = switchesHook.render('same-chat', false, null, actor) + if (override) switches.commands = override + const shellNote = composer.render({composer:{drafts:{},switches,models:[]}}) + return shellNote(group) + } + const unknown = note => { + assert.equal(note.note, 'tools.commands.unknown') + assert.notEqual(note.note, 'tools.shell.isolated') + } + unknown(render()); switchesHook.flush() + const reply = isolation => ({commands:'available',commands_provider:provider,execution_status:{readiness:'available',isolation,generation:2,incarnation:'run-2'}}) + requests[0](reply('isolated')); await new Promise(resolve => setImmediate(resolve)) + assert.equal(render().note, 'tools.shell.isolated') + unknown(render('alice:2')) // Same user refresh before cleanup invalidates positive evidence. + switchesHook.flush() + unknown(render('bob:3')) // Withhold cleanup of the second request. + requests[1](reply('isolated')); await new Promise(resolve => setImmediate(resolve)) + unknown(render('bob:3')) // Late authenticated generation cannot publish. + switchesHook.flush() + requests[2](reply('host')); await new Promise(resolve => setImmediate(resolve)) + assert.equal(render('bob:3').note, 'tools.commands.providerHost') + assert.equal(render('bob:3').warn, true) + for (const state of ['unknown', 'loading', '', null]) unknown(render('bob:3', {state,provider})) + unknown(render('bob:3', {state:'available',provider:'different-current-provider'})) + // Built-in bash remains a primitive choice, not an add-on identity. + group.providers.bash = 'builtin' + assert.equal(render('bob:3', {state:'host',provider:'builtin'}).warn, true) +}) diff --git a/core/http/react-ui/tests/unit/chategress.test.mjs b/core/http/react-ui/tests/unit/chategress.test.mjs index 69c0a8c59..d1479327e 100644 --- a/core/http/react-ui/tests/unit/chategress.test.mjs +++ b/core/http/react-ui/tests/unit/chategress.test.mjs @@ -22,7 +22,7 @@ test('chat egress: the Tools menu shows the row only where the Sandbox serves, a // The policy applies, but no add-on draws the network row for it. assert.equal(networkMode({ loaded: true, applies: true }), 'hidden') assert.equal(networkMode({ loaded: true, applies: false, applies_reason: 'host' }), 'host') - assert.equal(networkMode({ loaded: true, applies: false, applies_reason: 'none' }), 'hidden') + assert.equal(networkMode({ loaded: true, applies: false, applies_reason: 'none' }), 'unverified') }) test('chat egress: cards sort pending first, newest first, and group by tool line', () => { @@ -80,3 +80,7 @@ test('chat egress: the highlighted choice is what applies, never a choice above assert.equal(askedAboveLimit({ preset: 'all', ceiling: 'off' }), 'all') assert.equal(askedAboveLimit({ preset: 'registries', ceiling: 'all' }), '') }) + +test('chat egress: isolated reports cannot override backend false applicability', () => { + assert.equal(networkMode({ loaded: true, applies: false, addon: 'renamed', applies_reason: 'none', commands_isolation: 'isolated', preset: 'all' }), 'unverified') +}) diff --git a/core/http/react-ui/tests/unit/chatfeatures.test.mjs b/core/http/react-ui/tests/unit/chatfeatures.test.mjs index e7d46eaa4..216df6097 100644 --- a/core/http/react-ui/tests/unit/chatfeatures.test.mjs +++ b/core/http/react-ui/tests/unit/chatfeatures.test.mjs @@ -142,21 +142,19 @@ test('tool refusals: the typed codes of the add-on tools are read, an untyped er assert.equal(toolRefusal('read_file: /a\nplain text'), null) }) -test('sandbox state: the Tools picker reads isolated, host, cannot-run or unknown from the status read', async () => { - const { sandboxState } = await import('../../src/utils/sandboxStatus.js') - assert.deepEqual(sandboxState({ ok: true, canRun: true, active: 'gvisor' }), { state: 'isolated', backend: 'gvisor', reason: '' }) - assert.equal(sandboxState({ ok: true, canRun: true, active: 'host', hostNotice: true }).state, 'host') - assert.deepEqual(sandboxState({ ok: true, canRun: false, backends: [{ name: 'kvm', usable: false, reason: 'no /dev/kvm' }] }), { state: 'cannot', backend: '', reason: 'no /dev/kvm' }) - assert.equal(sandboxState(null).state, 'unknown') - assert.equal(sandboxState({ ok: false }).state, 'unknown') - const { startingState } = await import('../../src/utils/sandboxStatus.js') - assert.deepEqual(startingState(4000.4), { state: 'starting', backend: '', reason: '', retryMs: 4000 }) +test('command state: private discovery fields cannot establish selected provider isolation', async () => { + const { commandsState } = await import('../../src/utils/chatCommands.js') + for (const provider of ['copper-runner', 'violet-executor']) { + const res = { commands: 'available', commands_provider: provider, ok: true, canRun: true, active: 'gvisor' } + assert.equal(commandsState(res).state, 'unknown') + assert.equal(commandsState({ ...res, execution_status: { readiness: 'available', isolation: 'isolated' } }).state, 'available') + assert.equal(commandsState({ ...res, execution_status: { readiness: 'unknown', isolation: 'isolated' } }).state, 'unknown') + } }) -test('home actions: an administrator without Studio gets Install Studio under the same command', async () => { +test('home actions: only generic Apps browsing is native, never an install command', async () => { const { availableActions } = await import('../../src/components/home/homeActions.js') - const ids = (ctx) => availableActions(ctx).filter(a => a.cmd === '/studio').map(a => a.id) - assert.deepEqual(ids({ isAdmin: true, studioInstalled: false }), ['studioInstall']) - assert.deepEqual(ids({ isAdmin: true, studioInstalled: true }), ['studio']) - assert.deepEqual(ids({ isAdmin: false, studioInstalled: false }), []) + assert.ok(availableActions({ hasAddons: true }).some(a => a.cmd === '/apps')) + assert.ok(!availableActions({ hasAddons: false }).some(a => a.cmd === '/apps')) + assert.ok(!availableActions({ isAdmin: true }).some(a => a.cmd === '/studio')) }) diff --git a/core/http/react-ui/tests/unit/composer-render.test.mjs b/core/http/react-ui/tests/unit/composer-render.test.mjs new file mode 100644 index 000000000..c6559dabc --- /dev/null +++ b/core/http/react-ui/tests/unit/composer-render.test.mjs @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import ts from 'typescript' +import React, { useCallback, useMemo, useState } from 'react' +import { renderToString } from 'react-dom/server' +import * as slash from '../../src/utils/slashCommands.js' + +// Compile the entire production component, not a selected callback or source slice. +// Leaf components and service hooks are boundaries; React executes all composer hooks. +const source = readFileSync(new URL('../../src/components/chat/ChatComposer.jsx', import.meta.url), 'utf8') +const names = [...source.matchAll(/^import (\w+) from/gm)].map(m => m[1]) +const leaf = () => null +let captured +const scope = { + ...Object.fromEntries(names.map(n => [n, leaf])), ...slash, + React, useCallback, useMemo, useState, + useTranslation: () => ({ t: k => k }), useAuth: () => ({ isAdmin: true, user: { id: 'a' } }), + useModelFit: () => ({ resources: null }), useCommandsNotice: () => ({}), + hostMemory: () => null, agentFolderLine: () => '', CAP_CHAT: 'chat', + CHAT_SLASH_ACTIONS: [], SLASH_ACTIONS: [], + HomeComposer: props => { captured = props; return React.createElement('textarea', { defaultValue: props.message }) }, + CommandsChip: leaf, CommandsNotice: leaf, ToolFallbacks: leaf, ChatToolsControls: leaf, +} +const compiled = ts.transpileModule(source.replace(/^import .*\n/gm, '').replace('export default function', 'function'), { + compilerOptions: { jsx: ts.JsxEmit.React, target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None }, fileName: 'ChatComposer.jsx', +}).outputText +const Composer = new Function(...Object.keys(scope), `${compiled}; return ChatComposer`)(...Object.values(scope)) + +test('full ChatComposer renders and about uses only selected command status', () => { + for (const [state, expected] of [['available', 'isolated'], ['host', 'host'], ['provider-host', 'host'], ['unknown', 'unknown'], ['loading', 'unknown'], ['', 'unknown'], ['none', 'none']]) { + const composer = { + drafts: { text: '', files: [], setText() {}, setFiles() {} }, models: [], agents: [], + switches: { commands: { state }, builtins: [], addons: [], servers: [], connectors: [], skills: [], fallbacks: [] }, + } + let result + assert.match(renderToString(React.createElement(Composer, { composer, onRunAction: (_, value) => { result = value } })), /textarea/) + captured.onRunAction('about') + assert.equal(result.about.commands, expected, state) + } +}) diff --git a/core/http/react-ui/tests/unit/esm.test.mjs b/core/http/react-ui/tests/unit/esm.test.mjs index 251634535..60792154c 100644 --- a/core/http/react-ui/tests/unit/esm.test.mjs +++ b/core/http/react-ui/tests/unit/esm.test.mjs @@ -675,8 +675,8 @@ test('registry: the list is read off the Add-ons page only for a viewer with the assert.equal(contributesUI([{ ...nav[0], state: 'failed' }]), false) assert.equal(contributesUI(null), false) const base = { allowed: true, onAddonsPage: false, apps: plain, status: 'ready', consumers: 1 } - // Nothing contributes and the list is known: no read at all. - assert.equal(shouldPoll(base), false) + // A mounted consumer keeps even an empty/disabled list current. + assert.equal(shouldPoll(base), true) // A contribution keeps it current, with or without a consumer. assert.equal(shouldPoll({ ...base, apps: nav, consumers: 0 }), true) // The Add-ons page always reads it. diff --git a/core/http/react-ui/tests/unit/homeassistant.test.mjs b/core/http/react-ui/tests/unit/homeassistant.test.mjs index 3263141b5..30d7dea7b 100644 --- a/core/http/react-ui/tests/unit/homeassistant.test.mjs +++ b/core/http/react-ui/tests/unit/homeassistant.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict' import { readdirSync, readFileSync } from 'node:fs' import { availableActions, filterActions } from '../../src/components/home/homeActions.js' -const base = { isAdmin: true, hasModels: true, loadedCount: 0, studioInstalled: false, assistantAvailable: true } +const base = { isAdmin: true, hasModels: true, loadedCount: 0, assistantAvailable: true } const ids = (ctx) => availableActions(ctx).map(a => a.id) test('home assistant: the /assistant action is offered to an admin when the server has the assistant', () => { diff --git a/core/http/react-ui/tests/unit/legacyroutes.test.mjs b/core/http/react-ui/tests/unit/legacyroutes.test.mjs index caa124de7..3b5525c58 100644 --- a/core/http/react-ui/tests/unit/legacyroutes.test.mjs +++ b/core/http/react-ui/tests/unit/legacyroutes.test.mjs @@ -3,125 +3,24 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -import { addonPageAddress, bareChatsRedirect, chatsRedirect, STUDIO_TABS, studioAddress, studioRedirect, talkAddress, MIGRATION_ROUTES } from '../../src/utils/legacyRoutes.js' +import { bareChatsRedirect, chatsRedirect, MIGRATION_ROUTES } from '../../src/utils/legacyRoutes.js' const here = dirname(fileURLToPath(import.meta.url)) const router = readFileSync(join(here, '../../src/router.jsx'), 'utf8') -test('studioRedirect opens the Studio add-on and keeps the model, the query and the hash', () => { - // A model id may hold "/" or ":", which an add-on path segment cannot, so it travels as ?model=. - assert.equal(studioRedirect('image', 'stable/diffusion', '?x=1', '#top'), '/app/addons/studio/studio/images?x=1&model=stable%2Fdiffusion#top') - assert.equal(studioRedirect('3d', undefined, '', ''), '/app/addons/studio/studio/threed') - assert.equal(studioRedirect('tts', 'piper', '?voice=a', ''), '/app/addons/studio/studio/tts?voice=a&model=piper') -}) +import { addonPageAddress } from '../../src/addons/addresses.js' -test('studioAddress and talkAddress build the add-on page addresses', () => { - assert.equal(studioAddress(), '/app/addons/studio/studio') - assert.equal(studioAddress({ tab: 'images', model: 'a b', search: '?work=1' }), '/app/addons/studio/studio/images?work=1&model=a+b') - assert.equal(studioAddress({ search: '?work=w%201' }), '/app/addons/studio/studio?work=w+1') - assert.equal(talkAddress(), '/app/addons/talk/talk') - assert.equal(talkAddress({ search: '?model=x', hash: '#h' }), '/app/addons/talk/talk?model=x#h') +test('removed addon routes have no registrations and native routes remain', () => { + for (const path of ['studio','studio/:tab','studio/:tab/:model','talk','image','video','3d','tts','sound','diarization','transform','face','voice','voice-library','voice-library/new','fine-tune','quantize']) { + assert.ok(!router.includes(`path: '${path}'`), path) + assert.ok(!router.includes(`path: '${path}/:model'`), path + '/:model') + } + assert.ok(router.includes("path: '*'")) + for (const name of ['StudioTabRedirect','StudioRedirect','TalkRedirect','AddonRedirect','AddonsOffRedirect']) assert.ok(!router.includes(name), name) }) - -test('every standalone generator route maps to a Studio tab', () => { - assert.deepEqual(Object.keys(STUDIO_TABS).sort(), ['3d', 'diarization', 'image', 'sound', 'transform', 'tts', 'video']) +test('generic addon addresses retain nested pages selected model query and hash', () => { + assert.equal(addonPageAddress({addon:'arbitrary',page:'voice/library/new',model:'org/name:雪?&',search:'?x=1',hash:'#h'}), '/app/addons/arbitrary/voice/library/new?x=1&model=org%2Fname%3A%E9%9B%AA%3F%26#h') }) - -test('every moved row targets what studioRedirect builds for its own generator', () => { - const moved = MIGRATION_ROUTES.filter(r => r.kind === 'studio-tab') - assert.equal(moved.length, 7) - for (const r of moved) { - const key = r.from.replace('/app/', '') - assert.equal(r.to, studioRedirect(key), `${r.from} targets ${r.to}`) - } -}) - -test('each moved route has its own redirect entry, plain and with a model, bound to its own generator', () => { - const lines = router.split('\n') - for (const r of MIGRATION_ROUTES.filter(x => x.kind === 'studio-tab')) { - const key = r.from.replace('/app/', '') - for (const path of [key, `${key}/:model`]) { - const entry = lines.find(l => l.includes(`{ path: '${path}', element:`)) - assert.ok(entry, `router.jsx has no entry for path ${path}`) - assert.ok(entry.includes(``), `entry for ${path} is not bound to legacy="${key}": ${entry.trim()}`) - } - } -}) - -test('the Studio and Talk routes redirect to their add-ons, with or without a tab and a model', () => { - for (const path of ["path: 'studio'", "path: 'studio/:tab'", "path: 'studio/:tab/:model'"]) { - assert.ok(router.includes(`{ ${path}, element: }`), `no StudioRedirect for ${path}`) - } - assert.ok(router.includes("{ path: 'talk', element: }")) - assert.ok(MIGRATION_ROUTES.some(r => r.from === '/app/studio' && r.to === '/app/addons/studio/studio' && r.kind === 'addon-page')) - assert.ok(MIGRATION_ROUTES.some(r => r.from === '/app/talk' && r.to === '/app/addons/talk/talk' && r.kind === 'addon-page')) -}) - -test('the router imports no Studio or Talk page: both are add-ons', () => { - assert.ok(!/pages\/(Studio|Talk|ImageGen|VideoGen|ThreeDGen|TTS|Sound|Diarization|AudioTransform)'/.test(router)) -}) - -test('the same-path routes need no redirect', () => { - for (const r of MIGRATION_ROUTES.filter(x => x.kind === 'same')) assert.equal(r.from, r.to) -}) - -test('moved routes redirect to their add-on page, with no built-in page behind them', () => { - // Studio and Talk have their own redirects (see above). - for (const r of MIGRATION_ROUTES.filter(x => x.kind === 'addon-page' && !['/app/studio', '/app/talk'].includes(x.from))) { - const [, , , addon, ...rest] = r.to.split('/') - const page = rest.join('/') - assert.ok(router.includes(``) || router.includes(``), `router.jsx does not redirect ${r.from}`) - } - assert.ok(!router.includes('MovedPage')) - assert.ok(!/pages\/(FaceRecognition|VoiceRecognition|VoiceLibrary|VoiceProfileCreate|FineTune|Quantize)'/.test(router)) -}) - -test('addonPageAddress keeps the page path, the model, the query and the hash', () => { - assert.equal(addonPageAddress({ addon: 'voice', page: 'voice/recognition' }), '/app/addons/voice/voice/recognition') - assert.equal(addonPageAddress({ addon: 'face', page: 'recognition', model: 'org/name', search: '?x=1', hash: '#h' }), '/app/addons/face/recognition?x=1&model=org%2Fname#h') -}) - -test('a moved route with a :model segment carries the model as ?model=, not in the path', () => { - assert.ok(router.includes('withModel'), 'the model routes must use withModel') - assert.ok(!/\/app\/addons\/\$\{addon\}\/\$\{page\}\/\$\{model\}/.test(router), 'the model must not be appended to the path') -}) - -test('the face route and the face model route both open the Face add-on, the model as ?model=', () => { - assert.ok(MIGRATION_ROUTES.some(r => r.from === '/app/face' && r.to === '/app/addons/face/recognition' && r.kind === 'addon-page')) - const lines = router.split('\n') - const plain = lines.find(l => l.includes("{ path: 'face', element:")) - const withModel = lines.find(l => l.includes("{ path: 'face/:model', element:")) - assert.ok(plain?.includes(''), 'face does not redirect') - assert.ok(withModel?.includes(''), 'face/:model does not redirect with withModel') -}) - -test('the fine-tune and quantize routes redirect to their add-on pages', () => { - const lines = router.split('\n') - const rows = [ - { from: '/app/fine-tune', to: '/app/addons/fine-tuning/jobs', addon: 'fine-tuning', tool: 'fineTune', feature: 'fine_tuning', path: 'fine-tune' }, - { from: '/app/quantize', to: '/app/addons/quantization/jobs', addon: 'quantization', tool: 'quantize', feature: 'quantization', path: 'quantize' }, - ] - for (const r of rows) { - assert.ok(MIGRATION_ROUTES.some(m => m.from === r.from && m.to === r.to && m.kind === 'addon-page'), `no row for ${r.from}`) - const entry = lines.find(l => l.includes(`{ path: '${r.path}', element:`)) - assert.ok(entry?.includes(``), `${r.path} does not redirect`) - } -}) - -test('the Build hub is gone and its address opens the models', async () => { - const { hubs } = await import('../../src/components/hub/hubConfig.js') - assert.ok(!hubs.some(h => h.id === 'build')) - const entry = router.split('\n').find(l => l.includes("{ path: 'build', element:")) - assert.ok(entry?.includes('')) -}) - -test('the voice routes open the tabs of the one Voice page', () => { - const to = (from) => MIGRATION_ROUTES.find(r => r.from === from)?.to - assert.equal(to('/app/voice'), '/app/addons/voice/voice/recognition') - assert.equal(to('/app/voice-library'), '/app/addons/voice/voice/library') - assert.equal(to('/app/voice-library/new'), '/app/addons/voice/voice/library/new') -}) - test('chatsRedirect opens the matching page and keeps the query and the hash; the old chat page opens Home', () => { assert.equal(chatsRedirect('/app/chat'), '/app') assert.equal(chatsRedirect('/app/chat/'), '/app') diff --git a/core/http/react-ui/tests/unit/slots.test.mjs b/core/http/react-ui/tests/unit/slots.test.mjs index 1c7dfd518..71d3a5586 100644 --- a/core/http/react-ui/tests/unit/slots.test.mjs +++ b/core/http/react-ui/tests/unit/slots.test.mjs @@ -291,8 +291,8 @@ test('slots: the SDK can present an approval of its own addon only through the h test('slots: a page that only needs the list reads it once and never retries a failed read', () => { const base = { allowed: true, onAddonsPage: false, apps: [], consumers: 3 } assert.equal(shouldPoll({ ...base, status: 'idle' }), true) - assert.equal(shouldPoll({ ...base, status: 'ready' }), false) - assert.equal(shouldPoll({ ...base, status: 'error' }), false, 'no loop of failing reads for a page that only draws slots') + assert.equal(shouldPoll({ ...base, status: 'ready' }), true) + assert.equal(shouldPoll({ ...base, status: 'error' }), true, 'mounted consumers recover after failing reads') assert.equal(shouldPoll({ ...base, status: 'idle', allowed: false }), false) }) diff --git a/core/http/routes/addon_navigation_test.go b/core/http/routes/addon_navigation_test.go index 413a2f077..55c366b69 100644 --- a/core/http/routes/addon_navigation_test.go +++ b/core/http/routes/addon_navigation_test.go @@ -99,9 +99,8 @@ var _ = Describe("Add-on navigation routes with real authentication", func() { Expect(w.Code).To(Equal(http.StatusOK), w.Body.String()) var got []string Expect(json.Unmarshal(w.Body.Bytes(), &got)).To(Succeed()) - // The member existed when the routes registered, so the one-time upgrade - // seed pinned Studio and Talk for it. - Expect(got).To(Equal([]string{"studio", "talk"})) + // Navigation entries do not implicitly opt a member into pins. + Expect(got).To(BeEmpty()) put := func(sameOrigin bool) *httptest.ResponseRecorder { r := httptest.NewRequest(http.MethodPut, "/api/addons/pins", strings.NewReader(`["talk","studio"]`)) diff --git a/core/http/routes/studio_egress_test.go b/core/http/routes/studio_egress_test.go index 0eb49a4e4..00d426fe7 100644 --- a/core/http/routes/studio_egress_test.go +++ b/core/http/routes/studio_egress_test.go @@ -11,6 +11,7 @@ import ( "github.com/mudler/LocalAI/core/services/studio/egress" "github.com/mudler/LocalAI/core/services/studio/egress/audit" "github.com/mudler/LocalAI/core/services/studio/store" + "github.com/mudler/LocalAI/pkg/addons/contract" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -27,7 +28,7 @@ var _ = Describe("Chats Sandbox network routes", func() { s = newStudioServerWith(newTestFiles(), func(d *studio.Deps) { chats = d.Store d.IsAdmin = func(_ context.Context, user string) bool { return user == "root" } - d.AddonTools = routeAddons{} + d.AddonTools = networkPolicyAddons{} d.EgressAudit = lines d.EgressSettings = func(context.Context) (egress.Settings, error) { return egress.Settings{Ceiling: egress.PresetRegistries, ExtraRegistries: []string{"mirror.example.org"}}, nil @@ -54,7 +55,9 @@ var _ = Describe("Chats Sandbox network routes", func() { v := decodeJSON(rec) Expect(v).To(HaveKeyWithValue("preset", "off")) Expect(v).To(HaveKeyWithValue("ceiling", "registries")) - Expect(v).To(HaveKeyWithValue("applies", true)) + // Declared controls draw the row; neither isolation nor the control proves enforcement. + Expect(v).To(HaveKeyWithValue("applies", false)) + Expect(v).To(HaveKeyWithValue("addon", "sandbox")) Expect(v["registries"]).To(ContainElement(HaveKeyWithValue("id", "extra"))) rec = s.do("alice", http.MethodPut, "/api/chats/"+id+"/egress", map[string]any{"preset": "all"}) @@ -140,3 +143,13 @@ var _ = Describe("Chats Sandbox network routes", func() { } }) }) + +// Only this fixture declares enforcement of the host network policy. +type networkPolicyAddons struct{ routeAddons } + +func (networkPolicyAddons) Controls(_ context.Context, _, addon string) []string { + if addon == "sandbox" { + return []string{contract.ControlNetworkPolicy} + } + return nil +} diff --git a/core/schema/chats.go b/core/schema/chats.go index e56404ea5..568943385 100644 --- a/core/schema/chats.go +++ b/core/schema/chats.go @@ -802,6 +802,8 @@ type ChatsAddon struct { // ChatsAddonList lists the add-ons a chat can use. type ChatsAddonList struct { + ExecutionStatus *ChatsExecutionStatus `json:"execution_status,omitempty"` + Addons []ChatsAddon `json:"addons"` // Commands says whether the chat can run commands and code at its next // turn: available, off (the add-on that runs them is switched off for this @@ -1689,3 +1691,12 @@ type ChatsEgressEvent struct { type ChatsEgressEventList struct { Events []ChatsEgressEvent `json:"events"` } + +// ChatsExecutionStatus is presentation-only; it grants no execution authority. +type ChatsExecutionStatus struct { + Readiness string `json:"readiness"` + Isolation string `json:"isolation"` + Explanation string `json:"explanation,omitempty"` + Generation string `json:"generation,omitempty"` + Incarnation string `json:"incarnation,omitempty"` +} diff --git a/core/services/addons/admission.go b/core/services/addons/admission.go new file mode 100644 index 000000000..536fe2952 --- /dev/null +++ b/core/services/addons/admission.go @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MIT +package addons + +import ( + "fmt" + "slices" + "strings" + + "github.com/mudler/LocalAI/core/services/addons/hostops" + "github.com/mudler/LocalAI/core/services/addons/packages" +) + +// HostRequirementsRefusal is returned only by owner-admin lifecycle operations. +// It contains verified request names, never policy paths or grants of others. +type HostRequirementsRefusal struct{ Summary hostops.RequestSummary } + +func (e *HostRequirementsRefusal) Error() string { + return fmt.Sprintf("required host operations unavailable: %s; configure host policy and restart", strings.Join(e.Summary.MissingRequired, ", ")) +} +func (m *Manager) requests(r packages.PackageReceipt) hostops.RequestSummary { + return hostops.Requests(r.Manifest.Capabilities.HostOperations, hostops.SupportedOperations(), m.hostOps[r.Manifest.ID]) +} +func (m *Manager) admit(r packages.PackageReceipt) error { + s := m.requests(r) + if len(s.MissingRequired) != 0 { + return &HostRequirementsRefusal{Summary: s} + } + return nil +} +func (m *Manager) admission(id string) error { + if r, ok := currentReceipt(m.journal.Addons[id]); ok { + return m.admit(r) + } + return nil +} + +func summaryPointer(s hostops.RequestSummary) *hostops.RequestSummary { + if s.RequiredCount+s.OptionalCount == 0 { + return nil + } + return &s +} + +func copyRequestSummary(s *hostops.RequestSummary) *hostops.RequestSummary { + if s == nil { + return nil + } + out := *s + out.Required = slices.Clone(s.Required) + out.Optional = slices.Clone(s.Optional) + out.Granted = slices.Clone(s.Granted) + out.MissingRequired = slices.Clone(s.MissingRequired) + out.MissingOptional = slices.Clone(s.MissingOptional) + return &out +} diff --git a/core/services/addons/apps/apps.go b/core/services/addons/apps/apps.go index 06878624b..9eab146a9 100644 --- a/core/services/addons/apps/apps.go +++ b/core/services/addons/apps/apps.go @@ -5,6 +5,8 @@ package apps import ( + "github.com/mudler/LocalAI/core/services/addons/hostops" + "maps" "net/url" "regexp" "slices" @@ -34,7 +36,9 @@ type Snapshot struct { UpdatePending bool // HostOps are the host operation prefixes the host setting grants this // add-on. They decide which declared host controls are drawn. - HostOps []string + HostOps []string + HostOperations *hostops.RequestSummary + Unavailable bool } // List is the response of the apps list. @@ -151,12 +155,27 @@ type UIView struct { Actions []ChatActionView `json:"actions,omitempty"` Slots []SlotView `json:"slots,omitempty"` Renderers []contract.Renderer `json:"renderers,omitempty"` + // HomeCommands contains only resolved navigation metadata for this app. + HomeCommands []HomeCommandView `json:"homeCommands,omitempty"` + // ModelActions contains only declared destinations for this installed UI. + ModelActions []contract.ModelAction `json:"modelActions,omitempty"` // Controls are the host controls the host draws for the addon: the ones // its manifest declares that its grants and tools entitle it to now. The // host draws them with its own components; the addon supplies nothing. Controls []string `json:"controls,omitempty" enums:"network-policy,tool-providers" example:"network-policy"` } +// HomeCommandView is a navigation declaration resolved against this installed +// generation. Page is a declared page identifier, not a URL or tool invocation. +type HomeCommandView struct { + ID string `json:"id"` + Alias string `json:"alias,omitempty"` + Page string `json:"page"` + Label string `json:"label"` + Labels map[string]string `json:"labels,omitempty"` + Icon string `json:"icon"` +} + // ChatActionView is a declared chat action as the host draws it. type ChatActionView struct { // ID is the manifest identifier of the action. @@ -191,8 +210,9 @@ type SlotView struct { // refuses the matching requests of everyone else; this is a convenience for // the UI, never the control. type AdminView struct { - CanStart bool `json:"canStart"` - CanStop bool `json:"canStop"` + CanStart bool `json:"canStart"` + HostOperations *hostops.RequestSummary `json:"hostOperations,omitempty"` + CanStop bool `json:"canStop"` // ManagementURL opens the management view of the addon. ManagementURL string `json:"managementUrl" example:"/app/addons?manage=notes"` } @@ -242,10 +262,10 @@ func build(s Snapshot, admin bool) App { // The receipt came from the owner's journal, but the route is // re-checked here: an Open action for a page the addon does not // declare is never produced. - if s.Enabled && u.MainPage != "" && slices.Contains(u.Pages, u.MainPage) { + if s.Enabled && !s.Unavailable && u.MainPage != "" && slices.Contains(u.Pages, u.MainPage) { a.MainPage = "/app/addons/" + url.PathEscape(id) + "/" + url.PathEscape(u.MainPage) } - if s.Enabled { + if s.Enabled && !s.Unavailable { a.UI = uiView(id, s.Receipt.Digest, u) if a.UI != nil { a.UI.Controls = contract.EntitledControls(m, s.HostOps) @@ -256,7 +276,7 @@ func build(s Snapshot, admin bool) App { a.Update = "update-pending" } if admin { - a.Admin = &AdminView{CanStart: !s.Enabled, CanStop: s.Enabled, ManagementURL: "/app/addons?manage=" + url.QueryEscape(id)} + a.Admin = &AdminView{CanStart: !s.Enabled && !s.Unavailable, HostOperations: s.HostOperations, CanStop: s.Enabled, ManagementURL: "/app/addons?manage=" + url.QueryEscape(id)} } return a } @@ -293,6 +313,42 @@ func uiView(id, digest string, u *contract.UI) *UIView { } v := &UIView{Entry: asset(u.Entry), SDK: u.SDK, Pages: slices.Clone(u.Pages), Navigation: slices.Clone(u.Navigation), ChatActions: u.ChatActionIDs()} v.Actions, v.Slots, v.Renderers = contributions(u) + if contract.ValidateModelActions(u) == nil { + v.ModelActions = slices.Clone(u.ModelActions) + } + for _, command := range u.HomeCommands { + page := command.Page + if page == "" { + page = u.MainPage + } + if page == "" || !slices.Contains(u.Pages, page) { + continue + } + c := HomeCommandView{ID: command.ID, Alias: command.Alias, Page: page, Label: id, Icon: "puzzle"} + if u.Title != nil { + c.Label, c.Labels = u.Title.Default(), maps.Clone(u.Title.ByLanguage) + } + if page != u.MainPage { + c.Label += " — " + page + for language, label := range c.Labels { + c.Labels[language] = label + " — " + page + } + } + if command.Label != nil { + c.Label, c.Labels = command.Label.Default(), maps.Clone(command.Label.ByLanguage) + } + if u.Icon != "" { + c.Icon = asset(u.Icon) + } + if command.Icon != "" { + if command.Icon == u.Icon { + c.Icon = asset(u.Icon) + } else { + c.Icon = command.Icon + } + } + v.HomeCommands = append(v.HomeCommands, c) + } for _, css := range u.CSS { v.CSS = append(v.CSS, asset(css)) } diff --git a/core/services/addons/apps/extensions_test.go b/core/services/addons/apps/extensions_test.go new file mode 100644 index 000000000..63011aa22 --- /dev/null +++ b/core/services/addons/apps/extensions_test.go @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: MIT +package apps + +import ( + "encoding/json" + "strings" + + "github.com/mudler/LocalAI/pkg/addons/contract" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("typed home metadata", func() { + It("projects home commands with resolved declared pages display metadata immutable icons and generation", func() { + u := page("home") + u.HomeCommands = []contract.HomeCommand{{ID: "open", Alias: "notes"}, {ID: "history", Page: "other"}, {ID: "custom", Page: "other", Label: &contract.Localized{ByLanguage: map[string]string{"en": "History", "it": "Cronologia"}}, Icon: "clock"}, {ID: "packaged", Icon: u.Icon}} + a := Build([]Snapshot{homeSnapshot(u)}, false)[0] + Expect(a.Generation).To(Equal(uint64(3))) + b, e := json.Marshal(a.UI) + Expect(e).NotTo(HaveOccurred()) + var wire struct { + HomeCommands []struct { + ID, Alias, Page, Label, Icon string + Labels map[string]string + } `json:"homeCommands"` + } + Expect(json.Unmarshal(b, &wire)).To(Succeed()) + Expect(wire.HomeCommands).To(HaveLen(4)) + commands := wire.HomeCommands + Expect(commands[0].ID).To(Equal("open")) + Expect(commands[0].Alias).To(Equal("notes")) + Expect(commands[0].Page).To(Equal("home")) + Expect(commands[0].Label).To(Equal("Notes")) + Expect(commands[0].Labels).To(Equal(u.Title.ByLanguage)) + Expect(commands[0].Icon).To(Equal("/api/addons/notes/assets/" + strings.Repeat("a", 64) + "/ui/icon.png")) + Expect(commands[1].Page).To(Equal("other")) + Expect(commands[1].Label).To(Equal("Notes — other")) + Expect(commands[2].Label).To(Equal("History")) + Expect(commands[2].Labels["it"]).To(Equal("Cronologia")) + Expect(commands[2].Icon).To(Equal("clock")) + Expect(commands[3].Icon).To(Equal(commands[0].Icon)) + Expect(string(b)).NotTo(ContainSubstring(secretDir)) + }) + It("keeps disabled home apps installed without UI and never invents a destination", func() { + u := page("home") + u.HomeCommands = []contract.HomeCommand{{ID: "open"}} + s := snapshot("notes", false, "running", u) + Expect(BuildInstalled([]Snapshot{s})).To(HaveLen(1)) + a := Build([]Snapshot{s}, true)[0] + Expect(a.UI).To(BeNil()) + Expect(a.Generation).To(Equal(uint64(3))) + s.Receipt.Digest = "sha256:" + strings.Repeat("a", 64) + s.Enabled = true + u.MainPage = "missing" + u.HomeCommands = append(u.HomeCommands, contract.HomeCommand{ID: "bad", Page: "missing"}) + b, e := json.Marshal(Build([]Snapshot{s}, false)[0].UI) + Expect(e).NotTo(HaveOccurred()) + Expect(string(b)).NotTo(ContainSubstring("homeCommands")) + u.MainPage = "home" + u.Icon = "" + u.Title = nil + u.HomeCommands = []contract.HomeCommand{{ID: "open"}} + b, e = json.Marshal(Build([]Snapshot{s}, false)[0].UI) + Expect(e).NotTo(HaveOccurred()) + Expect(string(b)).To(ContainSubstring(`"label":"notes"`)) + Expect(string(b)).To(ContainSubstring(`"icon":"puzzle"`)) + }) +}) + +func homeSnapshot(u *contract.UI) Snapshot { + s := snapshot("notes", true, "running", u) + s.Receipt.Digest = "sha256:" + strings.Repeat("a", 64) + return s +} + +var _ = Describe("generic Home contributions", func() { + It("projects two non-Studio addon pages without identity dispatch", func() { + for _, id := range []string{"paint", "notebook"} { + u := page("canvas") + u.Pages = []string{"canvas", "other"} + u.HomeCommands = []contract.HomeCommand{{ID: "open"}, {ID: "past", Page: "other"}} + s := snapshot(id, true, "running", u) + s.Receipt.Digest = "sha256:" + strings.Repeat("a", 64) + a := Build([]Snapshot{s}, false)[0] + Expect(a.ID).To(Equal(id)) + Expect(a.UI.HomeCommands).To(HaveLen(2)) + Expect(a.UI.HomeCommands[0].Page).To(Equal("canvas")) + Expect(a.UI.HomeCommands[1].Page).To(Equal("other")) + } + }) +}) diff --git a/core/services/addons/apps/installed.go b/core/services/addons/apps/installed.go index 6ce8d61e1..fdf2ea40e 100644 --- a/core/services/addons/apps/installed.go +++ b/core/services/addons/apps/installed.go @@ -2,6 +2,7 @@ package apps import ( + "github.com/mudler/LocalAI/core/services/addons/hostops" "sort" "github.com/mudler/LocalAI/pkg/addons/contract" @@ -12,9 +13,10 @@ import ( // process observation. It never carries a package location, a source address, // a data path or a credential. type Installed struct { - ID string `json:"id" example:"notes"` - Title string `json:"title" example:"Notes"` - Description string `json:"description,omitempty"` + HostOperations *hostops.RequestSummary `json:"hostOperations,omitempty"` + ID string `json:"id" example:"notes"` + Title string `json:"title" example:"Notes"` + Description string `json:"description,omitempty"` // Icon is the same-origin icon URL of the installed generation. Icon string `json:"icon,omitempty"` Version string `json:"version" example:"1.2.3"` @@ -117,7 +119,7 @@ func BuildInstalled(snaps []Snapshot) []Installed { a := build(s, true) m, r := s.Receipt.Manifest, s.Receipt out = append(out, Installed{ - ID: s.AddonID, Title: a.Title, Description: a.Description, Icon: a.Icon, Version: m.Version, InstalledAt: s.InstalledAt, Generation: s.Generation, + ID: s.AddonID, HostOperations: s.HostOperations, Title: a.Title, Description: a.Description, Icon: a.Icon, Version: m.Version, InstalledAt: s.InstalledAt, Generation: s.Generation, Enabled: s.Enabled, State: a.State, SourceID: r.SourceID, Channel: r.Channel, Selection: r.Selection, Verification: r.Verification, Digest: r.Digest, Platform: Platform{OS: m.OS, Arch: m.Arch}, Compatibility: Compatibility{Host: m.Host, API: m.API, Protocol: m.Protocol, DataFormat: m.DataFormat}, diff --git a/core/services/addons/apps/modelactions_test.go b/core/services/addons/apps/modelactions_test.go new file mode 100644 index 000000000..b6af9812a --- /dev/null +++ b/core/services/addons/apps/modelactions_test.go @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MIT +package apps + +import ( + "encoding/json" + "github.com/mudler/LocalAI/pkg/addons/contract" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("installed model actions", func() { + It("projects typed model actions without sharing manifest or view slices", func() { + u := page("home") + u.ModelActions = []contract.ModelAction{{Capability: "FLAG_IMAGE", Page: "home", Path: "images"}} + s := homeSnapshot(u) + a := Build([]Snapshot{s}, false)[0] + Expect(a.UI.ModelActions).To(Equal(u.ModelActions)) + b, err := json.Marshal(a) + Expect(err).NotTo(HaveOccurred()) + Expect(string(b)).To(ContainSubstring(`"modelActions":[{"capability":"FLAG_IMAGE","page":"home","path":"images"}]`)) + u.ModelActions[0].Path = "changed" + Expect(a.UI.ModelActions[0].Path).To(Equal("images")) + a.UI.ModelActions[0].Page = "other" + Expect(u.ModelActions[0].Page).To(Equal("home")) + }) + It("keeps unavailable installed records and omits unavailable or absent model metadata", func() { + u := page("home") + u.ModelActions = []contract.ModelAction{{Capability: "FLAG_IMAGE", Page: "home"}} + s := homeSnapshot(u) + s.Enabled = false + Expect(BuildInstalled([]Snapshot{s})).To(HaveLen(1)) + Expect(Build([]Snapshot{s}, true)[0].UI).To(BeNil()) + s.Enabled = true + u.ModelActions = nil + b, err := json.Marshal(Build([]Snapshot{s}, false)[0]) + Expect(err).NotTo(HaveOccurred()) + Expect(string(b)).NotTo(ContainSubstring("modelActions")) + u.ModelActions = []contract.ModelAction{{Capability: "FLAG_CHAT", Page: "home"}} + Expect(Build([]Snapshot{s}, false)[0].UI.ModelActions).To(BeEmpty()) + }) +}) diff --git a/core/services/addons/extensions_lifecycle_test.go b/core/services/addons/extensions_lifecycle_test.go new file mode 100644 index 000000000..e238b7af5 --- /dev/null +++ b/core/services/addons/extensions_lifecycle_test.go @@ -0,0 +1,277 @@ +//go:build linux + +// SPDX-License-Identifier: MIT +package addons + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "time" + + "github.com/mudler/LocalAI/core/services/addons/catalog" + "github.com/mudler/LocalAI/core/services/addons/packages" + addonruntime "github.com/mudler/LocalAI/core/services/addons/runtime" + "github.com/mudler/LocalAI/core/services/addons/updates" + "github.com/mudler/LocalAI/pkg/addons/contract" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func lifecycleCapabilities(label string) contract.Capabilities { + return contract.Capabilities{Service: true, Actions: true, Tools: []string{"run", "health"}, 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: "Health", Kind: "read", UIOnly: true, InputSchema: json.RawMessage(`{"type":"object","additionalProperties":false}`)}, + }, UI: &contract.UI{Entry: "ui.js", SDK: contract.VersionRange{Min: 2, Max: 2}, Pages: []string{"home"}, MainPage: "home", HomeCommands: []contract.HomeCommand{{ID: "open", Alias: label}}, ModelActions: []contract.ModelAction{{Capability: "FLAG_IMAGE", Page: "home", Path: label}}}, ExecutionProvider: &contract.ExecutionProvider{Tools: []string{"run"}, StatusTool: "health"}, HostOperations: &contract.HostOperations{Required: []string{"workspace.materialize"}, Optional: []string{"egress.check"}}} +} + +var _ = Describe("typed staged Manager lifecycle", func() { + It("persists and recovers model actions and declarations through install manual and automatic upgrades and real activation rollback", func() { + r := newUpdateRig(true) + c := r.m.catalog + installer := r.m.installer + installer.Host.SDK = 2 + binary := filepath.Join(GinkgoT().TempDir(), "fixture") + out, e := exec.Command("go", "build", "-o", binary, "../../../tests/addons/fixture/cmd").CombinedOutput() + Expect(e).NotTo(HaveOccurred(), string(out)) + executable, e := os.ReadFile(binary) + Expect(e).NotTo(HaveOccurred()) + caps := lifecycleCapabilities("first") + mode := "normal" + unsupported := false + r.lib.transform = func(m map[string]any) map[string][]byte { + m["capabilities"] = caps + if unsupported { + m["futureExtension"] = true + } + config, _ := json.Marshal(map[string]string{"mode": mode}) + return map[string][]byte{"bin/sample": executable, "ui.js": []byte("export {}"), "fixture.json": config} + } + attach := func() { + s, e := addonruntime.NewSupervisor(addonruntime.Config{StateRoot: filepath.Join(r.cfg.Root, "runtime"), Limits: contract.DefaultLimits(), ReadyTimeout: time.Second, StableHealth: time.Second}) + Expect(e).NotTo(HaveOccurred()) + Expect(r.m.AttachRuntime(ctx, s)).To(Succeed()) + } + check := func(number uint64, digest string, want contract.Capabilities) addonruntime.Instance { + GinkgoHelper() + st, e := r.m.Status(ctx, "sample") + Expect(e).NotTo(HaveOccurred()) + Expect(st.Installed).To(BeTrue()) + Expect(st.DesiredEnabled).To(BeTrue()) + Expect(st.Generation).To(Equal(number)) + var chosen packages.PackageReceipt + for _, g := range st.Generations { + if g.Number == number { + chosen = g.Receipt + } + } + Expect(chosen.Digest).To(Equal(digest)) + Expect(chosen.Manifest.Capabilities).To(Equal(want)) + declared, e := r.m.DeclaredCapabilities(ctx, "sample", number) + Expect(e).NotTo(HaveOccurred()) + Expect(declared.ExecutionProvider).To(Equal(want.ExecutionProvider)) + Expect(declared.HostOperations).To(Equal(want.HostOperations)) + declared.ExecutionProvider.Tools[0] = "mutated" + declared.HostOperations.Required[0] = "mutated" + declared.HostOperations.Optional[0] = "mutated" + again, e := r.m.DeclaredCapabilities(ctx, "sample", number) + Expect(e).NotTo(HaveOccurred()) + Expect(again.ExecutionProvider).To(Equal(want.ExecutionProvider)) + Expect(again.HostOperations).To(Equal(want.HostOperations)) + raw, e := os.ReadFile(filepath.Join(chosen.Directory, "receipt.json")) + Expect(e).NotTo(HaveOccurred()) + var disk packages.PackageReceipt + Expect(json.Unmarshal(raw, &disk)).To(Succeed()) + Expect(disk).To(Equal(chosen)) + instance, e := r.m.Runtime(ctx, "sample") + Expect(e).NotTo(HaveOccurred()) + Expect(instance.Generation).To(Equal(number)) + regs, e := r.m.runtime.Registry.Snapshot(ctx) + Expect(e).NotTo(HaveOccurred()) + Expect(regs).To(HaveLen(1)) + Expect(regs[0].Generation).To(Equal(number)) + Expect(regs[0].Incarnation).To(Equal(instance.Incarnation)) + Expect(regs[0].Capabilities).To(Equal(want)) + installedApps, e := r.m.Apps(ctx, actor) + Expect(e).NotTo(HaveOccurred()) + Expect(installedApps).To(HaveLen(1)) + Expect(installedApps[0].UI).NotTo(BeNil()) + Expect(installedApps[0].UI.ModelActions).To(Equal(want.UI.ModelActions)) + installedApps[0].UI.ModelActions[0].Path = "mutated" + freshApps, e := r.m.Apps(ctx, actor) + Expect(e).NotTo(HaveOccurred()) + Expect(freshApps[0].UI.ModelActions).To(Equal(want.UI.ModelActions)) + return instance + } + reopen := func() { + Expect(r.m.Close()).To(Succeed()) + r.m = open(r.cfg, c, installer) + r.m.SetHostOps(map[string][]string{"sample": {"workspace."}}) + r.m.EnableUpdates(updates.Config{Now: func() time.Time { return *r.clk }, MinRefresh: time.Second, TTL: time.Hour}) + Expect(r.m.Reconcile(ctx)).To(Succeed()) + attach() + } + first := r.lib.publish("1.2.3-dev", "development", "typed-first", catalog.Requirements{}) + r.lib.refresh() + // Admission must reject a verified package before publication. + _, e = r.m.Submit(ctx, actor, LifecycleCommand{OperationID: "missing-required", AddonID: "sample", Action: "install", SourceID: "test", Version: "development"}) + Expect(e).To(HaveOccurred()) + missing, e := r.m.Status(ctx, "sample") + Expect(e).NotTo(HaveOccurred()) + Expect(missing.Installed).To(BeFalse()) + r.m.SetHostOps(map[string][]string{"sample": {"workspace."}}) + r.installChannel() + _, e = r.m.Submit(ctx, actor, LifecycleCommand{OperationID: "enable-typed", AddonID: "sample", Action: "enable"}) + Expect(e).NotTo(HaveOccurred()) + attach() + old := check(1, first.Digest, caps) + check(1, first.Digest, caps) + adminApps, e := r.m.Apps(ctx, actor) + Expect(e).NotTo(HaveOccurred()) + Expect(adminApps).To(HaveLen(1)) + Expect(adminApps[0].Admin.HostOperations.MissingOptional).To(Equal([]string{"egress.check"})) + Expect(adminApps[0].State).To(Equal("running")) + // A later SetHostOps is not a hot reload or a self grant. + r.m.SetHostOps(map[string][]string{"sample": {"workspace.", "egress."}}) + Expect(r.m.hostOps["sample"]).To(Equal([]string{"workspace."})) + + reopen() + recovered := check(1, first.Digest, caps) + Expect(recovered.Incarnation).NotTo(Equal(old.Incarnation)) + // A stricter candidate must not stop the active incarnation. + beforeRefusal, e := r.m.Status(ctx, "sample") + Expect(e).NotTo(HaveOccurred()) + caps = lifecycleCapabilities("unmet") + caps.HostOperations.Required = append(caps.HostOperations.Required, "egress.audit") + unmet := r.lib.publish("1.2.3-dev", "development", "typed-unmet", catalog.Requirements{}) + r.lib.refresh() + _, e = r.m.Submit(ctx, actor, upgrade("unmet-manual", first.Digest, unmet.Digest)) + Expect(e).To(HaveOccurred()) + r.tick(2 * time.Second) + denied := r.m.AutoUpgrade(ctx) + Expect(denied).To(HaveLen(1)) + Expect(denied[0].Err).To(HaveOccurred()) + r.tick(2 * time.Second) + skipped := r.m.AutoUpgrade(ctx) + Expect(skipped).To(HaveLen(1)) + Expect(skipped[0].SkippedAsFailure).To(BeTrue()) + afterRefusal, e := r.m.Status(ctx, "sample") + Expect(e).NotTo(HaveOccurred()) + Expect(afterRefusal).To(Equal(beforeRefusal)) + Expect(check(1, first.Digest, lifecycleCapabilities("first")).Incarnation).To(Equal(recovered.Incarnation)) + caps = lifecycleCapabilities("manual") + manual := r.lib.publish("1.2.3-dev", "development", "typed-manual", catalog.Requirements{}) + r.lib.refresh() + _, e = r.m.Submit(ctx, actor, upgrade("typed-manual", first.Digest, manual.Digest)) + Expect(e).NotTo(HaveOccurred()) + check(2, manual.Digest, caps) + caps = lifecycleCapabilities("automatic") + automatic := r.lib.publish("1.2.3-dev", "development", "typed-auto", catalog.Requirements{}) + r.tick(2 * time.Second) + results := r.m.AutoUpgrade(ctx) + Expect(results).To(HaveLen(1)) + Expect(results[0].Err).NotTo(HaveOccurred()) + Expect(results[0].Upgraded).To(BeTrue()) + prior := check(3, automatic.Digest, caps) + priorCaps := caps + // The verified candidate stages successfully, but its actual subprocess + // fails registration. Manager must start the prior receipt again. + caps = lifecycleCapabilities("broken") + mode = "exit-start" + broken := r.lib.publish("1.2.3-dev", "development", "typed-broken", catalog.Requirements{}) + r.lib.refresh() + _, e = r.m.Submit(ctx, actor, upgrade("typed-rollback", automatic.Digest, broken.Digest)) + Expect(e).To(HaveOccurred()) + restored := check(3, automatic.Digest, priorCaps) + Expect(restored.Incarnation).NotTo(Equal(prior.Incarnation)) + recoveryHost := &managerHost{m: r.m} + q, qe := recoveryHost.Quiesce(ctx, "sample") + Expect(qe).NotTo(HaveOccurred()) + var receipt packages.PackageReceipt + for _, g := range r.m.journal.Addons["sample"].Generations { + if g.Number == 3 { + receipt = g.Receipt + } + } + Expect(recoveryHost.StartCandidate(ctx, "sample", receipt, 3)).To(Succeed()) + Expect(recoveryHost.StopCandidate(ctx, "sample")).To(Succeed()) + Expect(recoveryHost.StopCandidate(ctx, "sample")).To(Succeed()) + Expect(q.Resume(ctx)).To(Succeed()) + check(3, automatic.Digest, priorCaps) + st, e := r.m.Status(ctx, "sample") + Expect(e).NotTo(HaveOccurred()) + Expect(st.Generations).To(HaveLen(3)) + reopen() + restarted := check(3, automatic.Digest, priorCaps) + Expect(restarted.Incarnation).NotTo(Equal(restored.Incarnation)) + // Strict unsupported-manifest refusal must leave the active installed + // state and process intact, not just preserve old immutable files. + mode = "normal" + unsupported = true + refused := r.lib.publish("1.2.3-dev", "development", "typed-unsupported", catalog.Requirements{}) + r.lib.refresh() + before, e := r.m.Status(ctx, "sample") + Expect(e).NotTo(HaveOccurred()) + _, e = r.m.Submit(ctx, actor, upgrade("typed-refusal", automatic.Digest, refused.Digest)) + Expect(e).To(HaveOccurred()) + after, e := r.m.Status(ctx, "sample") + Expect(e).NotTo(HaveOccurred()) + Expect(after).To(Equal(before)) + unchanged := check(3, automatic.Digest, priorCaps) + Expect(unchanged.Incarnation).To(Equal(restarted.Incarnation)) + reopen() + check(3, automatic.Digest, priorCaps) + // Restart with removed grants retains identity and enabled intent, + // but may not spawn a subprocess or admit tools/resources. + Expect(r.m.Close()).To(Succeed()) + r.m = open(r.cfg, c, installer) + r.m.SetHostOps(map[string][]string{}) + Expect(r.m.Reconcile(ctx)).To(Succeed()) + attach() + unavailable, e := r.m.Status(ctx, "sample") + Expect(e).NotTo(HaveOccurred()) + Expect(unavailable.Installed).To(BeTrue()) + Expect(unavailable.DesiredEnabled).To(BeTrue()) + Expect(unavailable.Generation).To(Equal(uint64(3))) + Expect(unavailable.StartEligible).To(BeFalse()) + _, e = r.m.Runtime(ctx, "sample") + Expect(e).To(HaveOccurred()) + regs, e := r.m.runtime.Registry.Snapshot(ctx) + Expect(e).NotTo(HaveOccurred()) + Expect(regs).To(BeEmpty()) + adminApps, e = r.m.Apps(ctx, actor) + Expect(e).NotTo(HaveOccurred()) + Expect(adminApps).To(HaveLen(1)) + Expect(adminApps[0].State).To(Equal("failed")) + Expect(adminApps[0].Enabled).To(BeTrue()) + Expect(adminApps[0].UI).To(BeNil()) + Expect(adminApps[0].MainPage).To(BeEmpty()) + Expect(adminApps[0].Admin.HostOperations.MissingRequired).To(Equal([]string{"workspace.materialize"})) + installed, e := r.m.Installed(ctx, actor) + Expect(e).NotTo(HaveOccurred()) + Expect(installed).To(HaveLen(1)) + Expect(installed[0].State).To(Equal("failed")) + Expect(installed[0].HostOperations.MissingRequired).To(Equal([]string{"workspace.materialize"})) + Expect(installed[0].Digest).To(Equal(automatic.Digest)) + readerApps, e := r.m.Apps(ctx, contract.Actor{UserID: "reader"}) + Expect(e).NotTo(HaveOccurred()) + Expect(readerApps).To(HaveLen(1)) + Expect(readerApps[0].Admin).To(BeNil()) + + _, e = r.m.DeclaredTools(ctx, "sample", 3) + Expect(e).To(HaveOccurred()) + _, e = r.m.DeclaredCapabilities(ctx, "sample", 3) + Expect(e).To(HaveOccurred()) + Expect(r.m.Close()).To(Succeed()) + r.m = open(r.cfg, c, installer) + r.m.SetHostOps(map[string][]string{"sample": {"workspace.", "egress."}}) + Expect(r.m.Reconcile(ctx)).To(Succeed()) + attach() + check(3, automatic.Digest, priorCaps) + adminApps, e = r.m.Apps(ctx, actor) + Expect(e).NotTo(HaveOccurred()) + Expect(adminApps[0].Admin.HostOperations.MissingRequired).To(BeEmpty()) + Expect(adminApps[0].Admin.HostOperations.MissingOptional).To(BeEmpty()) + }) +}) diff --git a/core/services/addons/gateway/admission_test.go b/core/services/addons/gateway/admission_test.go new file mode 100644 index 000000000..8af6bdd9d --- /dev/null +++ b/core/services/addons/gateway/admission_test.go @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +package gateway + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + + "github.com/mudler/LocalAI/core/services/addons" + "github.com/mudler/LocalAI/core/services/addons/hostops" + "github.com/mudler/LocalAI/pkg/addons/contract" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Host requirements carrier", func() { + It("preserves bounded owner admission refusals over mTLS without frontend grants", func() { + actor := contract.Actor{UserID: "admin", CredentialID: "key:admin", Mode: "named-user"} + raw := `{"schemaVersion":1,"id":"sample","version":"1.2.3","executable":"bin/sample","os":"linux","arch":"amd64","host":{"min":1,"max":2},"api":{"min":1,"max":1},"protocol":{"min":1,"max":1},"dataFormat":{"min":1,"max":2},"capabilities":{"service":true,"hostOperations":{"required":["workspace.materialize"]}}}` + m := ownerManagerPackage(actor, false, raw, nil, 1, map[string][]string{}) + owner := &Gateway{Manager: m, Revalidate: func(context.Context, contract.Actor) error { return nil }} + _, cs := carrierFixture(func(c *Carrier) http.Handler { + h, e := c.OwnerHandler(owner) + Expect(e).NotTo(HaveOccurred()) + return h + }) + front := &Gateway{Forward: cs["front-a"], Revalidate: func(context.Context, contract.Actor) error { return nil }} + _, e := front.Dispatch(context.Background(), actor, contract.Request{Action: "install", AddonID: "sample", OperationID: "required-missing", Arguments: json.RawMessage(`{"sourceId":"test","version":"1.2.3"}`)}) + Expect(e).To(HaveOccurred()) + var refusal *addons.HostRequirementsRefusal + Expect(errors.As(e, &refusal)).To(BeTrue()) + Expect(refusal.Summary.MissingRequired).To(Equal([]string{"workspace.materialize"})) + st, e := m.Status(context.Background(), "sample") + Expect(e).NotTo(HaveOccurred()) + Expect(st.Installed).To(BeFalse()) + rec := httptest.NewRecorder() + typedError(rec, &addons.HostRequirementsRefusal{Summary: hostops.RequestSummary{MissingRequired: []string{"workspace.materialize"}}}) + Expect(rec.Code).To(Equal(http.StatusForbidden)) + Expect(rec.Body.String()).To(ContainSubstring("workspace.materialize")) + Expect(rec.Body.String()).NotTo(ContainSubstring("directory")) + }) +}) diff --git a/core/services/addons/gateway/binding.go b/core/services/addons/gateway/binding.go new file mode 100644 index 000000000..0e9486260 --- /dev/null +++ b/core/services/addons/gateway/binding.go @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: MIT +package gateway + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "io" + "regexp" + "time" + + "github.com/mudler/LocalAI/pkg/addons/contract" +) + +// ExecutionBindingClaim names context, not authority. The owner must load its +// actor-owned conversation and agent; frontend tool lists and grants are absent. +type ExecutionBindingClaim struct { + ConversationID string `json:"conversationId"` +} + +const executionBindingAction = "execution-binding-v1" + +type executionBindingCall struct { + Version int `json:"version"` + Claim ExecutionBindingClaim `json:"claim"` +} +type executionBindingReply struct { + Version int `json:"version"` + Binding ExecutionStatusBinding `json:"binding"` +} + +func (c *ExecutionBindingClaim) UnmarshalJSON(raw []byte) error { + if observationObject(raw, "conversationId") != nil { + return ErrForbidden + } + type plain ExecutionBindingClaim + var v plain + if decode(bytes.NewReader(raw), &v) != nil || !validExecutionConversation(v.ConversationID) { + return ErrForbidden + } + *c = ExecutionBindingClaim(v) + return nil +} +func (c *executionBindingCall) UnmarshalJSON(raw []byte) error { + if observationObject(raw, "version", "claim") != nil { + return ErrForbidden + } + type plain executionBindingCall + var v plain + if decode(bytes.NewReader(raw), &v) != nil || v.Version != 1 || !validExecutionConversation(v.Claim.ConversationID) { + return ErrForbidden + } + *c = executionBindingCall(v) + return nil +} + +// BindingForActor never treats the claim as a conversation record. Missing +// authoritative context (including remote owner context) must fail closed. +func (g *Gateway) BindingForActor(ctx context.Context, a contract.Actor, c ExecutionBindingClaim) (ExecutionStatusBinding, error) { + if ctx.Err() != nil { + return ExecutionStatusBinding{}, ErrUnavailable + } + if !validExecutionConversation(c.ConversationID) { + return ExecutionStatusBinding{}, ErrForbidden + } + if err := g.revalidate(ctx, a); err != nil { + return ExecutionStatusBinding{}, err + } + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if g.Forward != nil { + return g.Forward.executionBinding(ctx, a, c) + } + if g.ResolveExecutionBinding == nil { + return ExecutionStatusBinding{}, ErrForbidden + } + b, err := g.ResolveExecutionBinding(ctx, a, c) + if err != nil { + return ExecutionStatusBinding{}, ClassifyCheck(err) + } + if b.ConversationID != c.ConversationID || !(ExecutionStatusRequest{Version: 1, Binding: b, ObservationID: "binding"}).valid(a) { + return ExecutionStatusBinding{}, ErrForbidden + } + if ctx.Err() != nil { + return ExecutionStatusBinding{}, ErrUnavailable + } + if err = g.revalidate(ctx, a); err != nil { + return ExecutionStatusBinding{}, err + } + return b, nil +} + +// ValidateExecutionBinding compares every component, never just the revision. +// An application owner uses this same resolver before and after observation. +func (g *Gateway) ValidateExecutionBinding(ctx context.Context, a contract.Actor, b ExecutionStatusBinding) error { + if b.Actor != a { + return ErrForbidden + } + now, err := g.BindingForActor(ctx, a, ExecutionBindingClaim{ConversationID: b.ConversationID}) + if err != nil { + return err + } + if now != b { + return ErrForbidden + } + return nil +} +func (c *Carrier) executionBinding(ctx context.Context, a contract.Actor, claim ExecutionBindingClaim) (ExecutionStatusBinding, error) { + caps, err := c.Capabilities(ctx) + if err != nil || !caps.Supports(executionBindingAction) { + return ExecutionStatusBinding{}, ErrUnavailable + } + var id [24]byte + if _, err = rand.Read(id[:]); err != nil { + return ExecutionStatusBinding{}, ErrUnavailable + } + r, err := c.send(ctx, a, wireRequest{Request: contract.Request{Action: executionBindingAction, OperationID: "binding-" + hex.EncodeToString(id[:])}, ExecutionBinding: &executionBindingCall{Version: 1, Claim: claim}}) + if err != nil { + return ExecutionStatusBinding{}, err + } + // Read-only cleanup: close errors cannot change the decoded result. + defer func() { _ = r.Body.Close() }() + var reply executionBindingReply + if decode(io.LimitReader(r.Body, 32768), &reply) != nil || reply.Version != 1 || reply.Binding.ConversationID != claim.ConversationID || !(ExecutionStatusRequest{Version: 1, Binding: reply.Binding, ObservationID: "binding"}).valid(a) || ctx.Err() != nil { + return ExecutionStatusBinding{}, ErrUnavailable + } + return reply.Binding, nil +} + +// Replies refuse aliases and duplicate keys, including nested identities. +func (r *executionBindingReply) UnmarshalJSON(raw []byte) error { + if observationObject(raw, "version", "binding") != nil { + return ErrForbidden + } + type plain executionBindingReply + var v plain + if json.Unmarshal(raw, &v) != nil { + return ErrForbidden + } + *r = executionBindingReply(v) + return nil +} + +var executionConversationPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`) + +func validExecutionConversation(id string) bool { return executionConversationPattern.MatchString(id) } diff --git a/core/services/addons/gateway/binding_test.go b/core/services/addons/gateway/binding_test.go new file mode 100644 index 000000000..cfa6397a1 --- /dev/null +++ b/core/services/addons/gateway/binding_test.go @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: MIT +package gateway + +import ( + "context" + "encoding/json" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "net/http" + "strings" + + "github.com/mudler/LocalAI/pkg/addons/contract" +) + +var _ = Describe("owner binding validation", func() { + It("validates current selection and authenticated actor", func() { + ctx := context.Background() + claim := ExecutionBindingClaim{ConversationID: "chat"} + b := ExecutionStatusBinding{Actor: frontActor, ConversationID: "chat", AddonID: "renamed", Generation: 1, Incarnation: "inc", ExecutionTool: "run", StatusTool: "health", CurrentSelection: "owner:1:2:3:4"} + g := &Gateway{Revalidate: func(context.Context, contract.Actor) error { return nil }} + _, err := g.BindingForActor(ctx, frontActor, claim) + Expect(err).To(HaveOccurred()) + calls := 0 + g.ResolveExecutionBinding = func(_ context.Context, actor contract.Actor, got ExecutionBindingClaim) (ExecutionStatusBinding, error) { + calls++ + if actor != frontActor || got != claim { + return ExecutionStatusBinding{}, ErrForbidden + } + return b, nil + } + got, err := g.BindingForActor(ctx, frontActor, claim) + Expect(err).NotTo(HaveOccurred()) + Expect(got).To(Equal(b)) + Expect(g.ValidateExecutionBinding(ctx, frontActor, b)).To(Succeed()) + stale := b + stale.CurrentSelection = "old" + Expect(g.ValidateExecutionBinding(ctx, frontActor, stale)).NotTo(Succeed()) + forged := b + forged.Actor.UserID = "other" + before := calls + Expect(g.ValidateExecutionBinding(ctx, frontActor, forged)).NotTo(Succeed()) + Expect(calls).To(Equal(before)) + b.CurrentSelection = "current-selection" // The resolver reports a different current selection. + Expect(g.ValidateExecutionBinding(ctx, frontActor, got)).NotTo(Succeed()) + }) +}) +var _ = Describe("binding claim strictness", func() { + It("rejects aliases, duplicate fields and null claims", func() { + for _, raw := range []string{`{"ConversationID":"chat"}`, `{"conversationId":"chat","actor":{}}`, `{"conversationId":"chat","conversationId":"other"}`, `null`} { + var c ExecutionBindingClaim + Expect(json.Unmarshal([]byte(raw), &c)).NotTo(Succeed(), raw) + } + }) +}) + +var _ = Describe("owner binding carrier", func() { + It("resolves only the authenticated claim over actual mTLS", func() { + b := ExecutionStatusBinding{Actor: frontActor, ConversationID: "chat", AddonID: "renamed", Generation: 1, Incarnation: "inc", ExecutionTool: "run", StatusTool: "health", CurrentSelection: "owner:1:2:3:4"} + calls := 0 + g := &Gateway{Revalidate: func(context.Context, contract.Actor) error { return nil }, ResolveExecutionBinding: func(_ context.Context, a contract.Actor, c ExecutionBindingClaim) (ExecutionStatusBinding, error) { + calls++ + if a != frontActor || c.ConversationID != "chat" { + return ExecutionStatusBinding{}, ErrForbidden + } + return b, nil + }} + _, cs := carrierFixture(func(car *Carrier) http.Handler { + h, e := car.OwnerHandler(g) + Expect(e).NotTo(HaveOccurred()) + return h + }) + front := &Gateway{Forward: cs["front-a"], Revalidate: g.Revalidate} + got, e := front.BindingForActor(context.Background(), frontActor, ExecutionBindingClaim{ConversationID: "chat"}) + Expect(e).NotTo(HaveOccurred()) + Expect(got).To(Equal(b)) + Expect(calls).To(Equal(1)) + Expect(front.ValidateExecutionBinding(context.Background(), frontActor, b)).To(Succeed()) + stale := b + stale.Incarnation = "old" + Expect(front.ValidateExecutionBinding(context.Background(), frontActor, stale)).NotTo(Succeed()) + _, e = front.BindingForActor(context.Background(), frontActor, ExecutionBindingClaim{ConversationID: "foreign"}) + Expect(e).To(HaveOccurred()) + before := calls + for _, raw := range []string{ + `{"version":1,"claim":{"conversationId":"chat","actor":{}}}`, + `{"version":1,"claim":{"ConversationId":"chat"}}`, + `{"version":1,"claim":{"conversationId":"chat","conversationId":"foreign"}}`, + `{"version":2,"claim":{"conversationId":"chat"}}`, + } { + body := `{"request":{"action":"execution-binding-v1","operationId":"bad"},"executionBinding":` + raw + `}` + var wire wireRequest + Expect(decode(strings.NewReader(body), &wire)).NotTo(Succeed()) + } + // A mixed door is signed and authenticated, but still has no authority. + resp, e := cs["front-a"].send(context.Background(), frontActor, wireRequest{Request: contract.Request{Action: executionBindingAction, OperationID: "mixed", AddonID: "forged"}, ExecutionBinding: &executionBindingCall{Version: 1, Claim: ExecutionBindingClaim{ConversationID: "chat"}}}) + if resp != nil { + Expect(resp.Body.Close()).To(Succeed()) + } + Expect(e).To(HaveOccurred()) + Expect(calls).To(Equal(before)) + g.ResolveExecutionBinding = nil + _, e = front.BindingForActor(context.Background(), frontActor, ExecutionBindingClaim{ConversationID: "chat"}) + Expect(e).To(HaveOccurred()) + }) + It("fails closed on unsupported owners and mismatched binding replies", func() { + b := ExecutionStatusBinding{Actor: frontActor, ConversationID: "other-chat", AddonID: "renamed", Generation: 1, Incarnation: "inc", ExecutionTool: "run", StatusTool: "health", CurrentSelection: "rev"} + for _, supported := range []bool{false, true} { + _, cs := carrierFixture(func(car *Carrier) http.Handler { + g := &Gateway{Revalidate: func(context.Context, contract.Actor) error { return nil }} + h, e := car.OwnerHandler(g) + Expect(e).NotTo(HaveOccurred()) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !supported && r.URL.Path == "/addons/capabilities" { + Expect(json.NewEncoder(w).Encode(Capabilities{Protocol: 2})).To(Succeed()) + return + } + if r.URL.Path == "/addons/forward" { + Expect(json.NewEncoder(w).Encode(executionBindingReply{Version: 1, Binding: b})).To(Succeed()) + return + } + h.ServeHTTP(w, r) + }) + }) + front := &Gateway{Forward: cs["front-a"], Revalidate: func(context.Context, contract.Actor) error { return nil }} + got, e := front.BindingForActor(context.Background(), frontActor, ExecutionBindingClaim{ConversationID: "chat"}) + Expect(e).To(HaveOccurred()) + Expect(got).To(Equal(ExecutionStatusBinding{})) + } + }) +}) + +var _ = Describe("binding DTO strictness", func() { + It("accepts canonical bindings and rejects aliases and duplicates", func() { + rawBinding := `{"actor":{"userId":"u","credentialId":"c","mode":"named-user"},"addonId":"renamed","generation":1,"incarnation":"inc","executionTool":"run","statusTool":"health","conversationId":"chat","currentSelection":"current"}` + var b ExecutionStatusBinding + Expect(json.Unmarshal([]byte(rawBinding), &b)).To(Succeed()) + Expect(b.ConversationID).To(Equal("chat")) + for _, raw := range []string{ + strings.Replace(rawBinding, `"addonId"`, `"AddonId"`, 1), + strings.Replace(rawBinding, `"userId"`, `"UserId"`, 1), + strings.Replace(rawBinding, `"addonId":"renamed"`, `"addonId":"renamed","addonId":"other"`, 1), + strings.Replace(rawBinding, `"actor":`, `"conversationId":"chat","ConversationID":"foreign","actor":`, 1), + } { + Expect(json.Unmarshal([]byte(raw), &b)).NotTo(Succeed(), raw) + } + }) +}) + +var _ = Describe("generic execution provider identity", func() { + It("passes through owner-resolved bindings with distinct provider and status names", func() { + for _, pair := range [][2]string{{"runner-one", "inspect"}, {"runner-two", "readiness"}} { + want := ExecutionStatusBinding{Actor: frontActor, ConversationID: "chat", AddonID: pair[0], Generation: 2, Incarnation: "live", ExecutionTool: "execute", StatusTool: pair[1], CurrentSelection: "current-inputs"} + g := &Gateway{Revalidate: func(context.Context, contract.Actor) error { return nil }, ResolveExecutionBinding: func(context.Context, contract.Actor, ExecutionBindingClaim) (ExecutionStatusBinding, error) { + return want, nil + }} + got, err := g.BindingForActor(context.Background(), frontActor, ExecutionBindingClaim{ConversationID: "chat"}) + Expect(err).NotTo(HaveOccurred()) + Expect(got).To(Equal(want)) + } + }) +}) diff --git a/core/services/addons/gateway/carrier.go b/core/services/addons/gateway/carrier.go index a7855545f..ce76a50ce 100644 --- a/core/services/addons/gateway/carrier.go +++ b/core/services/addons/gateway/carrier.go @@ -128,8 +128,10 @@ func (c *Carrier) peer(r *http.Request) (PeerIdentity, error) { } type wireRequest struct { - Request contract.Request `json:"request"` - Relay *RelayRequest `json:"relay,omitempty"` + ExecutionBinding *executionBindingCall `json:"executionBinding,omitempty"` + Observation *ExecutionStatusRequest `json:"observation,omitempty"` + Request contract.Request `json:"request"` + Relay *RelayRequest `json:"relay,omitempty"` // Proposal marks a call for the owner's proposal broker. Such a call is // never a typed dispatch. Proposal *ProposalCall `json:"proposal,omitempty"` @@ -270,6 +272,47 @@ func (c *Carrier) OwnerHandler(g *Gateway) (http.Handler, error) { http.Error(w, "invalid binding", 400) return } + if wire.ExecutionBinding != nil { + // New binding messages use exact canonical keys, including the + // routing envelope. Older ordinary messages keep their decoder. + var fields map[string]json.RawMessage + _ = json.Unmarshal(m.Body, &fields) + if observationObject(m.Body, "request", "executionBinding") != nil || observationObject(fields["request"], "action", "operationId", "addonId", "resourceId", "generation", "arguments") != nil { + http.Error(w, "invalid execution binding keys", 400) + return + } + if wire.Observation != nil || wire.Library != nil || wire.Resource != nil || wire.Apps != nil || wire.Artifact != nil || wire.Proposal != nil || wire.Relay != nil || wire.Request.Action != executionBindingAction || wire.Request.AddonID != "" || wire.Request.ResourceID != "" || wire.Request.Generation != 0 || (len(wire.Request.Arguments) != 0 && string(wire.Request.Arguments) != "null") { + http.Error(w, "invalid execution binding", 400) + return + } + out, err := g.BindingForActor(r.Context(), actor, wire.ExecutionBinding.Claim) + if err != nil { + typedError(w, err) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(executionBindingReply{Version: 1, Binding: out}) + return + } + // Reserved actions cannot fall through to ordinary tool dispatch. + if wire.Request.Action == executionBindingAction { + http.Error(w, "missing execution binding", 400) + return + } + if wire.Observation != nil { + if wire.Library != nil || wire.Resource != nil || wire.Apps != nil || wire.Artifact != nil || wire.Proposal != nil || wire.Relay != nil || wire.Request.Action != executionStatusAction || wire.Request.AddonID != "" || wire.Request.ResourceID != "" || wire.Request.Generation != 0 || (len(wire.Request.Arguments) != 0 && string(wire.Request.Arguments) != "null") { + http.Error(w, "invalid observation binding", 400) + return + } + out, err := g.ExecutionStatusRead(r.Context(), actor, *wire.Observation) + if err != nil { + typedError(w, err) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(out) + return + } if wire.Library != nil { call := *wire.Library // The owner classifies by the call, not by the action the peer wrote. @@ -476,6 +519,13 @@ func typedError(w http.ResponseWriter, e error) { _ = json.NewEncoder(w).Encode(resourceWire{Type: wireInvalid}) return } + var requirements *addons.HostRequirementsRefusal + if errors.As(e, &requirements) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(sdkWire{Type: "host_requirements", HostOperations: &requirements.Summary}) + return + } var refusal *contract.SDKRefusal if errors.As(e, &refusal) { // The administrator is told why the add-on cannot be installed. Only @@ -545,10 +595,11 @@ func IsOperationCause(code string) bool { const wireSDK = "sdk_incompatible" type sdkWire struct { - Type string `json:"type"` - Min uint64 `json:"min"` - Max uint64 `json:"max"` - Host uint64 `json:"host"` + HostOperations *hostops.RequestSummary `json:"hostOperations,omitempty"` + Type string `json:"type"` + Min uint64 `json:"min"` + Max uint64 `json:"max"` + Host uint64 `json:"host"` } // SDKRefusalError is the error of a failed operation whose cause is an SDK @@ -699,8 +750,13 @@ func (c *Carrier) send(ctx context.Context, a contract.Actor, wire wireRequest) return nil, resourceReplyError(resp.Body, resp.StatusCode, wire.Request.OperationID) } var b sdkWire - if decode(io.LimitReader(resp.Body, 4096), &b) == nil { + if decode(io.LimitReader(resp.Body, 32768), &b) == nil { switch b.Type { + case "host_requirements": + if b.HostOperations != nil { + return nil, fmt.Errorf("%w: %w", addons.ErrOperationFailed, &addons.HostRequirementsRefusal{Summary: *b.HostOperations}) + } + return nil, ErrForbidden case wireSDK: return nil, SDKRefusalError(&contract.SDKRefusal{Min: b.Min, Max: b.Max, Host: b.Host}) case wirePackage: @@ -727,7 +783,7 @@ func (c *Carrier) send(ctx context.Context, a contract.Actor, wire wireRequest) // The owner's typed refusal is a decision, not an outage: nothing // was applied and the same call is refused again. var b resourceWire - if decode(io.LimitReader(resp.Body, 4096), &b) == nil && b.Type == wireInvalid { + if decode(io.LimitReader(resp.Body, 32768), &b) == nil && b.Type == wireInvalid { return nil, ErrInvalidRequest } } @@ -766,7 +822,7 @@ var resourceRead = map[string]bool{"resource-list": true, "resource-grants": tru // mutation fails closed: only the known read actions are exempt, so an action // added later is reported as an unknown outcome until it is classified. func mutation(action string) bool { - return action != ActionHostSettingsRead && action != "read" && action != "operation-status" && action != "proposal-get" && action != "proposal-reconcile" && action != "proposal-list" && action != "artifact-status" && action != appsListAction && !readManagement[action] && !resourceRead[action] && !libraryRead[action] + return action != ActionHostSettingsRead && action != executionBindingAction && action != executionStatusAction && action != "read" && action != "operation-status" && action != "proposal-get" && action != "proposal-reconcile" && action != "proposal-list" && action != "artifact-status" && action != appsListAction && !readManagement[action] && !resourceRead[action] && !libraryRead[action] } // Act forwards an action call to the owner and waits for its answer, up to the diff --git a/core/services/addons/gateway/extensions_test.go b/core/services/addons/gateway/extensions_test.go new file mode 100644 index 000000000..a74b440db --- /dev/null +++ b/core/services/addons/gateway/extensions_test.go @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: MIT +package gateway + +import ( + "context" + "encoding/json" + "net/http" + "strings" + + "github.com/mudler/LocalAI/core/services/addons" + "github.com/mudler/LocalAI/pkg/addons/contract" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("typed installed Apps carrier", func() { + It("round trips installed typed home metadata over mTLS with owner actor visibility and immutable asset URLs", func() { + ctx := context.Background() + admin := contract.Actor{UserID: "admin", CredentialID: "key:admin", Mode: "named-user"} + reader := contract.Actor{UserID: "reader", CredentialID: "key:reader", Mode: "named-user"} + var manifest contract.Manifest + Expect(json.Unmarshal([]byte(uiManifest), &manifest)).To(Succeed()) + caps := contract.Capabilities{Service: true, Actions: true, Tools: []string{"run", "health"}, 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: "Health", Kind: "read", UIOnly: true, InputSchema: json.RawMessage(`{"type":"object","additionalProperties":false}`)}, + }, UI: &contract.UI{Entry: "ui.js", SDK: contract.VersionRange{Min: 2, Max: 2}, Pages: []string{"home", "history"}, MainPage: "home", Title: &contract.Localized{ByLanguage: map[string]string{"en": "Notes", "it": "Note"}}, Icon: "icon.png", HomeCommands: []contract.HomeCommand{ + {ID: "open", Alias: "notes"}, + {ID: "history", Page: "history", Label: &contract.Localized{ByLanguage: map[string]string{"en": "History", "it": "Cronologia"}}, Icon: "clock"}, + }}, ExecutionProvider: &contract.ExecutionProvider{Tools: []string{"run"}, StatusTool: "health"}, HostOperations: &contract.HostOperations{Required: []string{"workspace.materialize"}, Optional: []string{"egress.check"}}} + manifest.Capabilities = caps + raw, e := json.Marshal(manifest) + Expect(e).NotTo(HaveOccurred()) + m := ownerManagerPackage(admin, true, string(raw), map[string]string{"icon.png": "\x89PNG\r\n\x1a\n" + strings.Repeat("x", 100)}, 2, map[string][]string{"sample": {"workspace."}}) + status, e := m.Status(ctx, "sample") + Expect(e).NotTo(HaveOccurred()) + Expect(status.Generations[0].Receipt.Manifest.Capabilities).To(Equal(caps)) + // Status is the actual manifest-bearing DTO; its JSON copy must retain + // all declarations, including nested command labels, without aliases. + statusJSON, e := json.Marshal(status) + Expect(e).NotTo(HaveOccurred()) + var decoded addons.Status + Expect(json.Unmarshal(statusJSON, &decoded)).To(Succeed()) + Expect(decoded.Generations[0].Receipt.Manifest.Capabilities).To(Equal(caps)) + copied := &status.Generations[0].Receipt.Manifest.Capabilities + copied.UI.HomeCommands[0].Alias = "mutated" + copied.UI.HomeCommands[1].Label.ByLanguage["it"] = "mutated" + copied.ExecutionProvider.Tools[0] = "mutated" + copied.HostOperations.Required[0] = "mutated" + copied.HostOperations.Optional[0] = "mutated" + status, e = m.Status(ctx, "sample") + Expect(e).NotTo(HaveOccurred()) + Expect(status.Generations[0].Receipt.Manifest.Capabilities).To(Equal(caps)) + + allow := func(context.Context, contract.Actor) error { return nil } + owner := &Gateway{Manager: m, Revalidate: allow} + _, cs := carrierFixture(func(c *Carrier) http.Handler { + h, e := c.OwnerHandler(owner) + Expect(e).NotTo(HaveOccurred()) + return h + }) + _, e = m.Submit(ctx, admin, addons.LifecycleCommand{OperationID: "typed-enable", AddonID: "sample", Action: "enable"}) + Expect(e).NotTo(HaveOccurred()) + for _, a := range []contract.Actor{admin, reader} { + local, e := owner.Apps(ctx, a) + Expect(e).NotTo(HaveOccurred()) + Expect(local.Apps).To(HaveLen(1)) + for _, id := range []string{"front-a", "front-b"} { + front := &Gateway{Forward: cs[id], Revalidate: allow} + remote, e := front.Apps(ctx, a) + Expect(e).NotTo(HaveOccurred()) + localJSON, err := json.Marshal(local) + Expect(err).NotTo(HaveOccurred()) + remoteJSON, err := json.Marshal(remote) + Expect(err).NotTo(HaveOccurred()) + Expect(remoteJSON).To(MatchJSON(localJSON)) + app := remote.Apps[0] + Expect(app.Generation).To(Equal(uint64(1))) + Expect(app.UI).NotTo(BeNil()) + commands := app.UI.HomeCommands + Expect(commands).To(HaveLen(2)) + Expect(commands[0].ID).To(Equal("open")) + Expect(commands[0].Alias).To(Equal("notes")) + Expect(commands[0].Page).To(Equal("home")) + Expect(commands[0].Labels).To(Equal(caps.UI.Title.ByLanguage)) + Expect(commands[0].Label).To(Equal("Notes")) + Expect(commands[0].Icon).To(HavePrefix("/api/addons/sample/assets/")) + Expect(commands[0].Icon).To(HaveSuffix("/icon.png")) + digestPath := strings.TrimSuffix(strings.TrimPrefix(commands[0].Icon, "/api/addons/sample/assets/"), "/icon.png") + Expect(digestPath).To(MatchRegexp(`^[a-f0-9]{64}$`)) + Expect(commands[1].Page).To(Equal("history")) + Expect(commands[1].Labels).To(Equal(caps.UI.HomeCommands[1].Label.ByLanguage)) + Expect(commands[1].Icon).To(Equal("clock")) + Expect(app.Admin != nil).To(Equal(a == admin)) + commands[0].Alias = "mutated" + commands[0].Labels["it"] = "mutated" + commands[1].Labels["it"] = "mutated" + fresh, e := owner.Apps(ctx, a) + Expect(e).NotTo(HaveOccurred()) + Expect(fresh.Apps[0].UI.HomeCommands).To(Equal(local.Apps[0].UI.HomeCommands)) + b, e := json.Marshal(remote) + Expect(e).NotTo(HaveOccurred()) + Expect(string(b)).NotTo(ContainSubstring("directory")) + Expect(string(b)).NotTo(ContainSubstring("executionProvider")) + if a != admin { + Expect(string(b)).NotTo(ContainSubstring("hostOperations")) + } else { + Expect(app.Admin.HostOperations.MissingOptional).To(Equal([]string{"egress.check"})) + } + } + } + _, e = m.Submit(ctx, admin, addons.LifecycleCommand{OperationID: "typed-disable", AddonID: "sample", Action: "disable"}) + Expect(e).NotTo(HaveOccurred()) + for _, a := range []contract.Actor{admin, reader} { + local, e := owner.Apps(ctx, a) + Expect(e).NotTo(HaveOccurred()) + remote, e := (&Gateway{Forward: cs["front-a"], Revalidate: allow}).Apps(ctx, a) + Expect(e).NotTo(HaveOccurred()) + localJSON, err := json.Marshal(local) + Expect(err).NotTo(HaveOccurred()) + remoteJSON, err := json.Marshal(remote) + Expect(err).NotTo(HaveOccurred()) + Expect(remoteJSON).To(MatchJSON(localJSON)) + if a == admin { + Expect(remote.Apps).To(HaveLen(1)) + Expect(remote.Apps[0].UI).To(BeNil()) + Expect(remote.Apps[0].Enabled).To(BeFalse()) + Expect(remote.Apps[0].Generation).To(Equal(uint64(1))) + } else { + Expect(remote.Apps).To(BeEmpty()) + } + } + // Disabled means installed, not absent. Apps intentionally carries UI + // projection, not execution or host-operation authority. + installed, e := m.Installed(ctx, admin) + Expect(e).NotTo(HaveOccurred()) + Expect(installed).To(HaveLen(1)) + status, e = m.Status(ctx, "sample") + Expect(e).NotTo(HaveOccurred()) + Expect(status.Generations[0].Receipt.Manifest.Capabilities).To(Equal(caps)) + }) +}) diff --git a/core/services/addons/gateway/gateway.go b/core/services/addons/gateway/gateway.go index 2b27c509d..27a6499c4 100644 --- a/core/services/addons/gateway/gateway.go +++ b/core/services/addons/gateway/gateway.go @@ -31,12 +31,18 @@ type Gateway struct { HostSettings HostSettingsService // HostSettingsAdmin checks current host authority, never add-on supplied flags. HostSettingsAdmin func(context.Context, contract.Actor) error - Limits RelayLimits - Manager *addons.Manager - Runtime CurrentRuntime - Forward *Carrier - Admission Admission - Resolver ResourceResolver + // ResolveExecutionBinding loads actor-owned context on the owner. Nil denies. + ResolveExecutionBinding func(context.Context, contract.Actor, ExecutionBindingClaim) (ExecutionStatusBinding, error) + // AuthorizeExecutionStatus must revalidate current provider current selection, + // execution feature permission, access and builtin replacement authority on the owner. + // Nil denies all observations. Status is never selection authorization. + AuthorizeExecutionStatus func(context.Context, contract.Actor, ExecutionStatusBinding) error + Limits RelayLimits + Manager *addons.Manager + Runtime CurrentRuntime + Forward *Carrier + Admission Admission + Resolver ResourceResolver // Tools reports the tools each installed add-on declares. When nil the // Manager is used; with neither, a request that names a tool is denied. Tools ToolSource diff --git a/core/services/addons/gateway/library.go b/core/services/addons/gateway/library.go index 5c0cea02d..442f42cfd 100644 --- a/core/services/addons/gateway/library.go +++ b/core/services/addons/gateway/library.go @@ -180,6 +180,8 @@ func (c Capabilities) Supports(call string) bool { func (g *Gateway) capabilities() Capabilities { c := Capabilities{Protocol: 2, OS: g.OS, Arch: g.Arch} + // Transport support is not permission to select or observe a provider. + c.Calls = append(c.Calls, executionStatusAction, executionBindingAction) if c.OS == "" { c.OS = runtime.GOOS } diff --git a/core/services/addons/gateway/observation.go b/core/services/addons/gateway/observation.go new file mode 100644 index 000000000..23a7bd79d --- /dev/null +++ b/core/services/addons/gateway/observation.go @@ -0,0 +1,286 @@ +// SPDX-License-Identifier: MIT +package gateway + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "io" + "slices" + "time" + + "github.com/mudler/LocalAI/pkg/addons/contract" +) + +// ExecutionStatusBinding contains opaque authenticated identities, not credentials. +// Every component is a precondition; readiness never authorizes execution. +type ExecutionStatusBinding struct { + ConversationID string `json:"conversationId,omitempty"` + Actor contract.Actor `json:"actor"` + AddonID string `json:"addonId"` + Generation uint64 `json:"generation"` + Incarnation string `json:"incarnation"` + ExecutionTool string `json:"executionTool"` + StatusTool string `json:"statusTool"` + // CurrentSelection is a digest of current selection inputs, not mutation history. + // It conveys no authority and contains no raw store or configuration data. + CurrentSelection string `json:"currentSelection"` +} + +// Version belongs to this host-to-host DTO, not the addon protocol (still v1). +type ExecutionStatusRequest struct { + Version int `json:"version"` + Binding ExecutionStatusBinding `json:"binding"` + ObservationID string `json:"observationId"` +} +type ExecutionStatusResponse struct { + Version int `json:"version"` + Binding ExecutionStatusBinding `json:"binding"` + ObservationID string `json:"observationId"` + Status contract.ExecutionStatus `json:"status"` +} + +const executionStatusAction = "execution-status-read-v1" + +type observationRuntime interface { + AdmitObservation(uint64, string) (func(), error) + ReadObservation(context.Context, contract.Envelope, string) (contract.ExecutionStatus, error) +} + +func observationUnknown(r ExecutionStatusRequest) ExecutionStatusResponse { + return ExecutionStatusResponse{Version: 1, Binding: r.Binding, ObservationID: r.ObservationID, Status: contract.ExecutionStatus{Readiness: "unknown", Isolation: "unknown"}} +} +func (r ExecutionStatusRequest) valid(a contract.Actor) bool { + b := r.Binding + return (b.ConversationID == "" || validExecutionConversation(b.ConversationID)) && r.Version == 1 && b.Actor == a && a.UserID != "" && a.CredentialID != "" && len(a.UserID) <= 256 && len(a.CredentialID) <= 256 && (a.Mode == "named-user" || a.Mode == "local-owner") && contract.ValidName(b.AddonID) && b.Generation != 0 && b.Incarnation != "" && len(b.Incarnation) <= 256 && contract.ValidName(b.ExecutionTool) && contract.ValidName(b.StatusTool) && b.CurrentSelection != "" && len(b.CurrentSelection) <= 256 && r.ObservationID != "" && len(r.ObservationID) <= 256 +} + +// ExecutionStatusRead is a host-only observation door. It cannot name arguments, +// resources, operation IDs or grants. Ordinary model dispatch remains unchanged. +func (g *Gateway) ExecutionStatusRead(ctx context.Context, a contract.Actor, r ExecutionStatusRequest) (ExecutionStatusResponse, error) { + out := observationUnknown(r) + if ctx.Err() != nil { + return out, ErrUnavailable + } + if !r.valid(a) { + return out, ErrForbidden + } + if e := g.revalidate(ctx, a); e != nil { + return out, e + } + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if g.Forward != nil { + return g.Forward.executionStatus(ctx, a, r) + } + return g.executionStatusLocal(ctx, a, r) +} +func (g *Gateway) executionStatusLocal(ctx context.Context, a contract.Actor, r ExecutionStatusRequest) (ExecutionStatusResponse, error) { + out := observationUnknown(r) + if !r.valid(a) || g.AuthorizeExecutionStatus == nil { + return out, ErrForbidden + } + aa, ok := g.Admission.(AddonAdmission) + if !ok { + return out, ErrForbidden + } + b := r.Binding + check := func(ctx context.Context) error { + if e := g.revalidate(ctx, a); e != nil { + return e + } + // Host selection/feature/builtin authority is independent of reported facts. + if e := ClassifyCheck(g.AuthorizeExecutionStatus(ctx, a, b)); e != nil { + return e + } + src, ok := g.toolSource().(CapabilitySource) + if !ok { + return ErrForbidden + } + caps, e := src.DeclaredCapabilities(ctx, b.AddonID, b.Generation) + if e != nil { + return unreachableOrDenied(e) + } + p := caps.ExecutionProvider + if p == nil || p.StatusTool != b.StatusTool || !slices.Contains(p.Tools, b.ExecutionTool) || !slices.Contains(caps.Tools, b.ExecutionTool) || !slices.Contains(caps.Tools, b.StatusTool) || slices.Contains(caps.AdminTools, b.StatusTool) { + return ErrForbidden + } + action, status := false, false + for _, d := range caps.ToolDefinitions { + if d.Name == b.ExecutionTool { + action = contract.IsExecutionAction(caps, d) + } + if d.Name == b.StatusTool { + status = contract.IsExecutionStatusTool(caps, d) && contract.ExecutionStatusAcceptsEmptyArguments(d) + } + } + if !action || !status { + return ErrForbidden + } + return nil + } + err := aa.AdmitAddon(ctx, a, b.AddonID, func(ctx context.Context) error { + if e := check(ctx); e != nil { + return e + } + instance, e := g.current(ctx, b.AddonID) + if e != nil { + return e + } + if instance.Generation != b.Generation || instance.Incarnation != b.Incarnation { + return ErrForbidden + } + client, ok := instance.Client.(observationRuntime) + if !ok { + return ErrUnavailable + } + // Identity and accepting state are tested together under the actual client's + // withdrawal lock. Never look up another client to dispatch after this point. + release, e := client.AdmitObservation(b.Generation, b.Incarnation) + if e != nil { + return e + } + defer release() + var raw [24]byte + if _, e = rand.Read(raw[:]); e != nil { + return ErrUnavailable + } + args, _ := json.Marshal(map[string]string{"tool": b.StatusTool}) + env := contract.Envelope{Request: contract.Request{AddonID: b.AddonID, Generation: b.Generation, Action: "read", OperationID: "obs-" + hex.EncodeToString(raw[:]), Arguments: args}, Authority: contract.Authority{Actor: a, Scope: "read", ExpiresAt: time.Now().Add(5 * time.Second)}} + value, e := client.ReadObservation(ctx, env, r.ObservationID) + if e != nil { + return e + } + if e = ctx.Err(); e != nil { + return e + } + if e = check(ctx); e != nil { + return e + } + now, e := g.current(ctx, b.AddonID) + if e != nil { + return e + } + if now.Generation != b.Generation || now.Incarnation != b.Incarnation || now.Client != instance.Client { + return ErrUnavailable + } + done, e := client.AdmitObservation(b.Generation, b.Incarnation) + if e != nil { + return e + } + done() + rawStatus, e := json.Marshal(value) + if e != nil { + return ErrUnavailable + } + value, e = contract.DecodeExecutionStatus(rawStatus) + if e != nil { + return ErrUnavailable + } + out.Status = value + return nil + }) + if err != nil { + return observationUnknown(r), ClassifyCheck(err) + } + return out, nil +} +func (c *Carrier) executionStatus(ctx context.Context, a contract.Actor, r ExecutionStatusRequest) (ExecutionStatusResponse, error) { + out := observationUnknown(r) + caps, err := c.Capabilities(ctx) + if err != nil || !caps.Supports(executionStatusAction) { + return out, ErrUnavailable + } + var id [24]byte + if _, e := rand.Read(id[:]); e != nil { + return out, ErrUnavailable + } + req := contract.Request{OperationID: "status-" + hex.EncodeToString(id[:]), Action: executionStatusAction} + resp, e := c.send(ctx, a, wireRequest{Request: req, Observation: &r}) + if e != nil { + return out, e + } + // Read-only cleanup: close errors cannot change the decoded result. + defer func() { _ = resp.Body.Close() }() + var got ExecutionStatusResponse + if decode(io.LimitReader(resp.Body, 32768), &got) != nil || got.Version != 1 || got.Binding != r.Binding || got.ObservationID != r.ObservationID || ctx.Err() != nil { + return out, ErrUnavailable + } + raw, e := json.Marshal(got.Status) + if e != nil { + return out, ErrUnavailable + } + got.Status, e = contract.DecodeExecutionStatus(raw) + if e != nil { + return out, ErrUnavailable + } + return got, nil +} + +// Observation DTOs use exact keys even though older carrier messages permit +// encoding/json's case-insensitive aliases. Reject ambiguous nested identities. +func observationObject(raw []byte, keys ...string) error { + if checkObject(raw) != nil { + return ErrForbidden + } + var fields map[string]json.RawMessage + if json.Unmarshal(raw, &fields) != nil { + return ErrForbidden + } + for k := range fields { + if !slices.Contains(keys, k) { + return ErrForbidden + } + } + return nil +} +func (b *ExecutionStatusBinding) UnmarshalJSON(raw []byte) error { + if observationObject(raw, "conversationId", "actor", "addonId", "generation", "incarnation", "executionTool", "statusTool", "currentSelection") != nil { + return ErrForbidden + } + var fields map[string]json.RawMessage + _ = json.Unmarshal(raw, &fields) + if observationObject(fields["actor"], "userId", "credentialId", "mode") != nil { + return ErrForbidden + } + type plain ExecutionStatusBinding + var value plain + if decode(bytes.NewReader(raw), &value) != nil { + return ErrForbidden + } + *b = ExecutionStatusBinding(value) + return nil +} +func (r *ExecutionStatusRequest) UnmarshalJSON(raw []byte) error { + if observationObject(raw, "version", "binding", "observationId") != nil { + return ErrForbidden + } + type plain ExecutionStatusRequest + var value plain + if decode(bytes.NewReader(raw), &value) != nil { + return ErrForbidden + } + *r = ExecutionStatusRequest(value) + return nil +} +func (r *ExecutionStatusResponse) UnmarshalJSON(raw []byte) error { + if observationObject(raw, "version", "binding", "observationId", "status") != nil { + return ErrForbidden + } + type plain ExecutionStatusResponse + var value plain + if decode(bytes.NewReader(raw), &value) != nil { + return ErrForbidden + } + var fields map[string]json.RawMessage + _ = json.Unmarshal(raw, &fields) + status, e := contract.DecodeExecutionStatus(fields["status"]) + if e != nil { + return e + } + *r = ExecutionStatusResponse(value) + r.Status = status + return nil +} diff --git a/core/services/addons/gateway/observation_runtime_test.go b/core/services/addons/gateway/observation_runtime_test.go new file mode 100644 index 000000000..e0697f86c --- /dev/null +++ b/core/services/addons/gateway/observation_runtime_test.go @@ -0,0 +1,220 @@ +//go:build linux + +// SPDX-License-Identifier: MIT +package gateway + +import ( + "context" + "encoding/json" + "net/http" + "os" + "os/exec" + "path/filepath" + "sync/atomic" + "time" + + "github.com/mudler/LocalAI/core/services/addons" + "github.com/mudler/LocalAI/core/services/addons/packages" + addonruntime "github.com/mudler/LocalAI/core/services/addons/runtime" + "github.com/mudler/LocalAI/pkg/addons/contract" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Only lookup ordering is instrumented. Every returned client belongs to the +// real supervisor; admission, withdrawal and protocol dispatch are not mocked. +type observationLookup struct { + supervisor *addonruntime.Supervisor + calls atomic.Int32 + captured chan addonruntime.Instance + resume chan struct{} + pauseAt int32 +} + +func (s *observationLookup) Runtime(ctx context.Context, id string) (addonruntime.Instance, error) { + n := s.calls.Add(1) + if n == s.pauseAt && n == 2 { + // The first real /read has completed; pause before owner consumption. + s.captured <- addonruntime.Instance{} + select { + case <-s.resume: + case <-ctx.Done(): + return addonruntime.Instance{}, ctx.Err() + } + } + i, e := s.supervisor.Current(ctx, id, 1) + if n == s.pauseAt && n == 1 && e == nil { + s.captured <- i + select { + case <-s.resume: + case <-ctx.Done(): + return addonruntime.Instance{}, ctx.Err() + } + } + return i, e +} + +type observationRecord struct { + Incarnation string `json:"incarnation"` + Envelope contract.Envelope `json:"envelope"` +} + +var _ = Describe("supervised owner observation over authenticated carrier", func() { + var binary string + BeforeEach(func() { + binary = filepath.Join(GinkgoT().TempDir(), "fixture") + cmd := exec.Command("go", "build", "-o", binary, "../../../../tests/addons/fixture/cmd") + out, e := cmd.CombinedOutput() + Expect(e).NotTo(HaveOccurred(), string(out)) + }) + setup := func(pauseAt int32) (*addonruntime.Supervisor, packages.PackageReceipt, *observationLookup, *Gateway, ExecutionStatusRequest, string) { + ctx := context.Background() + caps, e := (observationCaps{}).DeclaredCapabilities(ctx, "sample", 1) + Expect(e).NotTo(HaveOccurred()) + caps.Service = true + caps.Actions = true + for i := range caps.ToolDefinitions { + caps.ToolDefinitions[i].Description = "Fixture tool" + if len(caps.ToolDefinitions[i].InputSchema) == 0 { + caps.ToolDefinitions[i].InputSchema = json.RawMessage(`{"type":"object","properties":{"command":{"type":"string"}},"required":["command"],"additionalProperties":false}`) + } + } + rawCaps, e := json.Marshal(caps) + Expect(e).NotTo(HaveOccurred()) + var manifest map[string]any + Expect(json.Unmarshal([]byte(uiManifest), &manifest)).To(Succeed()) + manifest["capabilities"] = json.RawMessage(rawCaps) + rawManifest, e := json.Marshal(manifest) + Expect(e).NotTo(HaveOccurred()) + b, e := os.ReadFile(binary) + Expect(e).NotTo(HaveOccurred()) + logPath := filepath.Join(GinkgoT().TempDir(), "observations.jsonl") + config, e := json.Marshal(map[string]string{"mode": "execution-status", "observationLog": logPath}) + Expect(e).NotTo(HaveOccurred()) + m := ownerManagerPackage(frontActor, true, string(rawManifest), map[string]string{"bin/sample": string(b), "fixture.json": string(config)}, 1) + s, e := addonruntime.NewSupervisor(addonruntime.Config{StateRoot: GinkgoT().TempDir(), Limits: contract.DefaultLimits(), ReadyTimeout: 2 * time.Second, StableHealth: time.Second}) + Expect(e).NotTo(HaveOccurred()) + DeferCleanup(func() { Expect(s.Close(ctx)).To(Succeed()) }) + Expect(m.AttachRuntime(ctx, s)).To(Succeed()) + _, e = m.Submit(ctx, frontActor, addons.LifecycleCommand{AddonID: "sample", OperationID: "enable", Action: "enable", Generation: 1}) + Expect(e).NotTo(HaveOccurred()) + first, e := s.Current(ctx, "sample", 1) + Expect(e).NotTo(HaveOccurred()) + Expect(first.Client).To(BeAssignableToTypeOf(&addonruntime.Client{})) + // The installed receipt, not a separately invented replacement, starts again. + st, e := m.Status(ctx, "sample") + Expect(e).NotTo(HaveOccurred()) + Expect(st.Generations).To(HaveLen(1)) + p := st.Generations[0].Receipt + Expect(p.Directory).NotTo(BeEmpty()) + lookup := &observationLookup{supervisor: s, captured: make(chan addonruntime.Instance, 1), resume: make(chan struct{}), pauseAt: pauseAt} + g := &Gateway{Manager: m, Runtime: lookup, Admission: &addonAdmitted{}, Revalidate: func(_ context.Context, a contract.Actor) error { + if a != frontActor { + return ErrForbidden + } + return nil + }, AuthorizeExecutionStatus: func(_ context.Context, a contract.Actor, b ExecutionStatusBinding) error { + if a != frontActor || b.CurrentSelection != "rev" { + return ErrForbidden + } + return nil + }} + _, cs := carrierFixture(func(c *Carrier) http.Handler { h, e := c.OwnerHandler(g); Expect(e).NotTo(HaveOccurred()); return h }) + front := &Gateway{Forward: cs["front-a"], Revalidate: g.Revalidate} + r := ExecutionStatusRequest{Version: 1, Binding: ExecutionStatusBinding{Actor: frontActor, AddonID: "sample", Generation: 1, Incarnation: first.Incarnation, ExecutionTool: "run", StatusTool: "health", CurrentSelection: "rev"}, ObservationID: "q"} + return s, p, lookup, front, r, logPath + } + records := func(path string) []observationRecord { + f, e := os.Open(path) + if os.IsNotExist(e) { + return nil + } + Expect(e).NotTo(HaveOccurred()) + defer func() { Expect(f.Close()).To(Succeed()) }() + d := json.NewDecoder(f) + var out []observationRecord + for d.More() { + var r observationRecord + Expect(d.Decode(&r)).To(Succeed()) + out = append(out, r) + } + return out + } + restart := func(s *addonruntime.Supervisor, p packages.PackageReceipt) addonruntime.Instance { + old, e := s.Current(context.Background(), "sample", 1) + Expect(e).NotTo(HaveOccurred()) + Expect(s.Stop(context.Background(), old)).To(Succeed()) + next, e := s.Start(context.Background(), p, 1) + Expect(e).NotTo(HaveOccurred()) + current, e := s.Current(context.Background(), "sample", 1) + Expect(e).NotTo(HaveOccurred()) + Expect(current.Client).To(BeIdenticalTo(next.Client)) + Expect(current.Client).NotTo(BeIdenticalTo(old.Client)) + Expect(current.Incarnation).NotTo(Equal(old.Incarnation)) + return next + } + positive := func(front *Gateway, r ExecutionStatusRequest, path string) { + out, e := front.ExecutionStatusRead(context.Background(), frontActor, r) + Expect(e).NotTo(HaveOccurred()) + Expect(out.Binding).To(Equal(r.Binding)) + Expect(out.ObservationID).To(Equal(r.ObservationID)) + Expect(out.Status).To(Equal(contract.ExecutionStatus{Readiness: "available", Isolation: "isolated"})) + rows := records(path) + Expect(rows).NotTo(BeEmpty()) + row := rows[len(rows)-1] + Expect(row.Incarnation).To(Equal(r.Binding.Incarnation)) + Expect(row.Envelope.Authority.Actor).To(Equal(frontActor)) + Expect(string(row.Envelope.Request.Arguments)).To(Equal(`{"tool":"health"}`)) + Expect(row.Envelope.Request.OperationID).To(HavePrefix("obs-")) + Expect(row.Envelope.Request.OperationID).NotTo(Equal(r.ObservationID)) + Expect(row.Envelope.Request.ResourceID).To(BeEmpty()) + } + It("reads typed status and preserves named tool and host operation IDs through a supervised process", func() { + _, _, _, front, r, path := setup(0) + positive(front, r, path) + Expect(records(path)).To(HaveLen(1)) + }) + for _, tc := range []struct { + name string + pause int32 + reads int + }{ + {"rejects a captured supervised client after same generation replacement before carrier dispatch", 1, 0}, + {"rejects a late supervised carrier observation after replacement before owner consumption", 2, 1}, + } { + tc := tc + It(tc.name, func() { + s, p, lookup, front, r, path := setup(tc.pause) + type result struct { + out ExecutionStatusResponse + err error + } + done := make(chan result, 1) + go func() { + out, e := front.ExecutionStatusRead(context.Background(), frontActor, r) + done <- result{out, e} + }() + var captured addonruntime.Instance + Eventually(lookup.captured, 3*time.Second).Should(Receive(&captured)) + if tc.pause == 1 { + Expect(captured.Incarnation).To(Equal(r.Binding.Incarnation)) + } + next := restart(s, p) + close(lookup.resume) + var got result + Eventually(done, 3*time.Second).Should(Receive(&got)) + Expect(got.err).To(HaveOccurred()) + Expect(got.out.Status.Readiness).To(Equal("unknown")) + Expect(got.out.Status.Isolation).To(Equal("unknown")) + rows := records(path) + Expect(rows).To(HaveLen(tc.reads)) + for _, row := range rows { + Expect(row.Incarnation).To(Equal(r.Binding.Incarnation)) + } + r.Binding.Incarnation = next.Incarnation + r.ObservationID = "fresh-q" + positive(front, r, path) + Expect(records(path)).To(HaveLen(tc.reads + 1)) + }) + } +}) diff --git a/core/services/addons/gateway/observation_test.go b/core/services/addons/gateway/observation_test.go new file mode 100644 index 000000000..d18b108b5 --- /dev/null +++ b/core/services/addons/gateway/observation_test.go @@ -0,0 +1,278 @@ +// SPDX-License-Identifier: MIT +package gateway + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "sync/atomic" + + addonruntime "github.com/mudler/LocalAI/core/services/addons/runtime" + "github.com/mudler/LocalAI/pkg/addons/contract" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type observationCaps struct{ toolSet } + +type rawObservationCaps struct { + toolSet + caps contract.Capabilities +} + +func (s rawObservationCaps) DeclaredCapabilities(context.Context, string, uint64) (contract.Capabilities, error) { + return s.caps, nil +} + +func (s observationCaps) DeclaredCapabilities(context.Context, string, uint64) (contract.Capabilities, error) { + return contract.Capabilities{Tools: []string{"run", "health"}, ExecutionProvider: &contract.ExecutionProvider{Tools: []string{"run"}, StatusTool: "health"}, ToolDefinitions: []contract.ToolDefinition{ + {Name: "run", Kind: "action", Shadows: "bash"}, + {Name: "health", Kind: "read", UIOnly: true, InputSchema: json.RawMessage(`{"type":"object","additionalProperties":false}`)}, + }}, nil +} + +type observationClient struct { + contract.Runtime + calls atomic.Int32 + stale atomic.Bool + after func() + actor contract.Actor +} + +func (c *observationClient) AdmitObservation(g uint64, i string) (func(), error) { + if c.stale.Load() || g != 1 || i != "inc" { + return nil, addonruntime.ErrNotSent + } + return func() {}, nil +} +func (c *observationClient) ReadObservation(_ context.Context, e contract.Envelope, q string) (contract.ExecutionStatus, error) { + c.calls.Add(1) + if c.stale.Load() { + return contract.ExecutionStatus{}, addonruntime.ErrNotSent + } + if e.Authority.Actor != c.actor || e.Request.OperationID == q || e.Request.OperationID == "" || e.Request.ResourceID != "" || string(e.Request.Arguments) != `{"tool":"health"}` { + return contract.ExecutionStatus{}, ErrForbidden + } + if c.after != nil { + c.after() + } + return contract.ExecutionStatus{Readiness: "available", Isolation: "isolated"}, nil +} + +var _ = Describe("exact owner execution observation", func() { + var g *Gateway + var c *observationClient + var r ExecutionStatusRequest + BeforeEach(func() { + c = &observationClient{actor: frontActor} + g = &Gateway{Revalidate: func(context.Context, contract.Actor) error { return nil }, Admission: &addonAdmitted{}, Runtime: currentObservation{c}, Tools: observationCaps{toolSet{gen: 1, tools: map[string][]string{"sample": {"run", "health"}}}}, AuthorizeExecutionStatus: func(_ context.Context, a contract.Actor, b ExecutionStatusBinding) error { + if a != frontActor || b.CurrentSelection != "rev" { + return ErrForbidden + } + return nil + }} + r = ExecutionStatusRequest{Version: 1, Binding: ExecutionStatusBinding{Actor: frontActor, AddonID: "sample", Generation: 1, Incarnation: "inc", ExecutionTool: "run", StatusTool: "health", CurrentSelection: "rev"}, ObservationID: "q"} + }) + + DescribeTable("rejects invalid raw execution declarations", func(field, value string) { + caps, err := g.Tools.(observationCaps).DeclaredCapabilities(context.Background(), "sample", 1) + Expect(err).NotTo(HaveOccurred()) + switch field { + case "schema": + caps.ToolDefinitions[1].InputSchema = json.RawMessage(value) + case "resource": + caps.ToolDefinitions[1].InputSchema = json.RawMessage(`{"type":"object","properties":{"resourceId":{"type":"string"}},"required":["resourceId"],"additionalProperties":false}`) + case "admin": + caps.AdminTools = []string{"health"} + case "status-ui": + caps.ToolDefinitions[1].UIOnly = false + case "status-kind": + caps.ToolDefinitions[1].Kind = value + case "action-ui": + caps.ToolDefinitions[0].UIOnly = true + case "action-kind": + caps.ToolDefinitions[0].Kind = value + case "shadow": + caps.ToolDefinitions[0].Shadows = value + } + g.Tools = rawObservationCaps{caps: caps} + out, err := g.ExecutionStatusRead(context.Background(), frontActor, r) + Expect(err).To(MatchError(ErrForbidden)) + Expect(out.Status.Readiness).To(Equal("unknown")) + Expect(c.calls.Load()).To(BeZero()) + }, + Entry("wrong object", "schema", `{"type":"string","additionalProperties":false}`), + Entry("additional properties true", "schema", `{"type":"object","additionalProperties":true}`), + Entry("additional properties absent", "schema", `{"type":"object"}`), + Entry("required", "schema", `{"type":"object","properties":{"query":{"type":"string"}},"required":["query"],"additionalProperties":false}`), + Entry("enum", "schema", `{"type":"object","enum":["x"],"additionalProperties":false}`), + Entry("unknown schema keyword", "schema", `{"type":"object","minProperties":1,"additionalProperties":false}`), + Entry("malformed required", "schema", `{"type":"object","required":null,"additionalProperties":false}`), + Entry("resource", "resource", ""), + Entry("admin", "admin", ""), + Entry("status uiOnly", "status-ui", ""), + Entry("status kind", "status-kind", "action"), + Entry("action uiOnly", "action-ui", ""), + Entry("action kind", "action-kind", "read"), + Entry("shadow", "shadow", "other"), + ) + + It("reads the exact uiOnly status tool locally and over authenticated mTLS", func() { + _, cs := carrierFixture(func(car *Carrier) http.Handler { + h, e := car.OwnerHandler(g) + Expect(e).NotTo(HaveOccurred()) + return h + }) + for _, gw := range []*Gateway{g, {Forward: cs["front-a"], Revalidate: g.Revalidate}} { + out, e := gw.ExecutionStatusRead(context.Background(), frontActor, r) + Expect(e).NotTo(HaveOccurred(), "forward=%v calls=%d", gw.Forward != nil, c.calls.Load()) + Expect(out.Binding).To(Equal(r.Binding)) + Expect(out.ObservationID).To(Equal("q")) + Expect(out.Status.Readiness).To(Equal("available")) + } + Expect(c.calls.Load()).To(Equal(int32(2))) + }) + It("refuses forged identities tools current selections and incarnations without dispatch", func() { + for _, change := range []func(*ExecutionStatusRequest){func(r *ExecutionStatusRequest) { r.Binding.Actor.UserID = "forged" }, func(r *ExecutionStatusRequest) { r.Binding.StatusTool = "settings" }, func(r *ExecutionStatusRequest) { r.Binding.CurrentSelection = "old" }, func(r *ExecutionStatusRequest) { r.Binding.Incarnation = "old" }, func(r *ExecutionStatusRequest) { r.Binding.Generation = 2 }, func(r *ExecutionStatusRequest) { r.Binding.ExecutionTool = "other" }, func(r *ExecutionStatusRequest) { r.Version = 2 }, func(r *ExecutionStatusRequest) { r.ObservationID = "" }} { + bad := r + change(&bad) + out, e := g.ExecutionStatusRead(context.Background(), frontActor, bad) + Expect(e).To(HaveOccurred()) + Expect(out.Status.Readiness).To(Equal("unknown")) + } + Expect(c.calls.Load()).To(BeZero()) + }) + It("rejects stale carrier replies and frontend authority fields without dispatch", func() { + _, cs := carrierFixture(func(car *Carrier) http.Handler { + h, e := car.OwnerHandler(g) + Expect(e).NotTo(HaveOccurred()) + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if req.URL.Path != "/addons/forward" { + h.ServeHTTP(w, req) + return + } + out := observationUnknown(r) + out.Binding.Incarnation = "replacement" + out.Status = contract.ExecutionStatus{Readiness: "available", Isolation: "isolated"} + Expect(json.NewEncoder(w).Encode(out)).To(Succeed()) + }) + }) + front := &Gateway{Forward: cs["front-a"], Revalidate: g.Revalidate} + out, e := front.ExecutionStatusRead(context.Background(), frontActor, r) + Expect(e).To(HaveOccurred()) + Expect(out.Status.Readiness).To(Equal("unknown")) + Expect(c.calls.Load()).To(BeZero()) + for _, extra := range []string{`"admin":true`, `"grants":["workspace."]`, `"operationId":"chosen"`, `"arguments":{}`} { + raw, _ := json.Marshal(r) + raw = append(raw[:len(raw)-1], []byte(","+extra+"}")...) + var decoded ExecutionStatusRequest + Expect(decode(strings.NewReader(string(raw)), &decoded)).To(HaveOccurred()) + } + }) + It("refuses forged observations through the real carrier and ignores ordinary dispatch attempts", func() { + _, cs := carrierFixture(func(car *Carrier) http.Handler { + h, e := car.OwnerHandler(g) + Expect(e).NotTo(HaveOccurred()) + return h + }) + for _, bad := range []ExecutionStatusRequest{ + {Version: 1, Binding: ExecutionStatusBinding{Actor: frontActor}, ObservationID: "q"}, + {Version: 1, Binding: r.Binding, ObservationID: ""}, + } { + _, e := cs["front-a"].executionStatus(context.Background(), frontActor, bad) + Expect(e).To(HaveOccurred()) + } + bad := r + bad.Binding.Actor.UserID = "forged" + _, e := cs["front-a"].executionStatus(context.Background(), frontActor, bad) + Expect(e).To(HaveOccurred()) + _, e = g.Dispatch(context.Background(), frontActor, contract.Request{Action: executionStatusAction}) + Expect(e).To(HaveOccurred()) + Expect(c.calls.Load()).To(BeZero()) + }) + It("does not relay a captured old binding to a same-generation replacement", func() { + replacement := &observationClient{actor: frontActor} + g.Runtime = restartObservation{old: c, replacement: replacement} + _, cs := carrierFixture(func(car *Carrier) http.Handler { + h, e := car.OwnerHandler(g) + Expect(e).NotTo(HaveOccurred()) + return h + }) + out, e := (&Gateway{Forward: cs["front-a"], Revalidate: g.Revalidate}).ExecutionStatusRead(context.Background(), frontActor, r) + Expect(e).To(HaveOccurred()) + Expect(out.Status.Readiness).To(Equal("unknown")) + Expect(c.calls.Load()).To(BeZero()) + Expect(replacement.calls.Load()).To(BeZero()) + }) + It("settles parent cancellation and owner permission loss as unknown", func() { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + c.after = func() { + g.AuthorizeExecutionStatus = func(context.Context, contract.Actor, ExecutionStatusBinding) error { return ErrForbidden } + } + out, e := g.ExecutionStatusRead(ctx, frontActor, r) + Expect(e).To(HaveOccurred()) + Expect(out.Status.Readiness).To(Equal("unknown")) + out, e = g.ExecutionStatusRead(context.Background(), frontActor, r) + Expect(e).To(HaveOccurred()) + Expect(out.Status.Readiness).To(Equal("unknown")) + }) + + It("does not grant owner selection authority through wire capability support", func() { + g.AuthorizeExecutionStatus = nil + _, cs := carrierFixture(func(car *Carrier) http.Handler { + h, e := car.OwnerHandler(g) + Expect(e).NotTo(HaveOccurred()) + return h + }) + caps, e := cs["front-a"].Capabilities(context.Background()) + Expect(e).NotTo(HaveOccurred()) + Expect(caps.Supports(executionStatusAction)).To(BeTrue()) + out, e := (&Gateway{Forward: cs["front-a"], Revalidate: g.Revalidate}).ExecutionStatusRead(context.Background(), frontActor, r) + Expect(e).To(HaveOccurred()) + Expect(out.Status.Readiness).To(Equal("unknown")) + Expect(c.calls.Load()).To(BeZero()) + }) + It("rejects duplicate noncanonical and nested authority keys in observation DTOs", func() { + raw, _ := json.Marshal(r) + for _, bad := range []string{ + strings.Replace(string(raw), `"version":1`, `"version":1,"version":1`, 1), + strings.Replace(string(raw), `"binding":`, `"Binding":`, 1), + strings.Replace(string(raw), `"actor":{`, `"actor":{"admin":true,`, 1), + strings.Replace(string(raw), `"userId":`, `"UserId":`, 1), + } { + var decoded ExecutionStatusRequest + Expect(json.Unmarshal([]byte(bad), &decoded)).To(HaveOccurred()) + } + }) + + It("withdraws a positive late reply when exact-client admission becomes stale", func() { + _, cs := carrierFixture(func(car *Carrier) http.Handler { + h, e := car.OwnerHandler(g) + Expect(e).NotTo(HaveOccurred()) + return h + }) + for _, gw := range []*Gateway{g, {Forward: cs["front-a"], Revalidate: g.Revalidate}} { + c.stale.Store(false) + c.after = func() { c.stale.Store(true) } + out, e := gw.ExecutionStatusRead(context.Background(), frontActor, r) + Expect(e).To(HaveOccurred()) + Expect(out.Status.Readiness).To(Equal("unknown")) + } + }) +}) + +type currentObservation struct{ c *observationClient } + +func (s currentObservation) Runtime(context.Context, string) (addonruntime.Instance, error) { + return addonruntime.Instance{Generation: 1, Incarnation: "inc", Client: s.c}, nil +} + +// Snapshot is captured first; withdrawal occurs before gateway admission. +type restartObservation struct{ old, replacement *observationClient } + +func (s restartObservation) Runtime(context.Context, string) (addonruntime.Instance, error) { + s.old.stale.Store(true) + return addonruntime.Instance{Generation: 1, Incarnation: "inc", Client: s.old}, nil +} diff --git a/core/services/addons/gateway/ownerfixture_test.go b/core/services/addons/gateway/ownerfixture_test.go index 4ba8ff40a..1fc1347a3 100644 --- a/core/services/addons/gateway/ownerfixture_test.go +++ b/core/services/addons/gateway/ownerfixture_test.go @@ -12,6 +12,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "sort" "github.com/mudler/LocalAI/core/services/addons" "github.com/mudler/LocalAI/core/services/addons/catalog" @@ -26,6 +27,10 @@ const uiManifest = `{"schemaVersion":1,"id":"sample","version":"1.2.3","executab // ownerManager builds a real owner journal. With a UI package it also installs // the addon "sample" through the real catalog and installer, operation "install". func ownerManager(actor contract.Actor, install bool) *addons.Manager { + return ownerManagerPackage(actor, install, uiManifest, nil, 1) +} + +func ownerManagerPackage(actor contract.Actor, install bool, manifest string, files map[string]string, sdk uint64, grants ...map[string][]string) *addons.Manager { root := GinkgoT().TempDir() ctx := context.Background() admin := func(_ context.Context, a contract.Actor) error { @@ -36,9 +41,20 @@ func ownerManager(actor contract.Actor, install bool) *addons.Manager { } var b bytes.Buffer tw := tar.NewWriter(&b) - for _, f := range []struct{ name, data string }{{"manifest.json", uiManifest}, {"bin/sample", "binary"}, {"ui.js", "export default {};"}, {"ui.css", "body{}"}, {"secret.txt", "private"}} { - Expect(tw.WriteHeader(&tar.Header{Name: f.name, Mode: 0755, Size: int64(len(f.data))})).To(Succeed()) - _, e := tw.Write([]byte(f.data)) + contents := map[string]string{"manifest.json": manifest, "bin/sample": "binary", "ui.js": "export default {};", "ui.css": "body{}", "secret.txt": "private"} + for name, data := range files { + contents[name] = data + } + // Stable entry order keeps identical package contents at the same digest. + names := make([]string, 0, len(contents)) + for name := range contents { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + data := contents[name] + Expect(tw.WriteHeader(&tar.Header{Name: name, Mode: 0755, Size: int64(len(data))})).To(Succeed()) + _, e := tw.Write([]byte(data)) Expect(e).NotTo(HaveOccurred()) } Expect(tw.Close()).To(Succeed()) @@ -56,7 +72,7 @@ func ownerManager(actor contract.Actor, install bool) *addons.Manager { Expect(e).NotTo(HaveOccurred()) c := catalog.Catalog{Sources: sources, TargetOS: "linux", TargetArch: "amd64"} cfg := addons.OwnerConfig{InstanceID: "owner", OwnerID: "owner", OwnerAddress: "https://owner.example", StorageID: "disk", Root: filepath.Join(root, "owner")} - i := &packages.Installer{Root: filepath.Join(cfg.Root, "packages"), Catalog: c, Host: contract.HostCompatibility{Host: 1, API: 1, Protocol: 1, SDK: 1, DataFormat: 1, OS: "linux", Arch: "amd64"}} + i := &packages.Installer{Root: filepath.Join(cfg.Root, "packages"), Catalog: c, Host: contract.HostCompatibility{Host: 1, API: 1, Protocol: 1, SDK: sdk, DataFormat: 1, OS: "linux", Arch: "amd64"}} DeferCleanup(func() { _ = filepath.Walk(root, func(p string, info os.FileInfo, e error) error { if e == nil && info.IsDir() { @@ -68,6 +84,9 @@ func ownerManager(actor contract.Actor, install bool) *addons.Manager { m, e := addons.NewManager(ctx, cfg, c, i, admin) Expect(e).NotTo(HaveOccurred()) DeferCleanup(func() { Expect(m.Close()).To(Succeed()) }) + if len(grants) != 0 { + m.SetHostOps(grants[0]) + } if install { _, e = m.Submit(ctx, actor, addons.LifecycleCommand{AddonID: "sample", OperationID: "install", Action: "install", SourceID: "test", Version: "1.2.3"}) Expect(e).NotTo(HaveOccurred()) diff --git a/core/services/addons/hostops/admission.go b/core/services/addons/hostops/admission.go new file mode 100644 index 000000000..e9c42beeb --- /dev/null +++ b/core/services/addons/hostops/admission.go @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MIT +package hostops + +import ( + "slices" + "strings" + + "github.com/mudler/LocalAI/pkg/addons/contract" +) + +// RequestSummary describes requests, not authority. Names are bounded by the +// verified manifest (32 total, 64 bytes each); no policy paths or other addon +// grants are included. Granted counts only requested, supported operations. +type RequestSummary struct { + Required []string `json:"required,omitempty"` + Optional []string `json:"optional,omitempty"` + Granted []string `json:"granted,omitempty"` + MissingRequired []string `json:"missingRequired,omitempty"` + MissingOptional []string `json:"missingOptional,omitempty"` + RequiredCount int `json:"requiredCount"` + OptionalCount int `json:"optionalCount"` + GrantedCount int `json:"grantedCount"` +} + +// Requests is side-effect free. Registry membership is exact; coverage uses the +// broker's literal nonempty prefix rule, not a regexp or a request-as-grant. +func Requests(req *contract.HostOperations, registered, grants []string) RequestSummary { + s := RequestSummary{} + if req == nil { + return s + } + s.Required = slices.Clone(req.Required) + s.Optional = slices.Clone(req.Optional) + slices.Sort(s.Required) + slices.Sort(s.Optional) + for i, names := range [][]string{s.Required, s.Optional} { + for _, name := range names { + if slices.Contains(registered, name) && hasPrefix(grants, name) { + s.Granted = append(s.Granted, name) + } else if i == 0 { + s.MissingRequired = append(s.MissingRequired, name) + } else { + s.MissingOptional = append(s.MissingOptional, name) + } + } + } + slices.Sort(s.Granted) + s.RequiredCount, s.OptionalCount, s.GrantedCount = len(s.Required), len(s.Optional), len(s.Granted) + return s +} + +// SupportedOperations is the host's startup registry vocabulary. Handlers are +// wired after manager recovery as their services become available. Admission +// must not mistake that startup ordering for unsupported operations. The broker +// still requires a registered handler and revalidates every request's authority. +func SupportedOperations() []string { + return strings.Fields(`finetune.start finetune.get finetune.list finetune.checkpoints finetune.dataset finetune.export finetune.progress finetune.stop quantize.start quantize.get quantize.list quantize.import quantize.progress quantize.stop workspace.materialize workspace.commit workspace.export workspace.import workspace.changes workspace.admin egress.check egress.audit connector.inbound`) +} diff --git a/core/services/addons/hostops/admission_test.go b/core/services/addons/hostops/admission_test.go new file mode 100644 index 000000000..a03360ca3 --- /dev/null +++ b/core/services/addons/hostops/admission_test.go @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: MIT +package hostops + +import ( + "context" + "slices" + "strings" + + "github.com/mudler/LocalAI/core/services/studio/session" + "github.com/mudler/LocalAI/core/services/studio/workspace" + + "github.com/mudler/LocalAI/pkg/addons/contract" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Only registration is exercised: no workspace method or handler may run. +type admissionWorkspaceStore struct{ workspace.Store } +type admissionInbox struct{} + +func (admissionInbox) Ingest(context.Context, string, contract.ConnectorInbound) error { + panic("admission parity must not invoke handlers") +} + +func registeredBuiltinOperations() []string { + b := New(NewInFlight(), nil, contract.DefaultLimits()) + Expect(RegisterEngineJobs(b, &fakeFT{}, &fakeQ{}, backends{})).To(Succeed()) + Expect(RegisterEngineDatasets(b, &fakeDatasets{})).To(Succeed()) + Expect(RegisterEngineModels(b, &fakeExporter{}, &fakeImporter{})).To(Succeed()) + p := NewProgresser(newFakeSource()) + Expect(RegisterEngineProgress(b, p, p)).To(Succeed()) + Expect(RegisterWorkspace(b, &WorkspaceHost{ + Store: &admissionWorkspaceStore{}, + Principal: func(contract.Actor) string { panic("registration only") }, + DataDir: func(string) string { panic("registration only") }, + Access: func(context.Context, string, string) (session.Access, error) { panic("registration only") }, + })).To(Succeed()) + Expect(RegisterEgress(b, &fakeEgress{})).To(Succeed()) + Expect(RegisterConnector(b, admissionInbox{})).To(Succeed()) + // Each registration error is checked above, so a duplicate cannot be hidden + // by the broker's map. Ops includes dynamic progress/stop and inbound specs. + return b.Ops() +} + +var _ = Describe("Host operation request admission", func() { + It("matches the supported vocabulary exactly to every real builtin registration", func() { + registered := registeredBuiltinOperations() + supported := SupportedOperations() + for _, names := range [][]string{registered, supported} { + Expect(names).NotTo(BeEmpty()) + seen := map[string]bool{} + for _, name := range names { + Expect(strings.TrimSpace(name)).NotTo(BeEmpty()) + Expect(seen[name]).To(BeFalse(), "duplicate operation %q", name) + seen[name] = true + } + } + Expect(supported).To(ConsistOf(registered), "startup admission must match the case-sensitive runtime registry") + }) + It("detects missing extra duplicate and case-changed operations with the parity oracle", func() { + registered := registeredBuiltinOperations() + Expect(registered).NotTo(BeEmpty()) + oracle := ConsistOf(registered) + for _, mutated := range [][]string{ + slices.Clone(registered[1:]), + append(slices.Clone(registered), "parity.negative.control"), + append(slices.Clone(registered), registered[0]), + append([]string{strings.ToUpper(registered[0])}, registered[1:]...), + } { + matches, err := oracle.Match(mutated) + Expect(err).NotTo(HaveOccurred()) + Expect(matches).To(BeFalse(), "parity oracle accepted mutated vocabulary %v", mutated) + } + }) + It("uses registered exact names and literal nonempty grant prefixes without self grants", func() { + req := &contract.HostOperations{Required: []string{"workspace.materialize", "future.op"}, Optional: []string{"egress.check"}} + s := Requests(req, []string{"workspace.materialize", "egress.check"}, []string{"workspace."}) + Expect(s.MissingRequired).To(Equal([]string{"future.op"})) + Expect(s.MissingOptional).To(Equal([]string{"egress.check"})) + Expect(s.Granted).To(Equal([]string{"workspace.materialize"})) + Expect(Requests(req, []string{"workspace.materialize"}, []string{""}).MissingRequired).To(HaveLen(2)) + Expect(Requests(&contract.HostOperations{Required: []string{"workspace.materialize"}}, []string{"workspace.materialize"}, []string{"workspace.materialize.narrow."}).MissingRequired).To(HaveLen(1)) + Expect(Requests(nil, nil, nil).MissingRequired).To(BeEmpty()) + }) + It("permits missing optional requests and bounds diagnostics to declaration names", func() { + req := &contract.HostOperations{Optional: []string{"egress.check", "future.op"}} + s := Requests(req, SupportedOperations(), []string{"egress."}) + Expect(s.MissingRequired).To(BeEmpty()) + Expect(s.MissingOptional).To(Equal([]string{"future.op"})) + Expect(s.OptionalCount).To(Equal(2)) + Expect(s.GrantedCount).To(Equal(1)) + }) +}) diff --git a/core/services/addons/hostops/broker.go b/core/services/addons/hostops/broker.go index 558684f04..39d9c1f86 100644 --- a/core/services/addons/hostops/broker.go +++ b/core/services/addons/hostops/broker.go @@ -158,6 +158,54 @@ func refuse(w http.ResponseWriter, status int, code, msg string) { // secret that incarnation holds; allowed lists the operation prefixes the // host setting gives the add-on. Whatever the check, the default is refuse. func (b *Broker) Handler(addonID, credential string, allowed []string) http.Handler { + return b.HandlerForRuntime(addonID, credential, allowed, 0, "") +} + +// RuntimeEndpoint owns callback admission and draining for one runtime. Stop +// prevents new HTTP requests and handler admissions. Drain waits for admitted +// requests, including those still decoding, without holding a lock across them. +// Retired observation correlations must not be forgotten until Drain returns. +type RuntimeEndpoint struct { + handler http.Handler + mu sync.Mutex + closed bool + active sync.WaitGroup +} + +type endpointKey struct{} + +func (b *Broker) EndpointForRuntime(addonID, credential string, allowed []string, generation uint64, incarnation string) *RuntimeEndpoint { + return &RuntimeEndpoint{handler: b.HandlerForRuntime(addonID, credential, allowed, generation, incarnation)} +} + +func (e *RuntimeEndpoint) ServeHTTP(w http.ResponseWriter, r *http.Request) { + e.mu.Lock() + if e.closed { + e.mu.Unlock() + refuse(w, http.StatusForbidden, contract.HostNoLiveOperation, "runtime endpoint closed") + return + } + e.active.Add(1) + e.mu.Unlock() + defer e.active.Done() + e.handler.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), endpointKey{}, e))) +} + +func (e *RuntimeEndpoint) Stop() { e.mu.Lock(); e.closed = true; e.mu.Unlock() } + +// Drain must be called after Stop, which serializes admission with Wait. +func (e *RuntimeEndpoint) Drain() { e.active.Wait() } + +// admit linearizes handler admission against Stop. No mutex is held while +// running a handler: cancellation and teardown may themselves need the broker. +func (e *RuntimeEndpoint) admit() bool { + e.mu.Lock() + defer e.mu.Unlock() + return !e.closed +} + +// HandlerForRuntime authenticates callbacks for exactly one runtime. +func (b *Broker) HandlerForRuntime(addonID, credential string, allowed []string, generation uint64, incarnation string) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { refuse(w, http.StatusMethodNotAllowed, contract.HostBadRequest, "POST only") @@ -182,6 +230,10 @@ func (b *Broker) Handler(addonID, credential string, allowed []string) http.Hand refuse(w, http.StatusBadRequest, contract.HostBadRequest, "malformed request") return } + if b.inflight.callbackDenied(addonID, req.OperationID, generation, incarnation) { + refuse(w, http.StatusForbidden, contract.HostScope, "observation callbacks denied") + return + } if !hasPrefix(allowed, req.Op) { refuse(w, http.StatusForbidden, contract.HostUnknownOp, "operation not allowed") return @@ -235,6 +287,8 @@ func (b *Broker) Handler(addonID, credential string, allowed []string) http.Hand if err != nil { status, code, msg := http.StatusServiceUnavailable, contract.HostUnavailable, "operation unavailable" switch { + case errors.Is(err, ErrForbidden): + status, code, msg = http.StatusForbidden, contract.HostNoLiveOperation, "runtime endpoint closed" case errors.Is(err, ErrNotFound): status, code, msg = http.StatusNotFound, contract.HostNotFound, "not found" case errors.Is(err, ErrConflict): @@ -326,6 +380,9 @@ func checkArguments(raw json.RawMessage) (json.RawMessage, error) { // operation ID and name; a repeat gets the first result, and a call that // arrives while the first still runs waits for it. A failure is not kept. func (b *Broker) run(ctx context.Context, spec Spec, call Call) (json.RawMessage, error) { + if endpoint, ok := ctx.Value(endpointKey{}).(*RuntimeEndpoint); ok && !endpoint.admit() { + return nil, ErrForbidden + } if !spec.Idempotent { return spec.Handler(ctx, call) } diff --git a/core/services/addons/hostops/inflight.go b/core/services/addons/hostops/inflight.go index 30e0540de..8dbdd168c 100644 --- a/core/services/addons/hostops/inflight.go +++ b/core/services/addons/hostops/inflight.go @@ -5,6 +5,7 @@ package hostops import ( + "errors" "sync" "time" @@ -15,23 +16,28 @@ import ( // add-on right now. A host operation is allowed only while its operation ID // is in here, so an add-on cannot act when no user asked it to. type InFlight struct { - mu sync.Mutex - entries map[string]*live + mu sync.Mutex + entries map[string]*live + observations map[string]*observation } type live struct { env contract.Envelope // binding is the chat the request was made for; bound is false for a // request no chat made. - binding Binding - bound bool + incarnation string + observation *observation + binding Binding + bound bool // mixed is set when two requests share the operation ID with different // bindings. Neither chat can be trusted, so the entry is not live. mixed bool n int } -func NewInFlight() *InFlight { return &InFlight{entries: map[string]*live{}} } +func NewInFlight() *InFlight { + return &InFlight{entries: map[string]*live{}, observations: map[string]*observation{}} +} func key(addonID, operationID string) string { return addonID + "/" + operationID } @@ -39,26 +45,25 @@ func key(addonID, operationID string) string { return addonID + "/" + operationI // function removes it; it is safe to call more than once. Two requests with // the same operation ID share one entry until both end. func (f *InFlight) Begin(env contract.Envelope) (end func()) { - return f.begin(env, Binding{}, false) + return f.begin(env, Binding{}, false, "") } // BeginBound records the envelope of a request made for the chat b. The // binding stays on the host: the envelope the add-on receives is unchanged. func (f *InFlight) BeginBound(env contract.Envelope, b Binding) (end func()) { - return f.begin(env, b, true) + return f.begin(env, b, true, "") } -func (f *InFlight) begin(env contract.Envelope, b Binding, bound bool) (end func()) { +func (f *InFlight) begin(env contract.Envelope, b Binding, bound bool, incarnation string) (end func()) { k := key(env.Request.AddonID, env.Request.OperationID) f.mu.Lock() if e := f.entries[k]; e != nil { e.n++ - e.env = env - if e.bound != bound || e.binding != b { + if e.observation != nil || e.bound != bound || e.binding != b || e.incarnation != incarnation || e.env.Request.Generation != env.Request.Generation || e.env.Authority.Actor != env.Authority.Actor || e.env.Authority.Scope != env.Authority.Scope || e.env.Request.ResourceID != env.Request.ResourceID { e.mixed = true } } else { - f.entries[k] = &live{env: env, binding: b, bound: bound, n: 1} + f.entries[k] = &live{env: env, binding: b, bound: bound, incarnation: incarnation, n: 1} } f.mu.Unlock() var once sync.Once @@ -94,3 +99,70 @@ func (f *InFlight) LookupBound(addonID, operationID string) (contract.Envelope, } return e.env, e.binding, true } + +// observation is host-private, never decoded from an addon message. +type observation struct { + env contract.Envelope + incarnation, request string + denied bool +} + +// BeginRuntime binds a live envelope to the authenticated runtime incarnation. +func (f *InFlight) BeginRuntime(env contract.Envelope, b Binding, bound bool, incarnation string) func() { + return f.begin(env, b, bound, incarnation) +} + +// BeginObservation reserves a unique operation for a host-owned observation. +// End removes the live authority idempotently. A bounded retired correlation is +// kept until the runtime endpoint closes, so late inbound-class callbacks cannot +// regain ordinary authority. At capacity admission fails closed (no eviction). +func (f *InFlight) BeginObservation(env contract.Envelope, b Binding, incarnation, request string) (func(), func() bool, error) { + if incarnation == "" || request == "" || len(request) > 256 || env.Request.OperationID == "" { + return nil, nil, errors.New("missing observation identity") + } + f.mu.Lock() + k := key(env.Request.AddonID, env.Request.OperationID) + if len(f.observations) >= 4096 || f.entries[k] != nil || f.observations[k] != nil { + f.mu.Unlock() + return nil, nil, errors.New("operation already used") + } + o := &observation{env: env, incarnation: incarnation, request: request} + f.observations[k] = o + f.entries[k] = &live{env: env, binding: b, bound: b.Valid(), incarnation: incarnation, observation: o, n: 1} + f.mu.Unlock() + var once sync.Once + end := func() { once.Do(func() { f.mu.Lock(); delete(f.entries, k); f.mu.Unlock() }) } + return end, func() bool { f.mu.Lock(); defer f.mu.Unlock(); return o.denied }, nil +} + +func (f *InFlight) callbackDenied(addon, op string, generation uint64, incarnation string) bool { + f.mu.Lock() + defer f.mu.Unlock() + if o := f.observations[key(addon, op)]; o != nil { + o.denied = true + return true + } + if e := f.entries[key(addon, op)]; e != nil && incarnation != "" && (e.incarnation != incarnation || e.env.Request.Generation != generation) { + return true + } + return false +} + +// ForgetRuntime releases retired observation correlations only after the callback +// endpoint has stopped admission and all admitted HTTP handlers have drained. +// Server.Close alone is insufficient: it does not wait for active handlers. +// Live entries are removed too; the stopped endpoint refuses subsequent calls. +func (f *InFlight) ForgetRuntime(addon, incarnation string) { + f.mu.Lock() + defer f.mu.Unlock() + for k, o := range f.observations { + if o.env.Request.AddonID == addon && o.incarnation == incarnation { + delete(f.observations, k) + } + } + for k, e := range f.entries { + if e.env.Request.AddonID == addon && e.incarnation == incarnation { + delete(f.entries, k) + } + } +} diff --git a/core/services/addons/hostops/observation_test.go b/core/services/addons/hostops/observation_test.go new file mode 100644 index 000000000..3c1c38f29 --- /dev/null +++ b/core/services/addons/hostops/observation_test.go @@ -0,0 +1,267 @@ +// SPDX-License-Identifier: MIT +package hostops + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "time" + + "github.com/mudler/LocalAI/pkg/addons/contract" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Observation callback boundary", func() { + It("denies every correlated class before handlers and retains late denial", func() { + f := &fixture{inflight: NewInFlight()} + f.b = New(f.inflight, always, contract.DefaultLimits()) + calls := 0 + file := filepath.Join(GinkgoT().TempDir(), "effect") + for op, scope := range map[string]string{"workspace.materialize": "read", "workspace.export": "read", "workspace.commit": "act", "finetune.start": "apply", "connector.inbound": ScopeInbound} { + feature := "chats" + if scope == ScopeInbound { + feature = "" + } + Expect(f.b.Register(Spec{Op: op, Scope: scope, Feature: feature, Handler: func(context.Context, Call) (json.RawMessage, error) { + calls++ + _ = os.WriteFile(file, []byte("effect"), 0600) + return json.RawMessage(`{}`), nil + }})).To(Succeed()) + } + f.h = f.b.HandlerForRuntime("fine-tuning", "secret", []string{"workspace.", "finetune.", "connector."}, 1, "inc") + e := env("fine-tuning", "status", "read", time.Now().Add(time.Minute)) + end, denied, err := f.inflight.BeginObservation(e, Binding{}, "inc", "request") + Expect(err).NotTo(HaveOccurred()) + for _, op := range []string{"workspace.materialize", "workspace.export", "workspace.commit", "finetune.start", "connector.inbound"} { + code, _ := f.call("secret", "status", op, `{}`) + Expect(code).To(Equal(http.StatusForbidden), op) + } + Expect(denied()).To(BeTrue()) + Expect(calls).To(BeZero()) + _, statErr := os.Stat(file) + Expect(os.IsNotExist(statErr)).To(BeTrue()) + ordinary := env("fine-tuning", "ordinary", "act", time.Now().Add(time.Minute)) + DeferCleanup(f.inflight.BeginRuntime(ordinary, Binding{}, false, "inc")) + code, _ := f.call("secret", "ordinary", "workspace.export", `{}`) + Expect(code).To(Equal(http.StatusOK)) + code, _ = f.call("secret", "ordinary", "workspace.commit", `{}`) + Expect(code).To(Equal(http.StatusOK)) + Expect(calls).To(Equal(2)) + contents, err := os.ReadFile(file) + Expect(err).NotTo(HaveOccurred()) + Expect(string(contents)).To(Equal("effect")) + end() + end() + _, live := f.inflight.Lookup("fine-tuning", "status") + Expect(live).To(BeFalse()) + code, _ = f.call("secret", "status", "connector.inbound", `{}`) + Expect(code).To(Equal(http.StatusForbidden)) + + }) + It("cannot overwrite an observation with a forged identity or duplicate operation", func() { + f := NewInFlight() + e := env("fine-tuning", "status", "read", time.Now().Add(time.Minute)) + end, _, err := f.BeginObservation(e, Binding{}, "inc", "request") + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(end) + forged := e + forged.Request.Generation = 2 + DeferCleanup(f.BeginRuntime(forged, Binding{}, false, "other")) + _, ok := f.Lookup("fine-tuning", "status") + Expect(ok).To(BeFalse()) + _, _, err = f.BeginObservation(e, Binding{}, "inc", "request2") + Expect(err).To(HaveOccurred()) + }) +}) + +var _ = Describe("Observation identity checks", func() { + It("rejects mismatched authenticated incarnation and forged IDs without touching handlers", func() { + f := newFixture(always) + e := env("fine-tuning", "status", "read", time.Now().Add(time.Minute)) + end, denied, err := f.inflight.BeginObservation(e, Binding{}, "inc", "q") + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(end) + f.h = f.b.HandlerForRuntime("fine-tuning", "secret", []string{"finetune."}, 2, "replacement") + code, _ := f.call("secret", "status", "finetune.get", `{}`) + Expect(code).To(Equal(403)) + Expect(denied()).To(BeTrue()) + code, _ = f.call("secret", "forged", "finetune.get", `{}`) + Expect(code).To(Equal(403)) + code, _ = f.call("forged", "status", "finetune.get", `{}`) + Expect(code).To(Equal(401)) + Expect(f.seen.AddonID).To(BeEmpty()) + }) + It("removes retired state on endpoint close", func() { + f := NewInFlight() + e := env("fine-tuning", "status", "read", time.Now().Add(time.Minute)) + end, _, err := f.BeginObservation(e, Binding{}, "inc", "q") + Expect(err).NotTo(HaveOccurred()) + end() + Expect(f.observations).To(HaveLen(1)) + f.ForgetRuntime("fine-tuning", "inc") + f.ForgetRuntime("fine-tuning", "inc") + Expect(f.observations).To(BeEmpty()) + Expect(f.entries).To(BeEmpty()) + }) + It("fails closed at the retained correlation bound without restricting ordinary operations", func() { + f := NewInFlight() + e := env("fine-tuning", "status", "read", time.Now().Add(time.Minute)) + for n := 0; n < 4096; n++ { + e.Request.OperationID = fmt.Sprintf("status-%d", n) + end, _, err := f.BeginObservation(e, Binding{}, "inc", "q") + Expect(err).NotTo(HaveOccurred()) + end() + } + e.Request.OperationID = "overflow" + _, _, err := f.BeginObservation(e, Binding{}, "inc", "q") + Expect(err).To(HaveOccurred()) + e.Request.OperationID = "ordinary" + end := f.BeginRuntime(e, Binding{}, false, "inc") + defer end() + _, live := f.Lookup("fine-tuning", "ordinary") + Expect(live).To(BeTrue()) + f.ForgetRuntime("fine-tuning", "inc") + Expect(f.observations).To(BeEmpty()) + }) + +}) + +// A body barrier runs only after authentication and after all request bytes have +// been read. Returning EOF resumes decoding and the correlation denial check. +type callbackBodyBarrier struct { + *strings.Reader + read chan struct{} + resume chan struct{} +} + +func (b *callbackBodyBarrier) Read(p []byte) (int, error) { + n, err := b.Reader.Read(p) + if err != nil { + close(b.read) + <-b.resume + } + return n, err +} +func (b *callbackBodyBarrier) Close() error { return nil } + +var _ = Describe("Callback endpoint retirement", func() { + It("keeps authenticated pending inbound callbacks denied while another endpoint works", func() { + f := NewInFlight() + b := New(f, always, contract.DefaultLimits()) + var inbound, ordinary atomic.Int32 + Expect(b.Register(Spec{Op: "connector.inbound", Scope: ScopeInbound, Handler: func(context.Context, Call) (json.RawMessage, error) { + inbound.Add(1) + return json.RawMessage(`{}`), nil + }})).To(Succeed()) + Expect(b.Register(Spec{Op: "workspace.export", Scope: "read", Feature: "chats", Handler: func(context.Context, Call) (json.RawMessage, error) { + ordinary.Add(1) + return json.RawMessage(`{}`), nil + }})).To(Succeed()) + end, _, err := f.BeginObservation(env("fine-tuning", "status", "read", time.Now().Add(time.Minute)), Binding{}, "inc", "q") + Expect(err).NotTo(HaveOccurred()) + end() + h := b.EndpointForRuntime("fine-tuning", "secret", []string{"connector."}, 1, "inc") + body := &callbackBodyBarrier{Reader: strings.NewReader(`{"operationId":"status","op":"connector.inbound","arguments":{}}`), read: make(chan struct{}), resume: make(chan struct{})} + req := httptest.NewRequest(http.MethodPost, "/host", body) + req.Header.Set(contract.HostCredentialHeader, "secret") + rec := httptest.NewRecorder() + done := make(chan struct{}) + go func() { h.ServeHTTP(rec, req); close(done) }() + <-body.read + h.Stop() + retired := make(chan struct{}) + go func() { h.Drain(); f.ForgetRuntime("fine-tuning", "inc"); close(retired) }() + select { + case <-retired: + Fail("retired before callback drained") + default: + } + Expect(f.callbackDenied("fine-tuning", "status", 1, "inc")).To(BeTrue()) + other := &fixture{b: b, inflight: f, h: b.HandlerForRuntime("other", "other-secret", []string{"workspace."}, 1, "other-inc")} + defer f.BeginRuntime(env("other", "ordinary", "read", time.Now().Add(time.Minute)), Binding{}, false, "other-inc")() + code, _ := other.call("other-secret", "ordinary", "workspace.export", `{}`) + Expect(code).To(Equal(http.StatusOK)) + close(body.resume) + <-done + Expect(inbound.Load()).To(BeZero()) + Expect(rec.Code).To(Equal(http.StatusForbidden)) + Expect(ordinary.Load()).To(Equal(int32(1))) + <-retired + Expect(f.observations).To(BeEmpty()) + Expect(f.entries).To(HaveLen(1)) + closed := &fixture{h: h} + code, _ = closed.call("secret", "fresh", "connector.inbound", `{}`) + Expect(code).To(Equal(http.StatusForbidden)) + Expect(inbound.Load()).To(BeZero()) + }) +}) + +var _ = Describe("Decoded callback dispatch retirement", func() { + It("refuses an authorized decoded callback paused across endpoint Stop", func() { + f := NewInFlight() + authorized, resume := make(chan struct{}), make(chan struct{}) + b := New(f, func(string, string) bool { close(authorized); <-resume; return true }, contract.DefaultLimits()) + var calls atomic.Int32 + Expect(b.Register(Spec{Op: "workspace.export", Scope: "read", Feature: "chats", Handler: func(context.Context, Call) (json.RawMessage, error) { + calls.Add(1) + return json.RawMessage(`{}`), nil + }})).To(Succeed()) + h := b.EndpointForRuntime("fine-tuning", "secret", []string{"workspace."}, 1, "inc") + defer f.BeginRuntime(env("fine-tuning", "ordinary", "read", time.Now().Add(time.Minute)), Binding{}, false, "inc")() + fixture := &fixture{h: h} + result := make(chan int, 1) + go func() { code, _ := fixture.call("secret", "ordinary", "workspace.export", `{}`); result <- code }() + <-authorized // Decoded and correlation checked; paused in the real feature check. + h.Stop() + drained := make(chan struct{}) + go func() { h.Drain(); close(drained) }() + select { + case <-drained: + Fail("drained before authorized request returned") + default: + } + close(resume) + Expect(<-result).To(Equal(http.StatusForbidden)) + <-drained + Expect(calls.Load()).To(BeZero()) + }) + It("stops without waiting for a handler that ignores cancellation", func() { + f := NewInFlight() + b := New(f, always, contract.DefaultLimits()) + entered, release := make(chan struct{}), make(chan struct{}) + var h *RuntimeEndpoint + Expect(b.Register(Spec{Op: "connector.inbound", Scope: ScopeInbound, Handler: func(context.Context, Call) (json.RawMessage, error) { + close(entered) + <-release // Deliberately ignores request cancellation. + h.Stop() // No lifecycle lock may be held across the handler or Drain. + return json.RawMessage(`{}`), nil + }})).To(Succeed()) + h = b.EndpointForRuntime("fine-tuning", "secret", []string{"connector."}, 1, "inc") + fixture := &fixture{h: h} + done := make(chan struct{}) + go func() { fixture.call("secret", "ordinary", "connector.inbound", `{}`); close(done) }() + <-entered + stopped := make(chan struct{}) + go func() { h.Stop(); close(stopped) }() + Eventually(stopped).Should(BeClosed()) + drained := make(chan struct{}) + go func() { h.Drain(); close(drained) }() + select { + case <-drained: + Fail("drained while handler still running") + default: + } + code, _ := fixture.call("secret", "fresh", "connector.inbound", `{}`) + Expect(code).To(Equal(http.StatusForbidden)) + close(release) + Eventually(done).Should(BeClosed()) + Eventually(drained).Should(BeClosed()) + }) +}) diff --git a/core/services/addons/management.go b/core/services/addons/management.go index 1f0cc9746..78b99cd01 100644 --- a/core/services/addons/management.go +++ b/core/services/addons/management.go @@ -170,6 +170,11 @@ func (m *Manager) Installed(ctx context.Context, actor contract.Actor) ([]apps.I if st.DesiredEnabled && m.runtime != nil { snap.State = m.runtime.State(id, st.Generation) } + snap.HostOperations = summaryPointer(m.requests(receipt)) + if m.admit(receipt) != nil { + snap.Unavailable = true + snap.State = "failed" + } if m.runtime != nil { restarts[id] = m.runtime.Attempts(id) } diff --git a/core/services/addons/manager.go b/core/services/addons/manager.go index 84afe0585..cffceb848 100644 --- a/core/services/addons/manager.go +++ b/core/services/addons/manager.go @@ -155,16 +155,25 @@ type Manager struct { skillSync SkillSync // hostOps is the host setting of the host operation prefixes of each // add-on. The apps list reads it to decide which host controls it draws. - hostOps map[string][]string + hostOps map[string][]string + hostOpsSet bool } // SetHostOps sets the host operation grants of the add-ons (the host setting, // read at startup). The apps list draws a host control only for an add-on -// whose grants entitle it. +// whose grants entitle it. The first call selects an immutable startup snapshot; +// subsequent calls do not reload policy. Call before attaching the runtime. func (m *Manager) SetHostOps(ops map[string][]string) { m.mu.Lock() defer m.mu.Unlock() - m.hostOps = ops + if m.hostOpsSet { + return + } + m.hostOpsSet = true + m.hostOps = make(map[string][]string, len(ops)) + for id, prefixes := range ops { + m.hostOps[id] = slices.Clone(prefixes) + } } // SkillSync copies the skills of an add-on package into the chats' skill layer @@ -318,6 +327,10 @@ func (m *Manager) Status(ctx context.Context, id string) (Status, error) { s.AddonID = id s.ObservedHealth = "stopped" s.StartEligible = s.Installed && s.DesiredEnabled + if m.admission(id) != nil { + s.StartEligible = false + s.ObservedHealth = "unavailable" + } for _, op := range m.journal.Operations { if op.Command.AddonID == id && op.Status.Phase != string(lifecycleCompleted) && op.Status.Phase != string(lifecycleFailed) { s.StartEligible = false @@ -497,6 +510,10 @@ func (m *Manager) submit(ctx context.Context, actor contract.Actor, cmd Lifecycl // ErrPackage is fixed, so it is safe to show. return op.Status, fmt.Errorf("%w: %w", ErrOperationFailed, packages.ErrPackage) } + var admission *HostRequirementsRefusal + if errors.As(c, &admission) { + return op.Status, fmt.Errorf("%w: %w", ErrOperationFailed, admission) + } var typed *FailureCause if errors.As(c, &typed) { return op.Status, fmt.Errorf("%w: %w", ErrOperationFailed, typed) @@ -545,6 +562,14 @@ func (m *Manager) submit(ctx context.Context, actor contract.Actor, cmd Lifecycl if m.uncertain || m.runtimeRecoveryRequired { return op.Status, ErrRecoveryRequired } + if e := m.admit(staged.Receipt()); e != nil { + op.Status.HostOperations = summaryPointer(m.requests(staged.Receipt())) + if discardErr := m.installer.Discard(staged); discardErr != nil { + xlog.Warn("Could not discard refused addon stage", "addon", cmd.AddonID, "error", discardErr) + } + return fail(e) + } + op.Status.HostOperations = summaryPointer(m.requests(staged.Receipt())) var e error if release != nil { // An explicit upgrade never changes the trusted identity, @@ -613,6 +638,9 @@ func (m *Manager) submit(ctx context.Context, actor contract.Actor, cmd Lifecycl } } case "enable": + if e := m.admission(cmd.AddonID); e != nil { + return fail(e) + } // Data that needs recovery is not served: it is restored or purged first. if m.dataBlocked(cmd.AddonID) { return fail() @@ -880,6 +908,7 @@ func (m *Manager) AttachRuntime(ctx context.Context, s *addonruntime.Supervisor) if m.uncertain || m.runtime != nil || s == nil { return ErrConflict } + m.hostOpsSet = true // An omitted startup policy is an immutable empty grant set. m.runtime = s m.instances = map[string]addonruntime.Instance{} m.startFailed = map[string]error{} @@ -902,6 +931,9 @@ func (m *Manager) syncFor(ctx context.Context, id string) error { // the add-on stopped on a generation that never ran. Without a running // instance there is nothing to replace and nothing to prove here. func (m *Manager) startCandidate(ctx context.Context, id string, prev Status, receipt packages.PackageReceipt, number uint64) error { + if e := m.admit(receipt); e != nil { + return e + } if m.runtime == nil || !prev.Installed || !prev.DesiredEnabled { return nil } @@ -992,6 +1024,10 @@ func (m *Manager) syncRuntime(ctx context.Context, target string) error { if _, failed := m.startFailed[id]; failed && id != target { continue } + if e := m.admission(id); e != nil { + m.startFailed[id] = e + continue + } if m.quiesced[id] || m.dataBlocked(id) { continue } @@ -1048,6 +1084,9 @@ func (m *Manager) Runtime(ctx context.Context, id string) (addonruntime.Instance return addonruntime.Instance{}, e } state := m.journal.Addons[id] + if m.admission(id) != nil { + return addonruntime.Instance{}, addonruntime.ErrRuntime + } if m.uncertain || m.runtimeRecoveryRequired || m.runtime == nil || !state.Installed || !state.DesiredEnabled { return addonruntime.Instance{}, addonruntime.ErrRuntime } @@ -1145,7 +1184,7 @@ func (m *Manager) OperationStatus(ctx context.Context, actor contract.Actor, id if !ok || op.Actor.UserID == "" || op.Actor.CredentialID == "" || (op.Actor.Mode != "named-user" && op.Actor.Mode != "local-owner") || op.Actor != actor { return OperationStatus{}, ErrOperationNotFound } - result := OperationStatus{OperationID: id, AddonID: op.Status.AddonID, Phase: op.Status.Phase, Generation: op.Status.Generation, RuntimeState: "not-observed"} + result := OperationStatus{OperationID: id, AddonID: op.Status.AddonID, Phase: op.Status.Phase, Generation: op.Status.Generation, RuntimeState: "not-observed", HostOperations: copyRequestSummary(op.Status.HostOperations)} if m.uncertain || m.runtimeRecoveryRequired { result.RuntimeState = string(lifecycleRecovery) } @@ -1183,6 +1222,12 @@ func (m *Manager) Apps(ctx context.Context, actor contract.Actor) ([]apps.App, e if st.DesiredEnabled && m.runtime != nil { snap.State = m.runtime.State(id, st.Generation) } + summary := m.requests(receipt) + snap.HostOperations = summaryPointer(summary) + if len(summary.MissingRequired) != 0 { + snap.Unavailable = true + snap.State = "failed" + } // The apps list answers from the check cache only: it never // evaluates and never reaches a library. if m.updates != nil { @@ -1245,7 +1290,7 @@ func (m *Manager) AppAsset(ctx context.Context, actor contract.Actor, id, digest return packages.PackageReceipt{}, 0, ErrForbidden } receipt, ok := currentReceipt(st) - if !ok || receipt.Manifest.Capabilities.UI == nil || receipt.Digest != "sha256:"+digest { + if !ok || m.admit(receipt) != nil || receipt.Manifest.Capabilities.UI == nil || receipt.Digest != "sha256:"+digest { return packages.PackageReceipt{}, 0, ErrForbidden } return receipt, st.Generation, nil @@ -1303,6 +1348,9 @@ func (m *Manager) DeclaredTools(ctx context.Context, id string, generation uint6 if !ok || !st.Installed || generation == 0 || st.Generation != generation { return nil, ErrForbidden } + if m.admission(id) != nil { + return nil, ErrForbidden + } if c, ok := m.toolCache[id]; ok && c.generation == generation { return c.tools, nil } @@ -1340,10 +1388,24 @@ func (m *Manager) DeclaredCapabilities(ctx context.Context, id string, generatio if !ok { return contract.Capabilities{}, ErrForbidden } + if m.admit(receipt) != nil { + return contract.Capabilities{}, ErrForbidden + } c := receipt.Manifest.Capabilities out := contract.Capabilities{Service: c.Service, Tools: append([]string(nil), c.Tools...), AdminTools: append([]string(nil), c.AdminTools...), Actions: c.Actions, Undo: c.Undo} out.ToolDefinitions = append([]contract.ToolDefinition(nil), c.ToolDefinitions...) out.SkillDefinitions = append([]contract.SkillDefinition(nil), c.SkillDefinitions...) + if c.ExecutionProvider != nil { + p := *c.ExecutionProvider + p.Tools = slices.Clone(p.Tools) + out.ExecutionProvider = &p + } + if c.HostOperations != nil { + h := *c.HostOperations + h.Required = slices.Clone(h.Required) + h.Optional = slices.Clone(h.Optional) + out.HostOperations = &h + } if c.Resources != nil { r := *c.Resources out.Resources = &r diff --git a/core/services/addons/manager_host_ops_test.go b/core/services/addons/manager_host_ops_test.go new file mode 100644 index 000000000..58740efe5 --- /dev/null +++ b/core/services/addons/manager_host_ops_test.go @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +package addons + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Manager host grants snapshot", func() { + It("does not retain the caller's map or prefix slices", func() { + m := &Manager{} + grants := map[string][]string{"a": {"workspace."}} + m.SetHostOps(grants) + grants["a"][0] = "egress." + grants["b"] = []string{"workspace."} + Expect(m.hostOps).To(Equal(map[string][]string{"a": {"workspace."}})) + m.SetHostOps(map[string][]string{}) + Expect(m.hostOps).To(Equal(map[string][]string{"a": {"workspace."}})) + }) + It("keeps an explicitly empty startup policy immutable", func() { + m := &Manager{} + m.SetHostOps(map[string][]string{}) + m.SetHostOps(map[string][]string{"a": {"workspace."}}) + Expect(m.hostOps).To(BeEmpty()) + }) +}) diff --git a/core/services/addons/packages/discard_test.go b/core/services/addons/packages/discard_test.go new file mode 100644 index 000000000..9efeb6883 --- /dev/null +++ b/core/services/addons/packages/discard_test.go @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT +package packages + +import ( + "context" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "os" +) + +var _ = Describe("Refused package stages", func() { + It("discards only the creating installer's unpublished single use stage", func() { + manifest, e := os.ReadFile("../../../../tests/addons/fixtures/packages/manifest.json") + Expect(e).NotTo(HaveOccurred()) + i, r := fixture(archive(nil, manifest)) + staged, e := i.Stage(context.Background(), r, nil) + Expect(e).NotTo(HaveOccurred()) + dir := staged.state.dir + other := &Installer{} + Expect(other.Discard(staged)).NotTo(Succeed()) + Expect(dir).To(BeADirectory()) + Expect(i.Discard(staged)).To(Succeed()) + Expect(dir).NotTo(BeADirectory()) + _, e = i.Commit(context.Background(), staged) + Expect(e).To(HaveOccurred()) + Expect(i.Discard(staged)).NotTo(Succeed()) + }) +}) diff --git a/core/services/addons/packages/extensions_test.go b/core/services/addons/packages/extensions_test.go new file mode 100644 index 000000000..2eeee02bd --- /dev/null +++ b/core/services/addons/packages/extensions_test.go @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: MIT +package packages + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + + "github.com/mudler/LocalAI/pkg/addons/contract" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("typed package declarations", func() { + It("persists model actions and typed declarations together separately and absent in verified immutable manifests and receipts", func() { + for mask := 0; mask < 16; mask++ { + b, e := os.ReadFile("../../../../tests/addons/fixtures/packages/manifest.json") + Expect(e).NotTo(HaveOccurred()) + var m contract.Manifest + Expect(json.Unmarshal(b, &m)).To(Succeed()) + if mask&1 != 0 { + m.Capabilities.UI = &contract.UI{Entry: "ui.js", SDK: contract.VersionRange{Min: 1, Max: 2}, Pages: []string{"home"}, MainPage: "home", HomeCommands: []contract.HomeCommand{{ID: "open"}}} + } + if mask&8 != 0 { + if m.Capabilities.UI == nil { + m.Capabilities.UI = &contract.UI{Entry: "ui.js", SDK: contract.VersionRange{Min: 1, Max: 2}, Pages: []string{"home"}} + } + m.Capabilities.UI.ModelActions = []contract.ModelAction{{Capability: "FLAG_IMAGE", Page: "home", Path: "images"}} + } + if mask&2 != 0 { + m.Capabilities.Actions = true + m.Capabilities.Tools = []string{"run", "health"} + m.Capabilities.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}`)}} + m.Capabilities.ExecutionProvider = &contract.ExecutionProvider{Tools: []string{"run"}, StatusTool: "health"} + } + if mask&4 != 0 { + m.Capabilities.HostOperations = &contract.HostOperations{Required: []string{"workspace.materialize"}, Optional: []string{"egress.check"}} + } + b, e = json.Marshal(m) + Expect(e).NotTo(HaveOccurred()) + parsed, e := contract.ParseManifest(b) + Expect(e).NotTo(HaveOccurred()) + data := archiveFiles(b, map[string][]byte{"ui.js": []byte("export {}")}) + i, r := fixture(data) + st, e := i.Stage(context.Background(), r, nil) + Expect(e).NotTo(HaveOccurred()) + receipt, e := i.Commit(context.Background(), st) + Expect(e).NotTo(HaveOccurred()) + Expect(receipt.Digest).To(Equal(r.Digest)) + Expect(receipt.Manifest).To(Equal(parsed)) + disk, e := os.ReadFile(filepath.Join(receipt.Directory, "receipt.json")) + Expect(e).NotTo(HaveOccurred()) + var loaded PackageReceipt + Expect(json.Unmarshal(disk, &loaded)).To(Succeed()) + Expect(loaded).To(Equal(receipt)) + manifest, e := os.ReadFile(filepath.Join(receipt.Directory, "manifest.json")) + Expect(e).NotTo(HaveOccurred()) + Expect(manifest).To(Equal(b)) + if mask == 0 { + for _, key := range []string{"homeCommands", "executionProvider", "hostOperations", "modelActions"} { + Expect(string(disk)).NotTo(ContainSubstring(key)) + } + } + } + }) + It("refuses unsupported typed candidates without changing the previous package bytes digest or receipt", func() { + b, e := os.ReadFile("../../../../tests/addons/fixtures/packages/manifest.json") + Expect(e).NotTo(HaveOccurred()) + i, r := fixture(archive(nil, b)) + st, e := i.Stage(context.Background(), r, nil) + Expect(e).NotTo(HaveOccurred()) + prior, e := i.Commit(context.Background(), st) + Expect(e).NotTo(HaveOccurred()) + before, e := os.ReadFile(filepath.Join(prior.Directory, "receipt.json")) + Expect(e).NotTo(HaveOccurred()) + // Unknown extension keys are refused, rather than weakening strict v1. + bad := []byte(strings.Replace(string(b), `"service":true`, `"service":true,"executionProvider":{"tools":["run"],"statusTool":"health","future":true}`, 1)) + candidate, release := fixture(archive(nil, bad)) + candidate.Root = i.Root + _, e = candidate.Stage(context.Background(), release, nil) + Expect(e).To(HaveOccurred()) + after, e := os.ReadFile(filepath.Join(prior.Directory, "receipt.json")) + Expect(e).NotTo(HaveOccurred()) + Expect(after).To(Equal(before)) + manifest, e := os.ReadFile(filepath.Join(prior.Directory, "manifest.json")) + Expect(e).NotTo(HaveOccurred()) + Expect(manifest).To(Equal(b)) + Expect(prior.Digest).To(Equal(r.Digest)) + Expect(prior.Digest).NotTo(Equal(release.Digest)) + }) +}) diff --git a/core/services/addons/packages/receipt.go b/core/services/addons/packages/receipt.go index 33c6d59a7..8a159927f 100644 --- a/core/services/addons/packages/receipt.go +++ b/core/services/addons/packages/receipt.go @@ -111,3 +111,22 @@ func (i *Installer) Commit(ctx context.Context, s StagedPackage) (PackageReceipt st.used = true return receipt, nil } + +// Discard consumes an unpublished stage without publishing an installed receipt. +// Only its creating installer can remove it; installed packages are untouched. +func (i *Installer) Discard(s StagedPackage) error { + if s.state == nil { + return ErrPackage + } + st := s.state + st.mu.Lock() + defer st.mu.Unlock() + if st.owner != i || st.used { + return ErrPackage + } + if e := os.RemoveAll(st.dir); e != nil { + return e + } + st.used = true + return nil +} diff --git a/core/services/addons/pins/pins.go b/core/services/addons/pins/pins.go index 3b263fa88..ec01e60d2 100644 --- a/core/services/addons/pins/pins.go +++ b/core/services/addons/pins/pins.go @@ -19,15 +19,6 @@ const HardLimit = 32 // shorter than the time an administrator needs to reinstall after a mistake. const Retention = 30 * 24 * time.Hour -// SeedIDs are pinned once, at upgrade, for every account that already exists, -// so the layout does not change under users who had Studio and Talk in the -// sidebar. An account created after the upgrade is never seeded. -var SeedIDs = []string{"studio", "talk"} - -// UpgradeSeedName names the one seed run of this release. A later release that -// seeds something else uses another name. -const UpgradeSeedName = "studio-talk-v1" - // Pin is one row: one user, one add-on, one position. Every field is // serialised: the file store writes whole Pin values, and a pin without its // owner or its missing mark would restart the retention window at each reopen. @@ -40,30 +31,12 @@ type Pin struct { func (Pin) TableName() string { return "addon_pins" } -// SeedRun records that a seed ran for the whole deployment. -type SeedRun struct { - Name string `gorm:"primaryKey;size:64"` - At time.Time -} - -func (SeedRun) TableName() string { return "addon_pin_seed_runs" } - // Store keeps the pins. Every method is safe for concurrent use. type Store interface { List(ctx context.Context, userID string) ([]Pin, error) Replace(ctx context.Context, userID string, ids []string) error - SeedExisting(ctx context.Context, userIDs []string, ids []string) (bool, error) Distinct(ctx context.Context) ([]string, error) MarkMissing(ctx context.Context, ids []string, now time.Time) error ClearMissing(ctx context.Context, ids []string) error DeleteMissingBefore(ctx context.Context, cutoff time.Time) (int64, error) - // MarkInstalled records, once per ID, that a seeded add-on was present. - // Installed lists the IDs recorded. A seeded pin does not expire before - // its add-on was installed once (see Sweep). - MarkInstalled(ctx context.Context, ids []string) error - Installed(ctx context.Context) ([]string, error) } - -// installedPrefix starts the name of the record that a seeded add-on was -// installed. The records share the table of the seed runs. -const installedPrefix = "installed:" diff --git a/core/services/addons/pins/service.go b/core/services/addons/pins/service.go index 1e9b3df2f..d389c0907 100644 --- a/core/services/addons/pins/service.go +++ b/core/services/addons/pins/service.go @@ -14,9 +14,7 @@ var ErrTooMany = errors.New("too many pins") // built-in catalog and the Apps list, so a pin is filtered exactly like a tile. type Service struct{ Store Store } -// List returns the caller's visible pins in order. It never seeds: the upgrade -// seed runs once at start (Store.SeedExisting), for the accounts that exist -// then, so an account created later starts with no pins. +// List returns visible pins in order without changing the saved selection. func (s *Service) List(ctx context.Context, userID string, visible map[string]bool) ([]string, error) { stored, err := s.Store.List(ctx, userID) if err != nil { diff --git a/core/services/addons/pins/service_gorm_test.go b/core/services/addons/pins/service_gorm_test.go index e5cba9eeb..1821678cd 100644 --- a/core/services/addons/pins/service_gorm_test.go +++ b/core/services/addons/pins/service_gorm_test.go @@ -28,19 +28,18 @@ var _ = Describe("Two frontends over one database", func() { Expect(got).To(Equal([]string{"x", "talk"}), "the second frontend sees a different list") }) - // Two frontends that start together run the upgrade seed once between them. - It("seed once when they start together", func() { + // Opening a second frontend does not alter a saved selection. + It("preserve explicit unpins when another frontend opens", func() { db, err := auth.InitDB(":memory:") - Expect(err).ToNot(HaveOccurred()) - a, _ := NewGormStore(db) - b, _ := NewGormStore(db) + Expect(err).NotTo(HaveOccurred()) + a, err := NewGormStore(db) + Expect(err).NotTo(HaveOccurred()) ctx := context.Background() - ranA, errA := a.SeedExisting(ctx, []string{"u1"}, SeedIDs) - ranB, errB := b.SeedExisting(ctx, []string{"u1"}, SeedIDs) - Expect(errA).ToNot(HaveOccurred()) - Expect(errB).ToNot(HaveOccurred()) - Expect(ranA).ToNot(Equal(ranB), "exactly one frontend must seed") - got, _ := a.List(ctx, "u1") - Expect(idsOf(got)).To(Equal([]string{"studio", "talk"}), "seeded twice or not at all") + Expect(a.Replace(ctx, "u1", []string{"studio", "talk"})).To(Succeed()) + Expect(a.Replace(ctx, "u1", []string{})).To(Succeed()) + b, err := NewGormStore(db) + Expect(err).NotTo(HaveOccurred()) + Expect(b.List(ctx, "u1")).To(BeEmpty()) + Expect(b.List(ctx, "u2")).To(BeEmpty()) }) }) diff --git a/core/services/addons/pins/service_test.go b/core/services/addons/pins/service_test.go index 0b22112e3..e9438f2c4 100644 --- a/core/services/addons/pins/service_test.go +++ b/core/services/addons/pins/service_test.go @@ -37,10 +37,7 @@ var _ = Describe("Service", func() { }) It("starts a new account with no pins", func() { - // The upgrade ran earlier for the accounts that existed then. - ran, err := svc.Store.SeedExisting(ctx, []string{"old"}, SeedIDs) - Expect(err).ToNot(HaveOccurred()) - Expect(ran).To(BeTrue()) + Expect(svc.Store.Replace(ctx, "old", []string{"studio", "talk"})).To(Succeed()) got, err := svc.List(ctx, "created-after-the-upgrade", vis("studio", "talk")) Expect(err).ToNot(HaveOccurred()) Expect(got).To(BeEmpty(), "a new account must start with no pins") @@ -50,19 +47,11 @@ var _ = Describe("Service", func() { Expect(slices.Equal(got, []string{"talk"})).To(BeTrue(), "a read or a save must never add a pin of its own: %v", got) }) - It("pins Studio and Talk once at the upgrade seed", func() { - ran, err := svc.Store.SeedExisting(ctx, []string{"u1"}, SeedIDs) - Expect(err).ToNot(HaveOccurred()) - Expect(ran).To(BeTrue()) - got, err := svc.List(ctx, "u1", vis("studio", "talk")) - Expect(err).ToNot(HaveOccurred()) - Expect(got).To(Equal([]string{"studio", "talk"}), "an account that existed at the upgrade keeps Studio and Talk") - _, err = svc.Put(ctx, "u1", []string{"talk"}, vis("studio", "talk")) - Expect(err).ToNot(HaveOccurred()) - ran, _ = svc.Store.SeedExisting(ctx, []string{"u1"}, SeedIDs) - Expect(ran).To(BeFalse(), "the seed ran a second time") - got, _ = svc.List(ctx, "u1", vis("studio", "talk")) - Expect(got).To(Equal([]string{"talk"}), "an unpin did not stick") + It("keeps explicit pins and unpins on repeated reads", func() { + Expect(svc.Store.Replace(ctx, "u1", []string{"studio", "talk"})).To(Succeed()) + Expect(svc.List(ctx, "u1", vis("studio", "talk"))).To(Equal([]string{"studio", "talk"})) + Expect(svc.Put(ctx, "u1", []string{"talk"}, vis("studio", "talk"))).To(Equal([]string{"talk"})) + Expect(svc.List(ctx, "u1", vis("studio", "talk"))).To(Equal([]string{"talk"})) }) It("keeps pins the caller cannot see on Put", func() { diff --git a/core/services/addons/pins/store_contract_test.go b/core/services/addons/pins/store_contract_test.go index fda111e42..7b0bfb161 100644 --- a/core/services/addons/pins/store_contract_test.go +++ b/core/services/addons/pins/store_contract_test.go @@ -46,35 +46,20 @@ func registerStoreContract(open func() Store) { Expect(got).To(BeEmpty(), "leak: %v", idsOf(got)) }) - It("the upgrade seed pins existing accounts once and skips later ones", func() { - ran, err := s.SeedExisting(ctx, []string{"u1", "u2"}, SeedIDs) - Expect(err).ToNot(HaveOccurred()) - Expect(ran).To(BeTrue()) - for _, u := range []string{"u1", "u2"} { - got, _ := s.List(ctx, u) - Expect(idsOf(got)).To(Equal([]string{"studio", "talk"}), "account %s that existed at the upgrade must have both pins", u) - } - // An account created after the upgrade has no pins. - got, _ := s.List(ctx, "u3") - Expect(got).To(BeEmpty(), "a later account must start empty") - _ = s.Replace(ctx, "u1", []string{"talk"}) // u1 unpins studio - again, err := s.SeedExisting(ctx, []string{"u1", "u2", "u3"}, SeedIDs) - Expect(err).ToNot(HaveOccurred()) - Expect(again).To(BeFalse(), "the seed ran twice") - got, _ = s.List(ctx, "u1") - Expect(idsOf(got)).To(Equal([]string{"talk"}), "an unpin must stick") - got, _ = s.List(ctx, "u3") - Expect(got).To(BeEmpty(), "a second run must not seed a later account") + It("starts all accounts empty until explicit replacement", func() { + Expect(s.List(ctx, "u1")).To(BeEmpty()) + Expect(s.Replace(ctx, "u1", []string{"studio", "talk"})).To(Succeed()) + Expect(s.List(ctx, "u2")).To(BeEmpty()) + Expect(s.Replace(ctx, "u1", []string{"talk"})).To(Succeed()) + got, err := s.List(ctx, "u1") + Expect(err).NotTo(HaveOccurred()) + Expect(idsOf(got)).To(Equal([]string{"talk"})) }) - It("a deployment with no account still records the seed, so a later account starts empty", func() { - ran, err := s.SeedExisting(ctx, nil, SeedIDs) - Expect(err).ToNot(HaveOccurred()) - Expect(ran).To(BeTrue(), "an empty first run must count") - again, _ := s.SeedExisting(ctx, []string{"late"}, SeedIDs) - Expect(again).To(BeFalse(), "the seed must not run after the first call") - got, _ := s.List(ctx, "late") - Expect(got).To(BeEmpty(), "a new account must start with no pins") + It("keeps an explicit empty selection empty", func() { + Expect(s.Replace(ctx, "u1", []string{"studio"})).To(Succeed()) + Expect(s.Replace(ctx, "u1", []string{})).To(Succeed()) + Expect(s.List(ctx, "u1")).To(BeEmpty()) }) It("missing pins are marked, cleared and deleted after the window", func() { @@ -118,13 +103,16 @@ func registerStoreContract(open func() Store) { Expect(d).To(Equal([]string{"a", "b", "c"})) }) - It("records once that a seeded add-on was installed, apart from the seed run", func() { - Expect(s.Installed(ctx)).To(BeEmpty()) - Expect(s.MarkInstalled(ctx, []string{"talk", "studio"})).To(Succeed()) - Expect(s.MarkInstalled(ctx, []string{"studio"})).To(Succeed()) - Expect(s.Installed(ctx)).To(Equal([]string{"studio", "talk"})) - ran, err := s.SeedExisting(ctx, []string{"u1"}, SeedIDs) - Expect(err).ToNot(HaveOccurred()) - Expect(ran).To(BeTrue(), "an install record is not the seed run") + It("applies equal retention to never-installed IDs in every store", func() { + Expect(s.Replace(ctx, "u1", []string{"studio", "talk", "arbitrary"})).To(Succeed()) + now := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + Expect(Sweep(ctx, s, []string{}, now)).To(BeZero()) + got, err := s.List(ctx, "u1") + Expect(err).NotTo(HaveOccurred()) + for _, pin := range got { + Expect(pin.MissingSince).To(Equal(&now)) + } + Expect(Sweep(ctx, s, []string{}, now.Add(Retention))).To(BeZero()) + Expect(Sweep(ctx, s, []string{}, now.Add(Retention+time.Second))).To(BeEquivalentTo(3)) }) } diff --git a/core/services/addons/pins/store_file.go b/core/services/addons/pins/store_file.go index 6e3693f66..6f7b9ef9f 100644 --- a/core/services/addons/pins/store_file.go +++ b/core/services/addons/pins/store_file.go @@ -8,7 +8,6 @@ import ( "os" "path/filepath" "slices" - "strings" "sync" "time" ) @@ -23,12 +22,11 @@ type FileStore struct { } type fileState struct { - Pins map[string][]Pin `json:"pins"` - SeedRuns map[string]time.Time `json:"seedRuns"` + Pins map[string][]Pin `json:"pins"` } func NewFileStore(path string) (*FileStore, error) { - s := &FileStore{path: path, st: fileState{Pins: map[string][]Pin{}, SeedRuns: map[string]time.Time{}}} + s := &FileStore{path: path, st: fileState{Pins: map[string][]Pin{}}} if path == "" { return s, nil } @@ -45,9 +43,6 @@ func NewFileStore(path string) (*FileStore, error) { if s.st.Pins == nil { s.st.Pins = map[string][]Pin{} } - if s.st.SeedRuns == nil { - s.st.SeedRuns = map[string]time.Time{} - } return s, nil } @@ -97,29 +92,6 @@ func rebuild(old []Pin, userID string, ids []string) []Pin { return out } -func (s *FileStore) SeedExisting(_ context.Context, userIDs []string, ids []string) (bool, error) { - s.mu.Lock() - defer s.mu.Unlock() - if _, done := s.st.SeedRuns[UpgradeSeedName]; done { - return false, nil - } - for _, userID := range userIDs { - have := map[string]bool{} - list := s.st.Pins[userID] - for _, p := range list { - have[p.AddonID] = true - } - for _, id := range ids { - if !have[id] { - list = append(list, Pin{UserID: userID, AddonID: id, Position: len(list)}) - } - } - s.st.Pins[userID] = list - } - s.st.SeedRuns[UpgradeSeedName] = time.Now().UTC() - return true, s.save() -} - func (s *FileStore) Distinct(_ context.Context) ([]string, error) { s.mu.Lock() defer s.mu.Unlock() @@ -185,32 +157,3 @@ func (s *FileStore) DeleteMissingBefore(_ context.Context, cutoff time.Time) (in } return n, s.save() } - -func (s *FileStore) MarkInstalled(_ context.Context, ids []string) error { - s.mu.Lock() - defer s.mu.Unlock() - changed := false - for _, id := range ids { - if _, done := s.st.SeedRuns[installedPrefix+id]; !done { - s.st.SeedRuns[installedPrefix+id] = time.Now().UTC() - changed = true - } - } - if !changed { - return nil - } - return s.save() -} - -func (s *FileStore) Installed(_ context.Context) ([]string, error) { - s.mu.Lock() - defer s.mu.Unlock() - var out []string - for name := range s.st.SeedRuns { - if id, ok := strings.CutPrefix(name, installedPrefix); ok { - out = append(out, id) - } - } - slices.Sort(out) - return out, nil -} diff --git a/core/services/addons/pins/store_file_test.go b/core/services/addons/pins/store_file_test.go index 67f2f2364..d41ebfa94 100644 --- a/core/services/addons/pins/store_file_test.go +++ b/core/services/addons/pins/store_file_test.go @@ -31,15 +31,16 @@ var _ = Describe("File store", func() { ctx := context.Background() path := filepath.Join(GinkgoT().TempDir(), "pins.json") a, _ := NewFileStore(path) - ran, _ := a.SeedExisting(ctx, []string{"local-owner"}, SeedIDs) - Expect(ran).To(BeTrue(), "first seed must run") + Expect(a.Replace(ctx, "local-owner", []string{"studio", "talk"})).To(Succeed()) _ = a.Replace(ctx, "local-owner", []string{"talk"}) b, err := NewFileStore(path) Expect(err).ToNot(HaveOccurred()) got, _ := b.List(ctx, "local-owner") Expect(idsOf(got)).To(Equal([]string{"talk"}), "pins lost across a restart") - again, _ := b.SeedExisting(ctx, []string{"local-owner"}, SeedIDs) - Expect(again).To(BeFalse(), "the seed marker must survive a restart") + Expect(b.Replace(ctx, "local-owner", []string{})).To(Succeed()) + c, err := NewFileStore(path) + Expect(err).NotTo(HaveOccurred()) + Expect(c.List(ctx, "local-owner")).To(BeEmpty()) }) // The retention window of a removed add-on must not restart at each reopen. diff --git a/core/services/addons/pins/store_gorm.go b/core/services/addons/pins/store_gorm.go index b9cebb030..8d5fa2f83 100644 --- a/core/services/addons/pins/store_gorm.go +++ b/core/services/addons/pins/store_gorm.go @@ -4,13 +4,10 @@ package pins import ( "context" "fmt" - "slices" - "strings" "time" "github.com/mudler/LocalAI/core/services/advisorylock" "gorm.io/gorm" - "gorm.io/gorm/clause" ) // GormStore keeps the pins in the account database, which every frontend of a @@ -21,7 +18,7 @@ func NewGormStore(db *gorm.DB) (*GormStore, error) { // Several frontends migrate the shared database at startup: take the // schema-migrate lock like every other shared table does. if err := advisorylock.WithLockCtx(context.Background(), db, advisorylock.KeySchemaMigrate, func() error { - return db.AutoMigrate(&Pin{}, &SeedRun{}) + return db.AutoMigrate(&Pin{}) }); err != nil { return nil, fmt.Errorf("migrating addon pin tables: %w", err) } @@ -51,44 +48,6 @@ func (s *GormStore) Replace(ctx context.Context, userID string, ids []string) er }) } -// SeedExisting inserts the marker row first. Only the call that inserts it -// seeds, so several frontends that start together seed once between them. -func (s *GormStore) SeedExisting(ctx context.Context, userIDs []string, ids []string) (bool, error) { - ran := false - err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - res := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&SeedRun{Name: UpgradeSeedName, At: time.Now().UTC()}) - if res.Error != nil { - return res.Error - } - if res.RowsAffected == 0 { - return nil - } - ran = true - for _, userID := range userIDs { - var have []Pin - if err := tx.Where("user_id = ?", userID).Find(&have).Error; err != nil { - return err - } - held := map[string]bool{} - for _, p := range have { - held[p.AddonID] = true - } - next := len(have) - for _, id := range ids { - if held[id] { - continue - } - if err := tx.Create(&Pin{UserID: userID, AddonID: id, Position: next}).Error; err != nil { - return err - } - next++ - } - } - return nil - }) - return ran, err -} - func (s *GormStore) Distinct(ctx context.Context) ([]string, error) { var out []string err := s.db.WithContext(ctx).Model(&Pin{}).Distinct().Pluck("addon_id", &out).Error @@ -113,26 +72,3 @@ func (s *GormStore) DeleteMissingBefore(ctx context.Context, cutoff time.Time) ( res := s.db.WithContext(ctx).Where("missing_since IS NOT NULL AND missing_since < ?", cutoff).Delete(&Pin{}) return res.RowsAffected, res.Error } - -func (s *GormStore) MarkInstalled(ctx context.Context, ids []string) error { - for _, id := range ids { - err := s.db.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(&SeedRun{Name: installedPrefix + id, At: time.Now().UTC()}).Error - if err != nil { - return err - } - } - return nil -} - -func (s *GormStore) Installed(ctx context.Context) ([]string, error) { - var names []string - if err := s.db.WithContext(ctx).Model(&SeedRun{}).Where("name LIKE ?", installedPrefix+"%").Pluck("name", &names).Error; err != nil { - return nil, err - } - out := make([]string, 0, len(names)) - for _, n := range names { - out = append(out, strings.TrimPrefix(n, installedPrefix)) - } - slices.Sort(out) - return out, nil -} diff --git a/core/services/addons/pins/sweep.go b/core/services/addons/pins/sweep.go index c68207e54..6e4897123 100644 --- a/core/services/addons/pins/sweep.go +++ b/core/services/addons/pins/sweep.go @@ -11,28 +11,10 @@ import ( // right now: the built-in IDs and every installed add-on, enabled or not. A // nil present means the owner did not answer, and then nothing changes: an // outage must never expire a pin. -// -// A seeded pin (SeedIDs) is kept while its add-on was never installed: the -// upgrade pins Studio and Talk before an administrator installs them, and -// that may take longer than Retention. Once the add-on was present, its pin -// follows the rule like any other. func Sweep(ctx context.Context, store Store, present []string, now time.Time) (int64, error) { if present == nil { return 0, nil } - var seen []string - for _, id := range SeedIDs { - if slices.Contains(present, id) { - seen = append(seen, id) - } - } - if err := store.MarkInstalled(ctx, seen); err != nil { - return 0, err - } - installed, err := store.Installed(ctx) - if err != nil { - return 0, err - } pinned, err := store.Distinct(ctx) if err != nil { return 0, err @@ -42,10 +24,6 @@ func Sweep(ctx context.Context, store Store, present []string, now time.Time) (i switch { case slices.Contains(present, id): back = append(back, id) - case slices.Contains(SeedIDs, id) && !slices.Contains(installed, id): - // Not installed yet: no clock runs, and a mark an older sweep - // set is cleared. - back = append(back, id) default: gone = append(gone, id) } diff --git a/core/services/addons/pins/sweep_test.go b/core/services/addons/pins/sweep_test.go index 6fece9159..3f3589fda 100644 --- a/core/services/addons/pins/sweep_test.go +++ b/core/services/addons/pins/sweep_test.go @@ -20,6 +20,44 @@ var _ = Describe("Sweep", func() { s, _ = NewFileStore("") }) + It("keeps present IDs regardless of enablement and treats nil as a complete no-op", func() { + Expect(s.Replace(ctx, "u1", []string{"studio", "talk", "arbitrary"})).To(Succeed()) + now := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + Expect(s.MarkMissing(ctx, []string{"studio", "talk", "arbitrary"}, now)).To(Succeed()) + Expect(Sweep(ctx, s, nil, now.Add(2*Retention))).To(BeZero()) + got, err := s.List(ctx, "u1") + Expect(err).NotTo(HaveOccurred()) + Expect(got).To(HaveLen(3)) + for _, pin := range got { + Expect(pin.MissingSince).To(Equal(&now)) + } + // Presence includes installed but disabled add-ons; enablement is not an input. + Expect(Sweep(ctx, s, []string{"studio", "talk", "arbitrary"}, now.Add(2*Retention))).To(BeZero()) + got, err = s.List(ctx, "u1") + Expect(err).NotTo(HaveOccurred()) + Expect(got).To(HaveLen(3)) + for _, pin := range got { + Expect(pin.MissingSince).To(BeNil()) + } + }) + + It("applies equal retention to never-installed Studio Talk and arbitrary IDs", func() { + Expect(s.Replace(ctx, "u1", []string{"studio", "talk", "arbitrary"})).To(Succeed()) + now := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + n, err := Sweep(ctx, s, []string{}, now) + Expect(err).NotTo(HaveOccurred()) + Expect(n).To(BeZero()) + got, err := s.List(ctx, "u1") + Expect(err).NotTo(HaveOccurred()) + for _, pin := range got { + Expect(pin.MissingSince).To(Equal(&now), pin.AddonID) + } + n, err = Sweep(ctx, s, []string{}, now.Add(Retention+time.Second)) + Expect(err).NotTo(HaveOccurred()) + Expect(n).To(BeEquivalentTo(3)) + Expect(s.List(ctx, "u1")).To(BeEmpty()) + }) + It("keeps a removed pin for thirty days and restores it on reinstall", func() { _ = s.Replace(ctx, "u1", []string{"studio", "notes"}) t0 := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) @@ -54,46 +92,17 @@ var _ = Describe("Sweep", func() { }) }) -var _ = Describe("Sweep of the seeded pins", func() { - ctx := context.Background() - t0 := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) - day := 24 * time.Hour - - It("keeps a seeded pin while its add-on was never installed, then applies the rule", func() { - s, _ := NewFileStore("") - _, err := s.SeedExisting(ctx, []string{"u1"}, SeedIDs) - Expect(err).ToNot(HaveOccurred()) - // Ninety days with Studio and Talk not installed: nothing expires. - for d := 0; d <= 90; d += 10 { - n, err := Sweep(ctx, s, []string{"notes"}, t0.Add(time.Duration(d)*day)) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(BeZero(), "day %d", d) - } - got, _ := s.List(ctx, "u1") - Expect(idsOf(got)).To(Equal([]string{"studio", "talk"})) - for _, p := range got { - Expect(p.MissingSince).To(BeNil(), p.AddonID) - } - - // Studio is installed, then removed: from then on its pin expires - // after Retention. Talk, never installed, stays. - _, _ = Sweep(ctx, s, []string{"studio"}, t0.Add(100*day)) - Expect(s.Installed(ctx)).To(Equal([]string{"studio"})) - _, _ = Sweep(ctx, s, []string{"notes"}, t0.Add(101*day)) - n, _ := Sweep(ctx, s, []string{"notes"}, t0.Add(101*day).Add(Retention).Add(time.Hour)) - Expect(n).To(BeEquivalentTo(1)) - got, _ = s.List(ctx, "u1") - Expect(idsOf(got)).To(Equal([]string{"talk"})) - }) - - It("clears the mark an older sweep set on a seeded pin that was never installed", func() { - s, _ := NewFileStore("") - _ = s.Replace(ctx, "u1", []string{"talk"}) - Expect(s.MarkMissing(ctx, []string{"talk"}, t0)).To(Succeed()) - _, err := Sweep(ctx, s, []string{"notes"}, t0.Add(day)) - Expect(err).ToNot(HaveOccurred()) - got, _ := s.List(ctx, "u1") - Expect(got).To(HaveLen(1)) - Expect(got[0].MissingSince).To(BeNil()) +var _ = Describe("Sweep retention marks", func() { + It("preserves a never-installed pin's existing missing mark", func() { + ctx := context.Background() + s, err := NewFileStore("") + Expect(err).NotTo(HaveOccurred()) + now := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + Expect(s.Replace(ctx, "u1", []string{"talk"})).To(Succeed()) + Expect(s.MarkMissing(ctx, []string{"talk"}, now)).To(Succeed()) + Expect(Sweep(ctx, s, []string{}, now.Add(time.Hour))).To(BeZero()) + got, err := s.List(ctx, "u1") + Expect(err).NotTo(HaveOccurred()) + Expect(got[0].MissingSince).To(Equal(&now)) }) }) diff --git a/core/services/addons/recovery.go b/core/services/addons/recovery.go index 305fb03f5..3a81a0846 100644 --- a/core/services/addons/recovery.go +++ b/core/services/addons/recovery.go @@ -133,6 +133,9 @@ func (h *managerHost) Commit(ctx context.Context, candidate recovery.Candidate) } defer h.lock()() m := h.m + if e := m.admit(staged.Receipt()); e != nil { + return packages.PackageReceipt{}, e + } receipt, e := m.installer.Commit(ctx, staged) if e != nil { return packages.PackageReceipt{}, e @@ -152,6 +155,9 @@ func (h *managerHost) Commit(ctx context.Context, candidate recovery.Candidate) func (h *managerHost) StartCandidate(ctx context.Context, id string, receipt packages.PackageReceipt, number uint64) error { defer h.lock()() m := h.m + if e := m.admit(receipt); e != nil { + return e + } if m.runtime == nil { return nil } diff --git a/core/services/addons/runtime/OBSERVATIONS.md b/core/services/addons/runtime/OBSERVATIONS.md new file mode 100644 index 000000000..01d80ccb0 --- /dev/null +++ b/core/services/addons/runtime/OBSERVATIONS.md @@ -0,0 +1,105 @@ +# Host observation boundary (T8–T9) + +`(*Client).ReadObservation(ctx, envelope, requestID)` is the dedicated host-only +status read API. It returns only a validated `contract.ExecutionStatus`; errors +return unknown/unknown and never return raw results or output handles. The read +is bounded by the parent deadline, five seconds and the runtime read timeout. + +## Authenticated owner read (T9) + +`Gateway.ExecutionStatusRead(ctx, actor, request)` is the host-only owner API. +The versioned request carries the observation ID and every component of binding B: +authenticated actor, addon, generation, incarnation, execution tool, status tool, +conversation and current provider-selection identity. The response echoes that identity with validated status. +No request field carries arguments, grants, administrator rights, or an operation ID. +The owner generates the runtime operation ID independently of the observation ID. +The wire envelope uses `{"tool":"declared-status-tool"}` for actual status input `{}`. + +The owner requires `AuthorizeExecutionStatus` on every admission and before publishing a reply. +This host callback must check current selection, execution feature permission, +addon access, and authority to replace the builtin provider. An absent callback denies reads. +The addon admission checks resource-independent account access. The verified manifest +must bind the execution action to this exact resourceless, `uiOnly` status read. +`Manager.Runtime` also checks installed/enabled lifecycle and required host operations. +The status path does not call `ToolAdapter.Execute` or change its `uiOnly` exclusion. + +The owner captures one actual client. `Client.AdmitObservation(generation, incarnation)` +checks identity and accepting state under the same mutex that serializes withdrawal. +Its idempotent lease pins that client through dispatch and consumption without holding +manager or supervisor locks across network I/O. Observation leases do not consume the +ordinary request concurrency allowance. Withdrawal rejects later admissions and cancels +pending observations. A replacement never receives a request bound to the old client. +The owner checks current lifecycle, client identity, selection, and permission again +before returning a positive value. Errors return unknown/unknown. + +The existing authenticated mTLS carrier adds `execution-status-read-v1` as a typed call. +Its request and response DTOs have their own version 1; addon protocol v1 remains unchanged. +The carrier rejects mixed call variants, identity mismatches, unknown fields, duplicate +keys, and noncanonical keys. Transport capability support cannot grant selection authority. +Older owners without this call return unavailable. Reads have no retries or positive cache. + +## T10 integration responsibilities + +Generate the observation ID in the host request shell, never from addon or browser input. +Share one observation within that enclosing request. T9 does not implement coalescing +or the reducer shell. Cancel the parent context when the request closes or authority changes. +The owner and runtime independently cap each read at five seconds. +Recheck the current binding when consuming a returned observation, including after carrier delivery. +A reply can become stale after the owner's final check; it is never execution authorization. +Application integration must configure the owner callback from authoritative host selection +and permission state. Until then, the default path denies observations. + +The client records observation ownership in the authoritative in-flight entry. +The incarnation-authenticated broker checks it before dispatching any operation +class (including inbound), without modifying grants. A denied callback invalidates +the status reply. Outputs and unknown payload fields, including artifact handles, +are rejected without opening, adopting or publishing anything. Independently +admitted concurrent ordinary reads/actions retain their normal authority. + +Live records and cancellation registrations are removed idempotently on completion, +error, timeout, cancellation or withdrawal. Retired correlations are kept until +the incarnation's callback endpoint stops admission and all admitted HTTP +handlers drain. `http.Server.Close` alone does not wait for active handlers. +The supervisor stops admission, closes the server to cancel network requests, +drains admitted requests, then forgets correlations. Pending decoding requests +retain their denial. Dispatch admission is serialized with endpoint Stop for +all scopes, without holding a lifecycle mutex across a handler or drain. +Pre-Stop admissions may finish, but retirement cannot complete underneath them. +This prevents a late callback from recovering credential-only inbound authority. +Retention is bounded to 4096 records per InFlight index; observation admission +fails closed at capacity, rather than +evicting a denial and restoring authority. Ordinary work is not capacity-limited +by this observation index. A long-lived runtime reaching the limit needs restart +before further observations; no cross-request positive result cache exists. + +Stop never waits for handlers. Drain runs in the endpoint cleanup goroutine, +not in process termination or under a supervisor lock. A handler that ignores +cancellation can delay its endpoint's drain and reclamation indefinitely, but +cannot restore admission or block another endpoint. Retained correlations stay +within the same 4096-record bound; no global closed-incarnation tombstones are +created. The endpoint and its cleanup goroutine remain until its handlers exit. + +This is a host-mediated effect boundary, not a process sandbox or purity +attestation. An addon may still mutate its own memory/files or perform its own I/O. +Uncorrelated independently authorized inbound work remains ordinary inbound work; +correlation is not evidence about arbitrary process behavior. + +## Owner binding API (bounded transport milestone) + +`Gateway.BindingForActor(ctx, actor, ExecutionBindingClaim{ConversationID: id})` +accepts only a conversation claim. The optional `ResolveExecutionBinding` callback +runs on the owner and must load actor-owned authoritative context. A missing +resolver denies the request. `ValidateExecutionBinding` resolves the claim again +and compares every binding component. The optional ToolAdapter backend forwards +these host-only calls; it does not change discovery or ordinary execution. + +The authenticated carrier negotiates `execution-binding-v1`, uses version 1 +canonical-key DTOs, and rejects authority fields and mixed dispatch doors. +`conversationId` is optional in the older observation binding DTO for compatibility, +but is required for the new binding API. Binding lookup has a five-second budget. +Transport support is not execution authority or permission to observe. + +This milestone tests an injected owner-trusted resolver, including real mTLS. +It does not install an application resolver, instrument agent/conversation stores, +or connect request/session lifecycle hooks. Product binding remains denied until +an owner resolver can capture authoritative mutation revisions and eligibility. diff --git a/core/services/addons/runtime/client.go b/core/services/addons/runtime/client.go index 68277bda0..97bd69f8e 100644 --- a/core/services/addons/runtime/client.go +++ b/core/services/addons/runtime/client.go @@ -26,6 +26,8 @@ type Client struct { mu sync.Mutex accept bool active int + observationLeases int + observations map[string]context.CancelFunc endpoint, credential string registration Registration limits contract.Limits @@ -45,16 +47,23 @@ func newClient(r Registration, credential string, l contract.Limits) (*Client, e t := httpclient.HardenedTransport() t.Proxy = nil t.MaxResponseHeaderBytes = 16 << 10 - return &Client{endpoint: r.Endpoint, credential: credential, registration: r, limits: l, http: httpclient.New(httpclient.WithTransport(t))}, nil + return &Client{inflight: hostops.NewInFlight(), endpoint: r.Endpoint, credential: credential, registration: r, limits: l, http: httpclient.New(httpclient.WithTransport(t))}, nil } func (c *Client) admitting() bool { c.mu.Lock(); defer c.mu.Unlock(); return c.accept } -func (c *Client) withdraw() { c.mu.Lock(); c.accept = false; c.mu.Unlock() } +func (c *Client) withdraw() { + c.mu.Lock() + defer c.mu.Unlock() + c.accept = false + for _, cancel := range c.observations { + cancel() + } +} func (c *Client) drain(ctx context.Context) { ticker := time.NewTicker(time.Millisecond) defer ticker.Stop() for { c.mu.Lock() - n := c.active + n := c.active + c.observationLeases c.mu.Unlock() if n == 0 { return @@ -185,9 +194,9 @@ func (c *Client) begin(ctx context.Context, env *contract.Envelope) func() { return func() {} } if b, ok := hostops.BindingOf(ctx); ok { - return c.inflight.BeginBound(*env, b) + return c.inflight.BeginRuntime(*env, b, true, c.registration.Incarnation) } - return c.inflight.Begin(*env) + return c.inflight.BeginRuntime(*env, hostops.Binding{}, false, c.registration.Incarnation) } func (c *Client) call(ctx context.Context, path string, env *contract.Envelope, in, out any, timeout time.Duration) error { done, e := c.admit() diff --git a/core/services/addons/runtime/extensions_test.go b/core/services/addons/runtime/extensions_test.go new file mode 100644 index 000000000..e2be183c3 --- /dev/null +++ b/core/services/addons/runtime/extensions_test.go @@ -0,0 +1,134 @@ +//go:build linux + +// SPDX-License-Identifier: MIT +package runtime + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "time" + + "github.com/mudler/LocalAI/core/services/addons/packages" + "github.com/mudler/LocalAI/pkg/addons/contract" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func typedCapabilities() contract.Capabilities { + return contract.Capabilities{Service: true, Actions: true, Tools: []string{"run", "health"}, 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}`)}}, UI: &contract.UI{Entry: "ui.js", SDK: contract.VersionRange{Min: 2, Max: 2}, Pages: []string{"home"}, MainPage: "home", HomeCommands: []contract.HomeCommand{{ID: "open"}}, ModelActions: []contract.ModelAction{{Capability: "FLAG_IMAGE", Page: "home", Path: "images"}}}, ExecutionProvider: &contract.ExecutionProvider{Tools: []string{"run"}, StatusTool: "health"}, HostOperations: &contract.HostOperations{Required: []string{"workspace.materialize"}, Optional: []string{"egress.check"}}} +} + +var _ = Describe("typed runtime declarations", func() { + It("requires exact typed capability equality on authenticated registration health", func() { + original := typedCapabilities() + for _, change := range []func(*contract.Capabilities){func(c *contract.Capabilities) { c.UI.ModelActions[0].Path = "changed" }, func(c *contract.Capabilities) { c.UI.ModelActions = nil }, func(c *contract.Capabilities) { c.UI.HomeCommands[0].ID = "changed" }, func(c *contract.Capabilities) { c.ExecutionProvider.StatusTool = "other" }, func(c *contract.Capabilities) { c.HostOperations.Required = []string{"other.op"} }, func(c *contract.Capabilities) { c.UI.HomeCommands = nil }, func(c *contract.Capabilities) { c.ExecutionProvider = nil }, func(c *contract.Capabilities) { c.HostOperations = nil }} { + var echo Registration + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer GinkgoRecover() + Expect(r.Header.Get("Authorization")).To(Equal("Bearer " + strings.Repeat("a", 64))) + Expect(r.Header.Get("X-LocalAI-Incarnation")).To(Equal("inc")) + Expect(json.NewEncoder(w).Encode(echo)).To(Succeed()) + })) + expected := Registration{AddonID: "sample", Generation: 7, Incarnation: "inc", Protocol: 1, Endpoint: srv.URL, Capabilities: original} + client, e := newClient(expected, strings.Repeat("a", 64), contract.DefaultLimits()) + Expect(e).NotTo(HaveOccurred()) + echo = expected + Expect(client.health(context.Background(), expected)).To(Succeed()) + b, e := json.Marshal(expected) + Expect(e).NotTo(HaveOccurred()) + Expect(json.Unmarshal(b, &echo)).To(Succeed()) + change(&echo.Capabilities) + Expect(client.health(context.Background(), expected)).To(MatchError(ErrRuntime)) + srv.Close() + } + }) + It("keeps all typed declarations on automatic crash restart with a fresh incarnation", func() { + s := supervisor() + caps := typedCapabilities() + i, e := s.Start(context.Background(), fixture("normal", caps), 9) + Expect(e).NotTo(HaveOccurred()) + Expect(i.process.cmd.Process.Kill()).To(Succeed()) + Eventually(func() string { + regs, e := s.Registry.Snapshot(context.Background()) + if e != nil || len(regs) != 1 { + return "" + } + return regs[0].Incarnation + }, 3*time.Second).Should(And(Not(BeEmpty()), Not(Equal(i.Incarnation)))) + regs, e := s.Registry.Snapshot(context.Background()) + Expect(e).NotTo(HaveOccurred()) + Expect(regs).To(HaveLen(1)) + Expect(regs[0].Capabilities).To(Equal(caps)) + regs[0].Capabilities.UI.ModelActions[0].Path = "mutated" + fresh, e := s.Registry.Snapshot(context.Background()) + Expect(e).NotTo(HaveOccurred()) + Expect(fresh[0].Capabilities).To(Equal(caps)) + Expect(regs[0].Generation).To(Equal(uint64(9))) + current, e := s.Current(context.Background(), "sample", 9) + Expect(e).NotTo(HaveOccurred()) + Expect(current.Client.(*Client).health(context.Background(), fresh[0])).To(Succeed()) + }) + + It("replays typed and legacy receipts across process restarts", func() { + s := supervisor() + caps := typedCapabilities() + prior := fixture("normal", caps) + for _, p := range []packages.PackageReceipt{prior, fixture("normal", service), prior} { + b, e := os.ReadFile(filepath.Join(p.Directory, "receipt.json")) + Expect(e).NotTo(HaveOccurred()) + var loaded packages.PackageReceipt + Expect(json.Unmarshal(b, &loaded)).To(Succeed()) + Expect(loaded).To(Equal(p)) + i, e := s.Start(context.Background(), loaded, 7) + Expect(e).NotTo(HaveOccurred()) + first := i.Incarnation + regs, e := s.Registry.Snapshot(context.Background()) + Expect(e).NotTo(HaveOccurred()) + Expect(regs).To(HaveLen(1)) + Expect(regs[0].Capabilities).To(Equal(p.Manifest.Capabilities)) + Expect(regs[0].Generation).To(Equal(uint64(7))) + Expect(s.Stop(context.Background(), i)).To(Succeed()) + i, e = s.Start(context.Background(), loaded, 7) + Expect(e).NotTo(HaveOccurred()) + Expect(i.Incarnation).NotTo(Equal(first)) + regs, e = s.Registry.Snapshot(context.Background()) + Expect(e).NotTo(HaveOccurred()) + Expect(regs[0].Capabilities).To(Equal(p.Manifest.Capabilities)) + Expect(s.Stop(context.Background(), i)).To(Succeed()) + } + }) +}) + +var _ = Describe("supervisor observation capture ordering", func() { + It("cannot observe a restarted same-generation process through the captured client", func() { + ctx := context.Background() + s := supervisor() + p := fixture("normal", typedCapabilities()) + old, e := s.Start(ctx, p, 9) + Expect(e).NotTo(HaveOccurred()) + captured, e := s.Current(ctx, "sample", 9) + Expect(e).NotTo(HaveOccurred()) + client := captured.Client.(*Client) + release, e := client.AdmitObservation(captured.Generation, captured.Incarnation) + Expect(e).NotTo(HaveOccurred()) + release() + Expect(s.Stop(ctx, old)).To(Succeed()) + replacement, e := s.Start(ctx, p, 9) + Expect(e).NotTo(HaveOccurred()) + Expect(replacement.Incarnation).NotTo(Equal(captured.Incarnation)) + Expect(replacement.Client).NotTo(BeIdenticalTo(captured.Client)) + _, e = client.AdmitObservation(9, captured.Incarnation) + Expect(e).To(HaveOccurred()) + env := contract.Envelope{Request: contract.Request{AddonID: "sample", Generation: 9, OperationID: "old-observation", Action: "read", Arguments: json.RawMessage(`{"tool":"health"}`)}, Authority: contract.Authority{Actor: contract.Actor{UserID: "u", CredentialID: "k", Mode: "named-user"}, Scope: "read", ExpiresAt: time.Now().Add(time.Second)}} + value, e := client.ReadObservation(ctx, env, "q") + Expect(e).To(MatchError(ErrNotSent)) + Expect(value.Readiness).To(Equal("unknown")) + _, e = replacement.Client.(*Client).AdmitObservation(9, captured.Incarnation) + Expect(e).To(HaveOccurred()) + }) +}) diff --git a/core/services/addons/runtime/observation.go b/core/services/addons/runtime/observation.go new file mode 100644 index 000000000..35693dfc2 --- /dev/null +++ b/core/services/addons/runtime/observation.go @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: MIT +package runtime + +import ( + "bytes" + "context" + "encoding/json" + "sync" + "time" + + "github.com/mudler/LocalAI/core/services/addons/hostops" + "github.com/mudler/LocalAI/pkg/addons/contract" +) + +// ReadObservation is the host-only status read boundary. The owner must first +// authorize the actor/tool and capture this exact Client, generation and +// incarnation. It does not grant access to uiOnly tools or admit a provider. +func (c *Client) ReadObservation(ctx context.Context, env contract.Envelope, requestID string) (contract.ExecutionStatus, error) { + unknown := contract.ExecutionStatus{Readiness: "unknown", Isolation: "unknown"} + // This narrow host path accepts only the named-tool envelope for actual {} input. + var args map[string]string + if json.Unmarshal(env.Request.Arguments, &args) != nil || len(args) != 1 || !contract.ValidName(args["tool"]) { + return unknown, ErrNotSent + } + canonical, _ := json.Marshal(args) + if !bytes.Equal(bytes.TrimSpace(env.Request.Arguments), canonical) { + return unknown, ErrNotSent + } + if requestID == "" || len(requestID) > 256 || c.inflight == nil || c.valid(env, "read") != nil { + return unknown, ErrNotSent + } + done, err := c.admit() + if err != nil { + return unknown, err + } + defer done() + binding, _ := hostops.BindingOf(ctx) + end, denied, err := c.inflight.BeginObservation(env, binding, c.registration.Incarnation, requestID) + if err != nil { + return unknown, ErrNotSent + } + defer end() + ctx, cancel := context.WithTimeout(ctx, min(5*time.Second, c.limits.ReadTimeout)) + defer cancel() + c.mu.Lock() + if !c.accept { + c.mu.Unlock() + return unknown, ErrNotSent + } + if c.observations == nil { + c.observations = map[string]context.CancelFunc{} + } + c.observations[env.Request.OperationID] = cancel + c.mu.Unlock() + defer func() { c.mu.Lock(); delete(c.observations, env.Request.OperationID); c.mu.Unlock() }() + resp, err := c.send(ctx, "/read", env) + if err != nil { + return unknown, err + } + // Read-only cleanup: close errors cannot change the decoded result. + defer func() { _ = resp.Body.Close() }() + var result contract.Result + if err = decode(resp.Body, c.limits.EnvelopeBytes, &result); err != nil { + return unknown, err + } + if denied() || len(result.Outputs) != 0 || result.Validate(c.limits) != nil || !c.admitting() { + return unknown, ErrRuntime + } + value, err := contract.DecodeExecutionStatus(result.Payload) + if err != nil { + return unknown, err + } + return value, nil +} + +// AdmitObservation checks the immutable identity and withdrawal state atomically. +// The returned lease only pins this client; it never re-resolves a replacement. +// Withdrawal cancels pending reads and prevents subsequent dispatch admission. +func (c *Client) AdmitObservation(generation uint64, incarnation string) (func(), error) { + c.mu.Lock() + defer c.mu.Unlock() + if !c.accept || c.registration.Generation != generation || c.registration.Incarnation != incarnation { + return nil, ErrNotSent + } + c.observationLeases++ + var once sync.Once + return func() { once.Do(func() { c.mu.Lock(); c.observationLeases--; c.mu.Unlock() }) }, nil +} diff --git a/core/services/addons/runtime/observation_admission_test.go b/core/services/addons/runtime/observation_admission_test.go new file mode 100644 index 000000000..e5b4e492e --- /dev/null +++ b/core/services/addons/runtime/observation_admission_test.go @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MIT +package runtime + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "time" + + "github.com/mudler/LocalAI/pkg/addons/contract" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("exact observation client admission", func() { + It("refuses missing wire tool discriminators before any read", func() { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + _, err := io.WriteString(w, `{"payload":{"readiness":"available","isolation":"isolated"}}`) + Expect(err).NotTo(HaveOccurred()) + })) + defer srv.Close() + c, e := newClient(Registration{AddonID: "sample", Generation: 1, Incarnation: "old", Endpoint: srv.URL}, strings.Repeat("a", 64), contract.DefaultLimits()) + Expect(e).NotTo(HaveOccurred()) + c.accept = true + env := contract.Envelope{Request: contract.Request{AddonID: "sample", Generation: 1, Action: "read", OperationID: "op", Arguments: json.RawMessage(`{}`)}, Authority: contract.Authority{Actor: contract.Actor{UserID: "u", CredentialID: "k", Mode: "named-user"}, Scope: "read", ExpiresAt: time.Now().Add(time.Second)}} + _, e = c.ReadObservation(context.Background(), env, "q") + Expect(e).To(HaveOccurred()) + Expect(calls.Load()).To(BeZero()) + }) + It("serializes exact identity admission with withdrawal and never dispatches the replacement", func() { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + _, err := io.WriteString(w, `{"payload":{"readiness":"available","isolation":"isolated"}}`) + Expect(err).NotTo(HaveOccurred()) + })) + defer srv.Close() + old, e := newClient(Registration{AddonID: "sample", Generation: 1, Incarnation: "old", Endpoint: srv.URL}, strings.Repeat("a", 64), contract.DefaultLimits()) + Expect(e).NotTo(HaveOccurred()) + old.accept = true + replacement, e := newClient(Registration{AddonID: "sample", Generation: 1, Incarnation: "new", Endpoint: srv.URL}, strings.Repeat("b", 64), contract.DefaultLimits()) + Expect(e).NotTo(HaveOccurred()) + replacement.accept = true + _, e = old.AdmitObservation(2, "old") + Expect(e).To(HaveOccurred()) + _, e = replacement.AdmitObservation(1, "old") + Expect(e).To(HaveOccurred()) + release, e := old.AdmitObservation(1, "old") + Expect(e).NotTo(HaveOccurred()) + old.withdraw() // deterministic capture-to-dispatch restart boundary + env := contract.Envelope{Request: contract.Request{AddonID: "sample", Generation: 1, Action: "read", OperationID: "op", Arguments: json.RawMessage(`{"tool":"health"}`)}, Authority: contract.Authority{Actor: contract.Actor{UserID: "u", CredentialID: "k", Mode: "named-user"}, Scope: "read", ExpiresAt: time.Now().Add(time.Second)}} + _, e = old.ReadObservation(context.Background(), env, "q") + Expect(e).To(HaveOccurred()) + release() + release() + Expect(old.active).To(BeZero()) + Expect(calls.Load()).To(BeZero()) + _, e = old.AdmitObservation(1, "old") + Expect(e).To(HaveOccurred()) + }) +}) diff --git a/core/services/addons/runtime/observation_test.go b/core/services/addons/runtime/observation_test.go new file mode 100644 index 000000000..7f4559a01 --- /dev/null +++ b/core/services/addons/runtime/observation_test.go @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: MIT +package runtime + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/mudler/LocalAI/core/services/addons/hostops" + "github.com/mudler/LocalAI/pkg/addons/contract" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Host-only observation read", func() { + makeClient := func(handler http.HandlerFunc) (*Client, *hostops.Broker) { + srv := httptest.NewServer(handler) + DeferCleanup(srv.Close) + c, err := newClient(Registration{AddonID: "sample", Generation: 1, Incarnation: "inc", Endpoint: srv.URL}, strings.Repeat("a", 64), contract.DefaultLimits()) + Expect(err).NotTo(HaveOccurred()) + c.accept = true + c.inflight = hostops.NewInFlight() + b := hostops.New(c.inflight, func(string, string) bool { return true }, contract.DefaultLimits()) + return c, b + } + env := func() contract.Envelope { + return contract.Envelope{Request: contract.Request{AddonID: "sample", Generation: 1, OperationID: "status", Action: "read", Arguments: json.RawMessage(`{"tool":"status"}`)}, Authority: contract.Authority{Actor: contract.Actor{UserID: "alice", CredentialID: "key", Mode: "named-user"}, Scope: "read", ExpiresAt: time.Now().Add(time.Minute)}} + } + It("rejects output handles before they can reach storage or output-open", func() { + var opens atomic.Int32 + c, _ := makeClient(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/output" { + opens.Add(1) + } + _, err := io.WriteString(w, `{"payload":{"readiness":"available","isolation":"isolated"},"outputs":[{"handle":"evil","name":"evil.txt","mediaType":"text/plain","size":1}]}`) + Expect(err).NotTo(HaveOccurred()) + }) + value, err := c.ReadObservation(context.Background(), env(), "q") + Expect(err).To(HaveOccurred()) + Expect(value.Readiness).NotTo(Equal("available")) + Expect(opens.Load()).To(BeZero()) + ordinary := env() + ordinary.Request.OperationID = "ordinary" + r, err := c.Read(context.Background(), ordinary) + Expect(err).NotTo(HaveOccurred()) + Expect(r.Outputs).To(HaveLen(1)) + }) + It("settles callback denial as failure even if the addon returns a valid positive payload", func() { + var callback http.Handler + var code atomic.Int32 + c, b := makeClient(func(w http.ResponseWriter, r *http.Request) { + req := httptest.NewRequest("POST", "/host", strings.NewReader(`{"operationId":"status","op":"connector.inbound","arguments":{}}`)) + req.Header.Set(contract.HostCredentialHeader, "secret") + rec := httptest.NewRecorder() + callback.ServeHTTP(rec, req) + code.Store(int32(rec.Code)) + _, err := io.WriteString(w, `{"payload":{"readiness":"available","isolation":"isolated"}}`) + Expect(err).NotTo(HaveOccurred()) + }) + var calls atomic.Int32 + Expect(b.Register(hostops.Spec{Op: "connector.inbound", Scope: hostops.ScopeInbound, Handler: func(context.Context, hostops.Call) (json.RawMessage, error) { + calls.Add(1) + return json.RawMessage(`{}`), nil + }})).To(Succeed()) + callback = b.HandlerForRuntime("sample", "secret", []string{"connector."}, 1, "inc") + _, err := c.ReadObservation(context.Background(), env(), "q") + Expect(err).To(HaveOccurred()) + Expect(code.Load()).To(Equal(int32(403))) + Expect(calls.Load()).To(BeZero()) + _, live := c.inflight.Lookup("sample", "status") + Expect(live).To(BeFalse()) + }) + It("cleans up after cancellation and rejects repeat correlation", func() { + entered := make(chan struct{}) + release := make(chan struct{}) + var once sync.Once + c, _ := makeClient(func(w http.ResponseWriter, r *http.Request) { once.Do(func() { close(entered) }); <-release }) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { _, err := c.ReadObservation(ctx, env(), "q"); done <- err }() + <-entered + cancel() + Expect(<-done).To(HaveOccurred()) + close(release) + _, live := c.inflight.Lookup("sample", "status") + Expect(live).To(BeFalse()) + _, err := c.ReadObservation(context.Background(), env(), "q2") + Expect(err).To(HaveOccurred()) + }) + It("keeps an ordinary concurrent read authorized while an observation is pending", func() { + entered := make(chan struct{}) + release := make(chan struct{}) + var statusCode atomic.Int32 + var callback http.Handler + c, b := makeClient(func(w http.ResponseWriter, r *http.Request) { + var e contract.Envelope + _ = json.NewDecoder(r.Body).Decode(&e) + if e.Request.OperationID == "status" { + close(entered) + <-release + } else { + req := httptest.NewRequest("POST", "/host", strings.NewReader(`{"operationId":"ordinary","op":"workspace.export","arguments":{}}`)) + req.Header.Set(contract.HostCredentialHeader, "secret") + rec := httptest.NewRecorder() + callback.ServeHTTP(rec, req) + statusCode.Store(int32(rec.Code)) + } + _, err := io.WriteString(w, `{"payload":{"readiness":"available","isolation":"isolated"}}`) + Expect(err).NotTo(HaveOccurred()) + }) + Expect(b.Register(hostops.Spec{Op: "workspace.export", Scope: "read", Feature: "chats", Handler: func(context.Context, hostops.Call) (json.RawMessage, error) { return json.RawMessage(`{}`), nil }})).To(Succeed()) + callback = b.HandlerForRuntime("sample", "secret", []string{"workspace."}, 1, "inc") + done := make(chan error, 1) + go func() { _, err := c.ReadObservation(context.Background(), env(), "q"); done <- err }() + <-entered + ordinary := env() + ordinary.Request.OperationID = "ordinary" + _, err := c.Read(context.Background(), ordinary) + close(release) + Expect(err).NotTo(HaveOccurred()) + Expect(<-done).NotTo(HaveOccurred()) + Expect(statusCode.Load()).To(Equal(int32(200))) + }) + DescribeTable("cleans up every settled path", func(mode string) { + c, _ := makeClient(func(w http.ResponseWriter, r *http.Request) { + switch mode { + case "error": + http.Error(w, "failed", 503) + case "timeout": + time.Sleep(50 * time.Millisecond) + case "malformed": + _, err := io.WriteString(w, `{"payload":{"artifact":"evil"}}`) + Expect(err).NotTo(HaveOccurred()) + default: + _, err := io.WriteString(w, `{"payload":{"readiness":"available","isolation":"isolated"}}`) + Expect(err).NotTo(HaveOccurred()) + } + }) + ctx := context.Background() + if mode == "timeout" { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, 20*time.Millisecond) + defer cancel() + } + _, err := c.ReadObservation(ctx, env(), "q") + if mode == "success" { + Expect(err).NotTo(HaveOccurred()) + } else { + Expect(err).To(HaveOccurred()) + } + _, live := c.inflight.Lookup("sample", "status") + Expect(live).To(BeFalse()) + }, Entry("success", "success"), Entry("error", "error"), Entry("timeout", "timeout"), Entry("malformed/artifact payload", "malformed")) + + It("cancels an admitted observation when the runtime withdraws", func() { + entered := make(chan struct{}) + release := make(chan struct{}) + c, _ := makeClient(func(w http.ResponseWriter, r *http.Request) { close(entered); <-release }) + done := make(chan error, 1) + go func() { _, err := c.ReadObservation(context.Background(), env(), "q"); done <- err }() + <-entered + c.withdraw() + select { + case err := <-done: + Expect(err).To(HaveOccurred()) + case <-time.After(100 * time.Millisecond): + close(release) + Fail("withdrawal did not cancel observation") + return + } + close(release) + _, live := c.inflight.Lookup("sample", "status") + Expect(live).To(BeFalse()) + }) + +}) diff --git a/core/services/addons/runtime/supervisor.go b/core/services/addons/runtime/supervisor.go index 701c71946..5659131ce 100644 --- a/core/services/addons/runtime/supervisor.go +++ b/core/services/addons/runtime/supervisor.go @@ -492,12 +492,19 @@ func (s *Supervisor) launch(ctx context.Context, pkg packages.PackageReceipt, ge } // serveHostOps serves the host operation channel of one incarnation and -// closes it when the process ends. terminate waits for the process, so the -// endpoint never outlives it. +// stops admission when the process ends. Cleanup drains active requests before +// releasing correlations; a handler ignoring cancellation cannot block terminate. func (s *Supervisor) serveHostOps(p *process, l net.Listener, id, credential string, prefixes []string) { - srv := &http.Server{Handler: s.config.HostOps.Broker.Handler(id, credential, prefixes), ReadHeaderTimeout: 5 * time.Second, MaxHeaderBytes: 16 << 10} + endpoint := s.config.HostOps.Broker.EndpointForRuntime(id, credential, prefixes, p.registration.Generation, p.registration.Incarnation) + srv := &http.Server{Handler: endpoint, ReadHeaderTimeout: 5 * time.Second, MaxHeaderBytes: 16 << 10} go func() { _ = srv.Serve(l) }() - go func() { <-p.done; _ = srv.Close() }() + go func() { + <-p.done + endpoint.Stop() + _ = srv.Close() + endpoint.Drain() + s.config.HostOps.InFlight.ForgetRuntime(id, p.registration.Incarnation) + }() } func (s *Supervisor) terminate(p *process) { diff --git a/core/services/addons/runtime/supervisor_test.go b/core/services/addons/runtime/supervisor_test.go index 5280a6a17..90de5ef4d 100644 --- a/core/services/addons/runtime/supervisor_test.go +++ b/core/services/addons/runtime/supervisor_test.go @@ -729,7 +729,7 @@ var _ = Describe("Addon data directory", func() { } Expect(call("wrong")).To(Equal(http.StatusUnauthorized)) Expect(call(credential)).To(Equal(http.StatusForbidden)) - end := in.Begin(contract.Envelope{Request: contract.Request{AddonID: "sample", OperationID: "op1", Generation: 1, Action: "a", Arguments: json.RawMessage(`{}`)}, Authority: contract.Authority{Actor: contract.Actor{UserID: "u", CredentialID: "c", Mode: "named-user"}, Scope: "read", ExpiresAt: time.Now().Add(time.Minute)}}) + end := in.BeginRuntime(contract.Envelope{Request: contract.Request{AddonID: "sample", OperationID: "op1", Generation: 1, Action: "a", Arguments: json.RawMessage(`{}`)}, Authority: contract.Authority{Actor: contract.Actor{UserID: "u", CredentialID: "c", Mode: "named-user"}, Scope: "read", ExpiresAt: time.Now().Add(time.Minute)}}, hostops.Binding{}, false, inst.Incarnation) Expect(call(credential)).To(Equal(http.StatusOK)) end() Expect(call(credential)).To(Equal(http.StatusForbidden)) diff --git a/core/services/addons/state.go b/core/services/addons/state.go index b4587e2d1..022dede46 100644 --- a/core/services/addons/state.go +++ b/core/services/addons/state.go @@ -1,7 +1,10 @@ // SPDX-License-Identifier: MIT package addons -import "github.com/mudler/LocalAI/core/services/addons/packages" +import ( + "github.com/mudler/LocalAI/core/services/addons/hostops" + "github.com/mudler/LocalAI/core/services/addons/packages" +) // lifecyclePhase is shared by the owner journal and upgrade reducer. The // operation DTO retains string fields for compatibility with existing callers. @@ -54,7 +57,8 @@ type OperationStatus struct { OperationID, AddonID, Phase string Generation uint64 // RuntimeState is an observation, never a durable execution receipt. - Stage string `json:"stage,omitempty"` - FailureCode string `json:"failureCode,omitempty"` - RuntimeState string `json:"runtimeState,omitempty"` + Stage string `json:"stage,omitempty"` + FailureCode string `json:"failureCode,omitempty"` + RuntimeState string `json:"runtimeState,omitempty"` + HostOperations *hostops.RequestSummary `json:"hostOperations,omitempty"` } diff --git a/core/services/addons/tools/adapter.go b/core/services/addons/tools/adapter.go index 2e586591c..628aa873b 100644 --- a/core/services/addons/tools/adapter.go +++ b/core/services/addons/tools/adapter.go @@ -25,7 +25,6 @@ import ( "encoding/json" "errors" "fmt" - "regexp" "slices" "strings" "time" @@ -317,18 +316,31 @@ func randomOperationID() (string, error) { return "t" + hex.EncodeToString(raw[:]), nil } -var toolName = regexp.MustCompile(`^addon__([a-z][a-z0-9-]{0,63})__([a-z][a-z0-9-]{0,63})$`) - -// ToolName is the function name of an add-on tool. +// ToolName formats a qualified identity. Callers validate components and, for +// model-callable tools, the total length with ParseToolName. func ToolName(addonID, tool string) string { return Prefix + addonID + "__" + tool } -// ParseToolName splits a namespaced name. It accepts nothing else. -func ParseToolName(name string) (addonID, tool string, ok bool) { - m := toolName.FindStringSubmatch(name) - if m == nil || len(name) > MaxNameLength { +// ParseQualifiedToolName splits an add-on tool identity with valid contract +// components. It does not apply the model's total name limit: Studio stand-ins +// identify unavailable add-ons, including long IDs, rather than dispatch tools. +func ParseQualifiedToolName(name string) (addonID, tool string, ok bool) { + qualified, ok := strings.CutPrefix(name, Prefix) + if !ok { return "", "", false } - return m[1], m[2], true + addonID, tool, ok = strings.Cut(qualified, "__") + if !ok || !contract.ValidName(addonID) || !contract.ValidName(tool) { + return "", "", false + } + return addonID, tool, true +} + +// ParseToolName splits a model-callable name, enforcing its total length limit. +func ParseToolName(name string) (addonID, tool string, ok bool) { + if len(name) > MaxNameLength { + return "", "", false + } + return ParseQualifiedToolName(name) } // IsToolName reports whether name is in the namespace reserved for add-on diff --git a/core/services/addons/tools/binding.go b/core/services/addons/tools/binding.go new file mode 100644 index 000000000..ba3d48147 --- /dev/null +++ b/core/services/addons/tools/binding.go @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT +package tools + +import ( + "context" + + "github.com/mudler/LocalAI/core/services/addons/gateway" + "github.com/mudler/LocalAI/pkg/addons/contract" +) + +// ExecutionBindingBackend is an optional host-only door. Ordinary Backend +// implementations and model tool execution do not gain binding authority. +type ExecutionBindingBackend interface { + BindingForActor(context.Context, contract.Actor, gateway.ExecutionBindingClaim) (gateway.ExecutionStatusBinding, error) + ValidateExecutionBinding(context.Context, contract.Actor, gateway.ExecutionStatusBinding) error +} + +// BindingForActor forwards only an authenticated actor and conversation claim. +// Discovery results, model arguments and frontend permissions are not inputs. +func (t *ToolAdapter) BindingForActor(ctx context.Context, a contract.Actor, c gateway.ExecutionBindingClaim) (gateway.ExecutionStatusBinding, error) { + b, ok := t.backend.(ExecutionBindingBackend) + if !ok { + return gateway.ExecutionStatusBinding{}, gateway.ErrForbidden + } + return b.BindingForActor(ctx, a, c) +} + +func (t *ToolAdapter) ValidateExecutionBinding(ctx context.Context, a contract.Actor, b gateway.ExecutionStatusBinding) error { + owner, ok := t.backend.(ExecutionBindingBackend) + if !ok { + return gateway.ErrForbidden + } + return owner.ValidateExecutionBinding(ctx, a, b) +} + +// ExecutionStatusRead is optional and never falls back to ordinary tool dispatch. +func (t *ToolAdapter) ExecutionStatusRead(ctx context.Context, a contract.Actor, r gateway.ExecutionStatusRequest) (gateway.ExecutionStatusResponse, error) { + owner, ok := t.backend.(interface { + ExecutionStatusRead(context.Context, contract.Actor, gateway.ExecutionStatusRequest) (gateway.ExecutionStatusResponse, error) + }) + if !ok { + return gateway.ExecutionStatusResponse{}, gateway.ErrForbidden + } + return owner.ExecutionStatusRead(ctx, a, r) +} diff --git a/core/services/addons/tools/binding_test.go b/core/services/addons/tools/binding_test.go new file mode 100644 index 000000000..6659fdab1 --- /dev/null +++ b/core/services/addons/tools/binding_test.go @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT +package tools_test + +import ( + "context" + "github.com/mudler/LocalAI/core/services/addons/gateway" + "github.com/mudler/LocalAI/core/services/addons/tools" + "github.com/mudler/LocalAI/pkg/addons/contract" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type bindingBackend struct { + fake + actor contract.Actor + claim gateway.ExecutionBindingClaim + binding gateway.ExecutionStatusBinding +} + +func (b *bindingBackend) BindingForActor(_ context.Context, a contract.Actor, c gateway.ExecutionBindingClaim) (gateway.ExecutionStatusBinding, error) { + b.actor = a + b.claim = c + return b.binding, nil +} +func (b *bindingBackend) ValidateExecutionBinding(_ context.Context, a contract.Actor, v gateway.ExecutionStatusBinding) error { + b.actor = a + if v != b.binding { + return gateway.ErrForbidden + } + return nil +} + +var _ = Describe("optional execution binding backend", func() { + It("forwards bindings only through an explicit binding backend", func() { + ctx := context.Background() + actor := contract.Actor{UserID: "u", CredentialID: "c", Mode: "named-user"} + claim := gateway.ExecutionBindingClaim{ConversationID: "chat"} + adapter := tools.NewToolAdapter(&fake{}) + _, err := adapter.BindingForActor(ctx, actor, claim) + Expect(err).To(HaveOccurred()) + Expect(adapter.ValidateExecutionBinding(ctx, actor, gateway.ExecutionStatusBinding{})).NotTo(Succeed()) + backend := &bindingBackend{binding: gateway.ExecutionStatusBinding{Actor: actor, ConversationID: "chat", CurrentSelection: "owner-epoch"}} + adapter = tools.NewToolAdapter(backend) + got, err := adapter.BindingForActor(ctx, actor, claim) + Expect(err).NotTo(HaveOccurred()) + Expect(got).To(Equal(backend.binding)) + Expect(backend.actor).To(Equal(actor)) + Expect(backend.claim).To(Equal(claim)) + Expect(adapter.ValidateExecutionBinding(ctx, actor, got)).To(Succeed()) + got.CurrentSelection = "forged" + Expect(adapter.ValidateExecutionBinding(ctx, actor, got)).To(MatchError(gateway.ErrForbidden)) + }) +}) diff --git a/core/services/addons/tools/consumer_matrix_test.go b/core/services/addons/tools/consumer_matrix_test.go index 5c70205ea..d803fd2f5 100644 --- a/core/services/addons/tools/consumer_matrix_test.go +++ b/core/services/addons/tools/consumer_matrix_test.go @@ -166,6 +166,9 @@ var _ = Describe("Tool consumer matrix", func() { }) Expect(importers).To(ConsistOf( "core/application/addon_gateway.go", // builds the provider from the owner gateway + "core/application/execution_binding.go", // validates the selected qualified identity + "core/services/studio/addontools.go", // formats unavailable stand-in identities + "core/services/studio/capabilities.go", // parses stand-in identities without dispatch "core/http/endpoints/mcp/addon_tools.go", // the one executor of the namespace "core/http/endpoints/mcp/tools.go", // refuses the namespace in MCP sessions )) diff --git a/core/services/addons/tools/definitions_test.go b/core/services/addons/tools/definitions_test.go index f351655b7..2fc121f42 100644 --- a/core/services/addons/tools/definitions_test.go +++ b/core/services/addons/tools/definitions_test.go @@ -210,3 +210,18 @@ var _ = Describe("The add-ons that replace a built-in chat tool", func() { })) }) }) + +var _ = Describe("typed execution status model exclusion", func() { + It("keeps declared uiOnly execution status tools out of generic adapters", func() { + app := apps.App{ID: "renamed", Generation: 1, Enabled: true, State: "running", Tools: []string{"health"}, ToolDefinitions: []contract.ToolDefinition{{Name: "health", Kind: "read", UIOnly: true, Description: "Observe execution.", InputSchema: json.RawMessage(`{"type":"object","additionalProperties":false}`)}}} + f := &fake{list: apps.List{Apps: []apps.App{app}}} + for _, a := range []*tools.ToolAdapter{tools.NewToolAdapter(f), tools.NewChatToolAdapter(f)} { + defs, e := a.Discover(context.Background(), member) + Expect(e).NotTo(HaveOccurred()) + Expect(defs).To(BeEmpty()) + _, e = a.Execute(context.Background(), member, tools.ToolName("renamed", "health"), json.RawMessage(`{}`)) + Expect(e).To(MatchError(tools.ErrUnknownTool)) + } + Expect(f.calls()).To(BeZero()) + }) +}) diff --git a/core/services/addons/tools/toolnames_test.go b/core/services/addons/tools/toolnames_test.go new file mode 100644 index 000000000..d70434e57 --- /dev/null +++ b/core/services/addons/tools/toolnames_test.go @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +package tools_test + +import ( + "strings" + + "github.com/mudler/LocalAI/core/services/addons/tools" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("qualified add-on tool names", func() { + It("round trips valid components and enforces the callable length boundary", func() { + for _, id := range []string{"a", "runner-9", "r" + strings.Repeat("x", 63)} { + for _, tool := range []string{"t", "read-doc", "t" + strings.Repeat("z", 63)} { + name := tools.ToolName(id, tool) + gotID, gotTool, ok := tools.ParseQualifiedToolName(name) + Expect(ok).To(BeTrue()) + Expect(gotID).To(Equal(id)) + Expect(gotTool).To(Equal(tool)) + _, _, callable := tools.ParseToolName(name) + Expect(callable).To(Equal(len(name) <= tools.MaxNameLength)) + } + } + for _, length := range []int{64, 65} { + name := tools.ToolName("a", "t"+strings.Repeat("x", length-11)) + Expect(len(name)).To(Equal(length)) + _, _, ok := tools.ParseToolName(name) + Expect(ok).To(Equal(length == 64)) + } + }) + It("rejects malformed qualified identities while reserving their namespace", func() { + for _, name := range []string{"addon__bad_id__tool", "addon__a__bad_tool", "addon__9a__tool", "addon__a__tool__extra", "addon____tool", "addon__a__", "addon__"} { + _, _, ok := tools.ParseQualifiedToolName(name) + Expect(ok).To(BeFalse(), name) + Expect(tools.IsToolName(name)).To(BeTrue(), name) + } + for _, name := range []string{"bash", "a__tool", "mcp__a__tool"} { + _, _, ok := tools.ParseToolName(name) + Expect(ok).To(BeFalse()) + Expect(tools.IsToolName(name)).To(BeFalse()) + } + }) +}) diff --git a/core/services/addons/tools_test.go b/core/services/addons/tools_test.go index d12f7186c..cc56243eb 100644 --- a/core/services/addons/tools_test.go +++ b/core/services/addons/tools_test.go @@ -117,6 +117,9 @@ var _ = Describe("declared capabilities of the installed generation", Label("too m := open(cfg, c, i) var caps contract.Capabilities fillNonZero(reflect.ValueOf(&caps).Elem()) + // Admission requires valid, supported requests even in the reflection fixture. + caps.HostOperations = &contract.HostOperations{Required: []string{"workspace.materialize"}, Optional: []string{"egress.check"}} + m.SetHostOps(map[string][]string{"notes": {"workspace."}}) v := contract.VersionRange{Min: 1, Max: 1} m.journal.Addons["notes"] = Status{AddonID: "notes", Installed: true, DesiredEnabled: true, Generation: 1, Generations: []Generation{{Number: 1, Receipt: packages.PackageReceipt{ Manifest: contract.Manifest{SchemaVersion: 1, ID: "notes", Version: "1.0.0", Executable: "bin/x", OS: "linux", Arch: "amd64", Host: v, API: v, Protocol: v, DataFormat: v, Capabilities: caps}}}}} diff --git a/core/services/addons/updates_test.go b/core/services/addons/updates_test.go index 1c1986f27..0b2a7fce6 100644 --- a/core/services/addons/updates_test.go +++ b/core/services/addons/updates_test.go @@ -8,10 +8,12 @@ import ( "crypto/sha256" "encoding/json" "fmt" + "io" "net/http" "net/http/httptest" "os" "path/filepath" + "sort" "strings" "sync" "time" @@ -40,6 +42,8 @@ type updateLibrary struct { archives map[string][]byte releases []catalog.Release sources *catalog.Sources + // transform lets lifecycle specs publish real typed executable packages. + transform func(map[string]any) map[string][]byte } func (l *updateLibrary) publish(version, channel, content string, requires catalog.Requirements) catalog.Release { @@ -49,15 +53,26 @@ func (l *updateLibrary) publish(version, channel, content string, requires catal var m map[string]any Expect(json.Unmarshal(manifest, &m)).To(Succeed()) m["version"] = version + files := map[string][]byte{"bin/sample": []byte(content)} + if l.transform != nil { + for name, data := range l.transform(m) { + files[name] = data + } + } manifest, _ = json.Marshal(m) + files["manifest.json"] = manifest var b bytes.Buffer tw := tar.NewWriter(&b) - for _, f := range []struct { - name string - data []byte - }{{"manifest.json", manifest}, {"bin/sample", []byte(content)}} { - Expect(tw.WriteHeader(&tar.Header{Name: f.name, Mode: 0755, Size: int64(len(f.data))})).To(Succeed()) - _, e = tw.Write(f.data) + // Stable entry order keeps identical package contents at the same digest. + names := make([]string, 0, len(files)) + for name := range files { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + data := files[name] + Expect(tw.WriteHeader(&tar.Header{Name: name, Mode: 0755, Size: int64(len(data))})).To(Succeed()) + _, e = tw.Write(data) Expect(e).NotTo(HaveOccurred()) } Expect(tw.Close()).To(Succeed()) @@ -167,6 +182,44 @@ func (r *updateRig) journalOps() int { return len(r.m.journal.Operations) } +var _ = Describe("update library archive fixtures", func() { + It("publishes identical bytes and digests with sorted archive entries for unchanged inputs and transforms", func() { + for _, transformed := range []bool{false, true} { + lib := &updateLibrary{archives: map[string][]byte{}, server: &httptest.Server{URL: "http://fixture.invalid"}} + wantNames := []string{"bin/sample", "manifest.json"} + if transformed { + lib.transform = func(m map[string]any) map[string][]byte { + m["description"] = "unchanged transform" + return map[string][]byte{"z-last.txt": []byte("last"), "a-first.txt": []byte("first"), "ui/main.js": []byte("export default {};")} + } + wantNames = []string{"a-first.txt", "bin/sample", "manifest.json", "ui/main.js", "z-last.txt"} + } + var archives [][]byte + digests := map[string]bool{} + for attempt := 0; attempt < 100; attempt++ { + release := lib.publish("1.2.3", "", "unchanged content", catalog.Requirements{}) + digests[release.Digest] = true + archives = append(archives, lib.archives[strings.TrimPrefix(release.Artifact, lib.server.URL)]) + } + Expect(digests).To(HaveLen(1), "unchanged publishes must not change the digest; transformed=%t", transformed) + for _, archive := range archives { + Expect(archive).To(Equal(archives[0])) + tr := tar.NewReader(bytes.NewReader(archive)) + var names []string + for { + header, err := tr.Next() + if err == io.EOF { + break + } + Expect(err).NotTo(HaveOccurred()) + names = append(names, header.Name) + } + Expect(names).To(Equal(wantNames), "archive entries must use lexical order") + } + } + }) +}) + var _ = Describe("explicit upgrade from an authoritative update check", func() { It("reports a digest-only change of a moving channel, applies nothing and offers the pinned upgrade", func() { r := newUpdateRig(true) diff --git a/core/services/studio/addontools.go b/core/services/studio/addontools.go index ab86ddc8e..1edfa140e 100644 --- a/core/services/studio/addontools.go +++ b/core/services/studio/addontools.go @@ -9,6 +9,7 @@ import ( "slices" "sort" + addontools "github.com/mudler/LocalAI/core/services/addons/tools" "github.com/mudler/LocalAI/core/services/studio/session" "github.com/mudler/LocalAI/core/services/studio/workspace" ) @@ -57,7 +58,7 @@ type AddonGate interface { // unavailableToolName is the name of the stand-in tool of an add-on that // cannot work. It has the shape of the broker's tool names. -func unavailableToolName(addonID string) string { return "addon__" + addonID + "__unavailable" } +func unavailableToolName(addonID string) string { return addontools.ToolName(addonID, "unavailable") } // ErrBadAddonID means a switch names something that is not an add-on ID, or // there are too many switches. @@ -193,12 +194,11 @@ func (s *Service) addonTools(ctx context.Context, a workspace.Actor, rec session continue } } - if off[d.AddonID] || reservedToolNames[d.Name] || (sc != nil && !sc.addons[d.AddonID]) { - continue + var grants []string + if sc != nil { + grants = sc.addonNames } - // An unattended run with no agent never gets a tool that acts at once: - // the owner's explicit grant, in an agent, is what allows it. - if d.Action && rec.Origin != "" && rec.AgentID == "" { + if !AddonToolEligible(d, rec, grants) { continue } name := d.Name diff --git a/core/services/studio/agents/json.go b/core/services/studio/agents/json.go index 3c38ecdec..eae3b1494 100644 --- a/core/services/studio/agents/json.go +++ b/core/services/studio/agents/json.go @@ -14,7 +14,8 @@ import ( // jsonStore keeps the agents in memory and, with a directory, in the file // agents.json (rewritten atomically after every change). One process owns the -// directory: the lock is in-process only, so a cluster uses the GORM store. +// directory. +// A cluster uses GORM. type jsonStore struct { mu sync.Mutex path string // empty: memory only diff --git a/core/services/studio/capabilities.go b/core/services/studio/capabilities.go index 2d30f0b21..27bfd24a5 100644 --- a/core/services/studio/capabilities.go +++ b/core/services/studio/capabilities.go @@ -8,6 +8,7 @@ import ( "strings" "time" + addontools "github.com/mudler/LocalAI/core/services/addons/tools" "github.com/mudler/LocalAI/core/services/studio/sandbox" "github.com/mudler/LocalAI/core/services/studio/session" "github.com/mudler/LocalAI/core/services/studio/skills" @@ -60,6 +61,8 @@ const ( // Commands is what a chat knows about running commands at a turn. type Commands struct { + ExecutionStatus *ExecutionStatusView + State CommandState // Tool is the name of the tool that runs commands, when State is available. Tool string @@ -136,11 +139,8 @@ func standInRunner(tools []session.Tool) string { if !slices.Contains(t.StandInFor, commandTool) { continue } - id, ok := strings.CutPrefix(t.Name, "addon__") - if !ok { - continue - } - if id, ok = strings.CutSuffix(id, "__unavailable"); ok { + id, tool, ok := addontools.ParseQualifiedToolName(t.Name) + if ok && tool == "unavailable" { return id } } @@ -205,6 +205,8 @@ func (s *Service) commandsOf(ctx context.Context, user string, tools []session.T // Tools menu shows it. It uses the same tools the model would get. The chat of // another user is session.ErrNotFound. func (s *Service) CommandRunner(ctx context.Context, user, conv string) (Commands, error) { + ctx, closeScope := s.executionScope(ctx, principal(user), conv) + defer closeScope() p := principal(user) rec, err := s.d.Store.GetChat(ctx, p, conv) if err != nil { @@ -233,6 +235,9 @@ func (s *Service) commandsFor(ctx context.Context, p string, rec session.ChatRec } } c := s.commandsOf(ctx, p, tools, rec.AddonsOff, sc) + if _, ok := s.d.AddonTools.(ExecutionObserver); ok { + c.ExecutionStatus = executionStatusView(ctx, p, c.Addon) + } tools = s.applyBuiltins(tools, s.builtinsFor(ctx, a, rec, false)) return shellSwitched(s.withHostBash(c, tools), s.hiddenFor(ctx, rec)), nil } diff --git a/core/services/studio/capabilities_test.go b/core/services/studio/capabilities_test.go index 28a8be882..c4b090d8a 100644 --- a/core/services/studio/capabilities_test.go +++ b/core/services/studio/capabilities_test.go @@ -2,7 +2,10 @@ package studio_test import ( "context" + "github.com/mudler/LocalAI/core/services/studio/executionrequest" + model "github.com/mudler/LocalAI/pkg/addons/model/executionstatus" "strings" + "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -233,3 +236,45 @@ var _ = Describe("capabilities", func() { Expect(childrenOf(svc, alice, id)).To(HaveLen(2)) }) }) + +// presentationAddons supplies an admitted request just as the product broker does. +type presentationAddons struct { + *fakeAddons + bound bool +} + +func (p presentationAddons) NewExecutionObservation(ctx context.Context, user, conv string) (*executionrequest.Request, string, func() string) { + if !p.bound { + return nil, "", nil + } + b := model.Binding{Actor: user, Addon: "sandbox", Generation: "27", Incarnation: "runtime-two", ExecutionTool: "bash", StatusTool: "status"} + r := executionrequest.New(ctx, b, "q", model.Eligible, executionrequest.IO{ + Validate: func(context.Context, model.Binding) error { return nil }, + Read: func(context.Context, model.Binding, model.ObservationID, time.Time) (model.Payload, error) { + return model.ValidPayload(model.Value{Readiness: model.Available, Isolation: model.Host}), nil + }, + }, nil) + Expect(r.Binding()).To(Equal(b)) + return r, b.Addon, func() string { return "plain explanation" } +} + +var _ = Describe("execution presentation DTO", func() { + It("returns admitted binding identity through CommandRunner and no identity without admission", func() { + for _, bound := range []bool{true, false} { + broker := presentationAddons{fakeAddons: &fakeAddons{tools: map[string][]studio.AddonTool{alice: runAddon()}}, bound: bound} + svc, _, _ := newCapsChat(broker, nil) + id := create(svc, alice) + c, err := svc.CommandRunner(context.Background(), alice, id) + Expect(err).NotTo(HaveOccurred()) + Expect(c.ExecutionStatus).NotTo(BeNil()) + if bound { + Expect(c.ExecutionStatus.Generation).To(Equal("27")) + Expect(c.ExecutionStatus.Incarnation).To(Equal("runtime-two")) + Expect(c.ExecutionStatus.Readiness).To(Equal("available")) + } else { + Expect(c.ExecutionStatus.Generation).To(BeEmpty()) + Expect(c.ExecutionStatus.Readiness).To(Equal("unknown")) + } + } + }) +}) diff --git a/core/services/studio/egress_test.go b/core/services/studio/egress_test.go index 5ea9a9e75..9693fd8cb 100644 --- a/core/services/studio/egress_test.go +++ b/core/services/studio/egress_test.go @@ -238,11 +238,11 @@ var _ = Describe("the add-on that draws the network row of a chat", func() { return svc } - It("is the add-on that runs the commands and carries the network policy", func() { + It("names the runner control without claiming host network enforcement", func() { svc := service("sandbox", "isolated", map[string][]string{"sandbox": {"network-policy", "tool-providers"}}) v, err := svc.Egress(context.Background(), alice, create(svc, alice)) Expect(err).NotTo(HaveOccurred()) - Expect(v.Applies).To(BeTrue()) + Expect(v.Applies).To(BeFalse()) Expect(v.JSON().Addon).To(Equal("sandbox")) }) @@ -252,10 +252,10 @@ var _ = Describe("the add-on that draws the network row of a chat", func() { Expect(v.JSON().Addon).To(Equal("devbox")) }) - It("is none when the runner has no network policy, and the policy still applies", func() { + It("is none when the runner has no network policy and enforcement is unknown", func() { svc := service("sandbox", "isolated", map[string][]string{"sandbox": {"tool-providers"}}) v, _ := svc.Egress(context.Background(), alice, create(svc, alice)) - Expect(v.Applies).To(BeTrue()) + Expect(v.Applies).To(BeFalse()) Expect(v.JSON().Addon).To(BeEmpty()) }) diff --git a/core/services/studio/egressapi.go b/core/services/studio/egressapi.go index b1a9ea880..92d87c48c 100644 --- a/core/services/studio/egressapi.go +++ b/core/services/studio/egressapi.go @@ -72,6 +72,8 @@ func registriesView(s egress.Settings) []egress.Registry { // Egress returns the network of a chat for its owner. The chat of another // user is session.ErrNotFound. func (s *Service) Egress(ctx context.Context, user, conv string) (EgressView, error) { + ctx, closeScope := s.executionScope(ctx, principal(user), conv) + defer closeScope() p := principal(user) rec, err := s.d.Store.GetChat(ctx, p, conv) if err != nil { @@ -116,7 +118,8 @@ func (s *Service) Egress(ctx context.Context, user, conv string) (EgressView, er case cmds.State == CommandsAvailable && (cmds.Provider == BuiltinProvider || cmds.Isolation == "host"): v.AppliesReason = EgressAppliesHost case cmds.State == CommandsAvailable: - v.Applies = true + // Provider isolation does not establish host network-policy enforcement. + v.AppliesReason = EgressAppliesNone if c, ok := s.d.AddonTools.(AddonControls); ok && slices.Contains(c.Controls(ctx, p, cmds.Provider), contract.ControlNetworkPolicy) { v.Addon = cmds.Provider } diff --git a/core/services/studio/executionobservation.go b/core/services/studio/executionobservation.go new file mode 100644 index 000000000..4cab34084 --- /dev/null +++ b/core/services/studio/executionobservation.go @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: MIT +package studio + +import ( + "context" + "sync" + + "github.com/mudler/LocalAI/core/services/studio/executionrequest" + "github.com/mudler/LocalAI/core/services/studio/session" + model "github.com/mudler/LocalAI/pkg/addons/model/executionstatus" +) + +// ExecutionObserver admits the owner-selected provider, not a discovery claim. +// The request and explanation reader belong exclusively to this outer scope. +type ExecutionObserver interface { + NewExecutionObservation(context.Context, string, string) (*executionrequest.Request, string, func() string) +} + +type executionObservationKey struct{} +type executionObservation struct { + ctx context.Context + user, conversation string + once sync.Once + request *executionrequest.Request + addon string + explanation func() string + provider ExecutionObserver +} + +// executionScope borrows existing outer state. Nested consumers must not close +// it: only the real turn, priming operation or standalone API request owns Close. +func (s *Service) executionScope(ctx context.Context, user, conversation string) (context.Context, func()) { + if existing, ok := ctx.Value(executionObservationKey{}).(*executionObservation); ok && existing.user == user && existing.conversation == conversation { + return ctx, func() {} + } + provider, _ := s.d.AddonTools.(ExecutionObserver) + ownerCtx, cancel := context.WithCancel(ctx) + scope := &executionObservation{ctx: ownerCtx, user: user, conversation: conversation, provider: provider} + return context.WithValue(ctx, executionObservationKey{}, scope), func() { + cancel() + scope.once.Do(func() {}) + if scope.request != nil { + scope.request.Close() + } + } +} +func (s *Service) productScope(ctx context.Context, x session.Access) (context.Context, func()) { + a := x.Actor() + return s.executionScope(ctx, a.Principal, a.ChatID) +} + +// ExecutionObservation never falls back to an arbitrary installed provider. +// An out-of-scope, wrong-user or non-selected add-on has unknown status. +func ExecutionObservation(ctx context.Context, user, addon string) (model.Value, string) { + scope, ok := ctx.Value(executionObservationKey{}).(*executionObservation) + if !ok || user != scope.user { + return model.Unknown(), "" + } + scope.once.Do(func() { + if scope.provider != nil { + scope.request, scope.addon, scope.explanation = scope.provider.NewExecutionObservation(scope.ctx, scope.user, scope.conversation) + } + }) + if scope.request == nil || addon != scope.addon { + return model.Unknown(), "" + } + value := scope.request.Observe(ctx) + explanation := "" + if value.Readiness != model.UnknownReadiness && scope.explanation != nil { + explanation = scope.explanation() + } + return value, explanation +} + +// ExecutionStatusView is observation data, never execution authorization. +type ExecutionStatusView struct { + Readiness string `json:"readiness"` + Isolation string `json:"isolation"` + Explanation string `json:"explanation,omitempty"` + Generation string `json:"generation,omitempty"` + Incarnation string `json:"incarnation,omitempty"` +} + +func executionStatusView(ctx context.Context, user, addon string) *ExecutionStatusView { + v, explanation := model.Unknown(), "" + if addon != "" { + v, explanation = ExecutionObservation(ctx, user, addon) + } + out := &ExecutionStatusView{Readiness: string(v.Readiness), Isolation: string(v.Isolation), Explanation: explanation} + scope, ok := ctx.Value(executionObservationKey{}).(*executionObservation) + if ok && scope.user == user && scope.addon == addon && scope.request != nil { + b := scope.request.Binding() + out.Generation, out.Incarnation = b.Generation, b.Incarnation + } + return out +} diff --git a/core/services/studio/executionobservation_test.go b/core/services/studio/executionobservation_test.go new file mode 100644 index 000000000..bb51e7085 --- /dev/null +++ b/core/services/studio/executionobservation_test.go @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: MIT +package studio + +import ( + "context" + "encoding/json" + "errors" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "sync/atomic" + "time" + + "github.com/mudler/LocalAI/core/services/studio/executionrequest" + "github.com/mudler/LocalAI/core/services/studio/session" + "github.com/mudler/LocalAI/core/services/studio/session/sessiontest" + "github.com/mudler/LocalAI/core/services/studio/workspace" + model "github.com/mudler/LocalAI/pkg/addons/model/executionstatus" +) + +type observationProvider struct { + AddonTools + reads atomic.Int32 + requests chan *executionrequest.Request + started chan struct{} + release chan struct{} + fail bool +} + +func (p *observationProvider) NewExecutionObservation(ctx context.Context, user, conv string) (*executionrequest.Request, string, func() string) { + b := model.Binding{Actor: user, Addon: "renamed-provider", Generation: "1", Incarnation: "one", ExecutionTool: "run", StatusTool: "inspect"} + r := executionrequest.New(ctx, b, "host-q", model.Eligible, executionrequest.IO{ + Validate: func(context.Context, model.Binding) error { return nil }, + Read: func(ctx context.Context, _ model.Binding, _ model.ObservationID, _ time.Time) (model.Payload, error) { + p.reads.Add(1) + if p.started != nil { + close(p.started) + } + if p.release != nil { + select { + case <-p.release: + case <-ctx.Done(): + return model.InvalidPayload(), ctx.Err() + } + } + if p.fail { + return model.InvalidPayload(), errors.New("failed") + } + return model.ValidPayload(model.Value{Readiness: model.Available, Isolation: model.Isolated}), nil + }}, nil) + if p.requests != nil { + p.requests <- r + } + return r, b.Addon, func() string { return "" } +} + +var _ = Describe("execution status product scope", func() { + It("shares one status observation across the actual session hook", func() { + p := &observationProvider{requests: make(chan *executionrequest.Request, 3)} + s := &Service{d: Deps{AddonTools: p}} + h := &sessiontest.FakeHarness{Turns: [][]sessiontest.Step{{{Tool: "status", Args: "{}"}, {Tool: "egress", Args: "{}"}}}} + failures := make(chan string, 8) + observe := func(ctx context.Context) { + v, _ := ExecutionObservation(ctx, "alice", "renamed-provider") + if v.Isolation != model.Isolated { + failures <- "not isolated" + } + } + m, err := session.New(session.Options{Store: sessiontest.NewMemoryStore(), StateDir: GinkgoT().TempDir(), Harness: h, ProductScope: s.productScope, + ChatTools: func(ctx context.Context, _ session.Access) (session.TurnTools, error) { + observe(ctx) + tools := []session.Tool{} + for _, name := range []string{"status", "egress"} { + tools = append(tools, session.Tool{Name: name, Description: name, Schema: json.RawMessage(`{"type":"object"}`), Run: func(ctx context.Context, _ json.RawMessage) (string, error) { observe(ctx); return "ok", nil }}) + } + return session.TurnTools{Tools: tools}, nil + }}) + Expect(err).NotTo(HaveOccurred()) + defer func() { Expect(m.Close(context.Background())).To(Succeed()) }() + x, err := session.NewAccess(workspace.Actor{Principal: "alice", ChatID: "c1"}, workspace.View{}) + Expect(err).NotTo(HaveOccurred()) + chat, err := m.Open(context.Background(), x, "c1") + Expect(err).NotTo(HaveOccurred()) + Expect(chat.Send(context.Background(), session.Message{Text: "hello"})).To(Succeed()) + timer := time.NewTimer(5 * time.Second) + defer timer.Stop() + for { + select { + case e := <-chat.Events(): + if e.Kind == session.KindTurnEnd { + goto finished + } + case <-timer.C: + Fail("turn timed out") + } + } + finished: + Expect(failures).To(BeEmpty()) + Expect(p.reads.Load()).To(Equal(int32(2)), "priming + one turn") + for range 2 { + r := <-p.requests + Expect(r.Observe(context.Background()) != model.Unknown()).To(BeFalse(), "scope not closed") + } + }) +}) +var _ = Describe("execution status product scope", func() { + It("closes status observations on cancellation and failure", func() { + for _, fail := range []bool{false, true} { + p := &observationProvider{started: make(chan struct{}), release: make(chan struct{}), fail: fail} + s := &Service{d: Deps{AddonTools: p}} + ctx, closeScope := s.executionScope(context.Background(), "alice", "c1") + consumer, cancel := context.WithCancel(ctx) + done := make(chan model.Value, 1) + go func() { v, _ := ExecutionObservation(consumer, "alice", "renamed-provider"); done <- v }() + select { + case <-p.started: + case <-time.After(5 * time.Second): + Fail("no read") + } + cancel() + Expect(<-done).To(Equal(model.Unknown())) + if fail { + close(p.release) + } else { + closeScope() + } + v, _ := ExecutionObservation(ctx, "alice", "renamed-provider") + Expect(v != model.Unknown()).To(BeFalse(), v) + closeScope() + Expect(p.reads.Load() != 1).To(BeFalse(), "retried read") + legacy, _ := ExecutionObservation(ctx, "alice", "legacy-provider") + Expect(legacy).To(Equal(model.Unknown())) + } + }) +}) + +var _ = Describe("execution status product scope", func() { + It("retains the admitted status presentation binding per request", func() { + p := &observationProvider{} + s := &Service{d: Deps{AddonTools: p}} + ctx, closeScope := s.executionScope(context.Background(), "alice", "chat-one") + defer closeScope() + v := executionStatusView(ctx, "alice", "renamed-provider") + Expect(v.Readiness != "available" || v.Isolation != "isolated" || v.Generation != "1" || v.Incarnation != "one").To(BeFalse(), "missing or unredacted admitted presentation binding", v) + Expect(*executionStatusView(ctx, "alice", "renamed-provider")).To(Equal(*v)) + Expect(executionStatusView(ctx, "bob", "renamed-provider").Readiness).To(Equal("unknown")) + next, closeNext := s.executionScope(context.Background(), "alice", "chat-two") + defer closeNext() + Expect(executionStatusView(next, "alice", "renamed-provider").Readiness != "available").To(BeFalse(), "fresh request lost status") + Expect(p.reads.Load() != 2).To(BeFalse(), "DTO re-probed within request") + }) +}) + +var _ = Describe("execution status product scope", func() { + It("leaves unbound status presentation unknown", func() { + s := &Service{} + ctx, done := s.executionScope(context.Background(), "alice", "chat") + defer done() + v := executionStatusView(ctx, "alice", "") + Expect(v.Readiness != "unknown" || v.Isolation != "unknown" || v.Generation != "" || v.Incarnation != "").To(BeFalse(), "unbound status manufactured identity", v) + }) +}) diff --git a/core/services/studio/executionrequest/README.md b/core/services/studio/executionrequest/README.md new file mode 100644 index 000000000..95b2bc7b9 --- /dev/null +++ b/core/services/studio/executionrequest/README.md @@ -0,0 +1,43 @@ +# Request-local execution observation shell + +`New(parent, binding, q, eligibility, IO, Clock)` starts a fresh **Unbound** +model and submits its authoritative `Bind`. The caller must generate Q and +obtain the binding from the owner; this package does not admit providers. +Call `defer request.Close()` at the enclosing request or turn boundary. +Nested prompt, status and egress consumers call `Observe` on that same object. +No context attachment or production entry-point integration exists yet. + +`IO.Read` handles the reducer's sole `Read` effect. It must preserve B/Q through +the carrier, decode the strict typed contract, and honor cancellation. +`IO.Validate` checks current owner authority, current selection and exact runtime +identity. It runs after the read and before each settled observation consumption. +Nil callbacks cannot produce a positive observation. Status never grants tool +access or proves network-policy enforcement. + +The shell serializes actual `executionstatus.Reduce` events. `Publish` wakes +consumers, `Cancel` cancels the read, and `Read` starts its I/O goroutine. A +five-second maximum deadline starts at construction and respects an earlier +parent deadline. A clock callback supplies `Tick`; parent cancellation supplies +terminal `Close`. Explicit lifecycle/permission/provider changes call +`Invalidate`. There is deliberately no rebind method: a new admission lifetime +requires a new request object. Settled failures cannot retry because the reducer +ignores subsequent probes (R7), not because of a shell retry flag. + +A consumer's own canceled wait returns unknown without closing other consumers. +Close/Invalidate stop waiting consumers without waiting for an uncooperative I/O +callback. Late callbacks still enter the reducer and cannot restore withdrawn or +closed values. I/O implementations remain responsible for stopping their work. +Validation and consumption cannot be atomic with subsequent execution: execution +must retain its independent owner admission and freshness checks. + +`Trace` provides detached reducer transitions for diagnostics and tests. Binding +identities must be opaque tokens, never credentials. Model values carry readiness +and isolation only; typed wire explanations and backend/API projections belong +to the subsequent integration task. + +Focused verification (with the repository's prepared offline environment): + +```sh +go test -race -count=1 ./core/services/studio/executionrequest ./pkg/addons/model/executionstatus +go run ./pkg/addons/model/executionstatus/cmd/viewer -check +``` diff --git a/core/services/studio/executionrequest/request.go b/core/services/studio/executionrequest/request.go new file mode 100644 index 000000000..317e5c4cf --- /dev/null +++ b/core/services/studio/executionrequest/request.go @@ -0,0 +1,217 @@ +// SPDX-License-Identifier: MIT + +// Package executionrequest runs the effects of one executionstatus model. +// It owns no authority, provider selection, cross-request cache or retry policy. +package executionrequest + +import ( + "context" + "sync" + "time" + + model "github.com/mudler/LocalAI/pkg/addons/model/executionstatus" +) + +// IO must preserve the admitted binding and observation ID through the owner +// carrier. Read supplies a payload only after strict contract decoding. Validate +// checks current owner authority, selection and runtime identity, never reported +// readiness. Implementations must honor context cancellation. +type IO struct { + Read func(context.Context, model.Binding, model.ObservationID, time.Time) (model.Payload, error) + Validate func(context.Context, model.Binding) error +} + +type Timer interface{ Stop() bool } + +// Clock preserves monotonic time; fake clocks let tests deliver the deadline +// without sleeps. AfterFunc must schedule callbacks, not invoke them inline. +type Clock interface { + Now() time.Time + AfterFunc(time.Duration, func()) Timer +} +type realClock struct{} + +func (realClock) Now() time.Time { return time.Now() } +func (realClock) AfterFunc(d time.Duration, fn func()) Timer { return time.AfterFunc(d, fn) } + +// Request is an explicitly owned observation lifetime. Call Close at the real +// enclosing request/turn boundary. Do not retain it in a service or chat cache. +// Events are serialized under mu; only Reduce decides transitions and effects. +type Request struct { + mu sync.Mutex + ctx context.Context + b model.Binding + q model.ObservationID + io IO + clock Clock + origin, deadline time.Time + state model.State + trace []model.Transition + changed chan struct{} + cancel context.CancelFunc + timer Timer + stopParent func() bool +} + +// New consumes an already authoritative admission, not a frontend selection +// claim. Q is generated by the host caller once per admitted request. A nil +// clock uses real monotonic time. Missing IO fails closed without retry. +func New(ctx context.Context, b model.Binding, q model.ObservationID, eligibility model.Eligibility, io IO, clock Clock) *Request { + if clock == nil { + clock = realClock{} + } + now := clock.Now() + deadline := now.Add(5 * time.Second) + if d, ok := ctx.Deadline(); ok && d.Before(deadline) { + deadline = d + } + r := &Request{ctx: ctx, b: b, q: q, io: io, clock: clock, origin: now, deadline: deadline, state: model.Unbound{}, changed: make(chan struct{})} + r.mu.Lock() + r.step(model.Bind{B: b, Q: q, Eligibility: eligibility}) + r.mu.Unlock() + stop := context.AfterFunc(ctx, r.Close) + r.mu.Lock() + r.stopParent = stop + if _, closed := r.state.(model.Closed); closed { + stop() + } + r.mu.Unlock() + return r +} +func (r *Request) now() int64 { return r.clock.Now().Sub(r.origin).Nanoseconds() } + +// step runs reducer effects while holding mu. External reads run outside mu; +// their completion feeds an event, including after cancellation or withdrawal. +func (r *Request) step(e model.Event) { + tr := model.Reduce(r.state, e) + r.state = tr.State + // Copy slices so Trace callers cannot alter the shell's history. + tr.Effects = append([]model.Effect(nil), tr.Effects...) + r.trace = append(r.trace, tr) + for _, effect := range tr.Effects { + switch effect.(type) { + case model.Read: + ctx, cancel := context.WithCancel(r.ctx) + r.cancel = cancel + r.timer = r.clock.AfterFunc(r.deadline.Sub(r.clock.Now()), func() { r.mu.Lock(); defer r.mu.Unlock(); r.step(model.Tick{Now: r.now()}) }) + go r.read(ctx, cancel) + case model.Cancel: + if r.cancel != nil { + r.cancel() + } + case model.Publish: + close(r.changed) + r.changed = make(chan struct{}) + } + } + if _, pending := r.state.(model.Pending); !pending && r.timer != nil { + r.timer.Stop() + r.timer = nil + } +} +func (r *Request) read(ctx context.Context, cancel context.CancelFunc) { + defer cancel() + payload := model.InvalidPayload() + var err error + if r.io.Read != nil { + payload, err = r.io.Read(ctx, r.b, r.q, r.deadline) + } + // The carrier's return is not freshness proof. Check before any positive + // publication; the consuming caller checks again before using it. + fresh := r.io.Validate != nil && r.io.Validate(ctx, r.b) == nil + r.mu.Lock() + defer r.mu.Unlock() + if r.ctx.Err() != nil { + r.step(model.Close{}) + } + if !fresh { + r.step(model.Invalidate{Cause: "stale-binding"}) + } + if err != nil { + r.step(model.Failure{B: r.b, Q: r.q, Cause: "read-failed", Now: r.now()}) + return + } + r.step(model.Reply{B: r.b, Q: r.q, Payload: payload, Now: r.now()}) +} + +// Observe coalesces consumers of the same request. A consumer's cancellation +// stops only its wait; parent cancellation closes the entire observation. +// A settled error never causes another read: the reducer enforces R7. +func (r *Request) Observe(ctx context.Context) model.Value { + r.mu.Lock() + if r.ctx.Err() != nil { + r.step(model.Close{}) + } + r.step(model.Probe{B: r.b, Q: r.q, Now: r.now(), Deadline: r.deadline.Sub(r.origin).Nanoseconds()}) + for { + if ctx.Err() != nil { + r.mu.Unlock() + return model.Unknown() + } + if _, pending := r.state.(model.Pending); !pending { + if _, observed := r.state.(model.Observed); !observed { + v := model.Project(r.state) + r.mu.Unlock() + return v + } + r.mu.Unlock() + fresh := r.io.Validate != nil && r.io.Validate(ctx, r.b) == nil + r.mu.Lock() + if r.ctx.Err() != nil { + r.step(model.Close{}) + } + // A canceled consumer cannot establish freshness failure for peers. + // Parent cancellation above still closes the shared observation. + if ctx.Err() != nil { + r.mu.Unlock() + return model.Unknown() + } + if !fresh { + r.step(model.Invalidate{Cause: "stale-binding"}) + } + v := model.Project(r.state) + if ctx.Err() != nil { + v = model.Unknown() + } + r.mu.Unlock() + return v + } + changed := r.changed + r.mu.Unlock() + select { + case <-changed: + case <-ctx.Done(): + return model.Unknown() + } + r.mu.Lock() + } +} + +func (r *Request) Invalidate(cause model.Cause) { + r.mu.Lock() + defer r.mu.Unlock() + r.step(model.Invalidate{Cause: cause}) +} +func (r *Request) Close() { + r.mu.Lock() + defer r.mu.Unlock() + r.step(model.Close{}) + if r.stopParent != nil { + r.stopParent() + } +} + +// Trace returns detached reducer transitions for model/runtime correspondence +// checks. It includes ignored events, never credentials or wire payloads. +func (r *Request) Trace() []model.Transition { + r.mu.Lock() + defer r.mu.Unlock() + out := append([]model.Transition(nil), r.trace...) + for i := range out { + out[i].Effects = append([]model.Effect(nil), out[i].Effects...) + } + return out +} + +// Binding returns the immutable admitted identity for presentation only. +func (r *Request) Binding() model.Binding { return r.b } diff --git a/core/services/studio/executionrequest/request_test.go b/core/services/studio/executionrequest/request_test.go new file mode 100644 index 000000000..bc2c46b1a --- /dev/null +++ b/core/services/studio/executionrequest/request_test.go @@ -0,0 +1,376 @@ +// SPDX-License-Identifier: MIT +package executionrequest_test + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + shell "github.com/mudler/LocalAI/core/services/studio/executionrequest" + model "github.com/mudler/LocalAI/pkg/addons/model/executionstatus" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestRequest(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Request-local execution observation") +} + +type timer struct { + fn func() + stopped bool +} + +func (t *timer) Stop() bool { old := t.stopped; t.stopped = true; return !old } + +type clock struct { + mu sync.Mutex + now time.Time + timers []*timer +} + +func (c *clock) Now() time.Time { c.mu.Lock(); defer c.mu.Unlock(); return c.now } +func (c *clock) AfterFunc(_ time.Duration, fn func()) shell.Timer { + c.mu.Lock() + defer c.mu.Unlock() + t := &timer{fn: fn} + c.timers = append(c.timers, t) + return t +} +func (c *clock) advance(d time.Duration) { + c.mu.Lock() + c.now = c.now.Add(d) + ts := append([]*timer(nil), c.timers...) + c.mu.Unlock() + for _, t := range ts { + t.fn() + } +} + +// deadlineConsumer delivers an expiry at the validation boundary without a wall timer. +type deadlineConsumer struct { + context.Context + deadline time.Time + done chan struct{} +} + +func (c *deadlineConsumer) Deadline() (time.Time, bool) { return c.deadline, true } +func (c *deadlineConsumer) Done() <-chan struct{} { return c.done } +func (c *deadlineConsumer) Err() error { + select { + case <-c.done: + return context.DeadlineExceeded + default: + return nil + } +} + +var _ = Describe("Reducer shell", func() { + var b model.Binding + var c *clock + var io shell.IO + BeforeEach(func() { + b = model.Binding{Actor: "actor/credential", Addon: "renamed", Generation: "1", Incarnation: "first", ExecutionTool: "run", StatusTool: "health"} + c = &clock{now: time.Now()} + io.Validate = func(context.Context, model.Binding) error { return nil } + io.Read = func(context.Context, model.Binding, model.ObservationID, time.Time) (model.Payload, error) { + return model.ValidPayload(model.Value{Readiness: model.Available, Isolation: model.Isolated}), nil + } + }) + It("coalesces concurrent prompt/status/egress consumers into one exact read", func() { + entered := make(chan struct{}) + release := make(chan struct{}) + calls := 0 + var mu sync.Mutex + io.Read = func(ctx context.Context, got model.Binding, q model.ObservationID, deadline time.Time) (model.Payload, error) { + mu.Lock() + calls++ + mu.Unlock() + if got != b || q != "q" || !deadline.Equal(c.Now().Add(5*time.Second)) { + return model.InvalidPayload(), errors.New("wrong binding") + } + close(entered) + <-release + return model.ValidPayload(model.Value{Readiness: model.Available, Isolation: model.Isolated}), nil + } + r := shell.New(context.Background(), b, "q", model.Eligible, io, c) + defer r.Close() + results := make(chan model.Value, 3) + for range 3 { + go func() { results <- r.Observe(context.Background()) }() + } + <-entered + close(release) + for range 3 { + Expect(<-results).To(Equal(model.Value{Readiness: model.Available, Isolation: model.Isolated})) + } + mu.Lock() + Expect(calls).To(Equal(1)) + mu.Unlock() + Expect(r.Observe(context.Background()).Isolation).To(Equal(model.Isolated)) + reads := 0 + for _, tr := range r.Trace() { + for _, e := range tr.Effects { + if _, ok := e.(model.Read); ok { + reads++ + } + } + } + Expect(reads).To(Equal(1)) + }) + for _, kind := range []string{"failure", "malformed"} { + It("never retries settled "+kind, func() { + calls := 0 + io.Read = func(context.Context, model.Binding, model.ObservationID, time.Time) (model.Payload, error) { + calls++ + if kind == "failure" { + return model.InvalidPayload(), errors.New("offline") + } + return model.InvalidPayload(), nil + } + r := shell.New(context.Background(), b, "q", model.Eligible, io, c) + defer r.Close() + Expect(r.Observe(context.Background())).To(Equal(model.Unknown())) + Expect(r.Observe(context.Background())).To(Equal(model.Unknown())) + Expect(calls).To(Equal(1)) + Expect(r.Trace()[2].Rule).To(Equal(model.R4)) + }) + } + It("settles missing contracts without dispatch", func() { + io.Read = func(context.Context, model.Binding, model.ObservationID, time.Time) (model.Payload, error) { + Fail("unexpected dispatch") + return model.InvalidPayload(), nil + } + r := shell.New(context.Background(), b, "q", "no-contract", io, c) + defer r.Close() + Expect(r.Observe(context.Background())).To(Equal(model.Unknown())) + }) + It("times out at five seconds, cancels the read and ignores late success", func() { + entered := make(chan struct{}) + canceled := make(chan struct{}) + release := make(chan struct{}) + returned := make(chan struct{}) + io.Read = func(ctx context.Context, _ model.Binding, _ model.ObservationID, _ time.Time) (model.Payload, error) { + close(entered) + <-ctx.Done() + close(canceled) + <-release + defer close(returned) + return model.ValidPayload(model.Value{Readiness: model.Available, Isolation: model.Isolated}), nil + } + r := shell.New(context.Background(), b, "q", model.Eligible, io, c) + defer r.Close() + out := make(chan model.Value, 1) + go func() { out <- r.Observe(context.Background()) }() + <-entered + c.advance(5 * time.Second) + Expect(<-out).To(Equal(model.Unknown())) + <-canceled + close(release) + <-returned + Expect(r.Observe(context.Background())).To(Equal(model.Unknown())) + }) + It("uses the earlier parent deadline", func() { + deadline := c.Now().Add(time.Second) + ctx, cancel := context.WithDeadline(context.Background(), deadline) + defer cancel() + io.Read = func(_ context.Context, _ model.Binding, _ model.ObservationID, d time.Time) (model.Payload, error) { + if !d.Equal(deadline) { + return model.InvalidPayload(), errors.New("wrong deadline") + } + return model.ValidPayload(model.Value{Readiness: model.Available, Isolation: model.Host}), nil + } + r := shell.New(ctx, b, "q", model.Eligible, io, c) + defer r.Close() + Expect(r.Observe(context.Background()).Isolation).To(Equal(model.Host)) + }) + for _, reason := range []string{"close", "parent-cancel", "disable", "same-generation-incarnation", "current-selection"} { + It("withdraws pending reads on "+reason, func() { + entered := make(chan struct{}) + canceled := make(chan struct{}) + release := make(chan struct{}) + io.Read = func(ctx context.Context, _ model.Binding, _ model.ObservationID, _ time.Time) (model.Payload, error) { + close(entered) + <-ctx.Done() + close(canceled) + <-release + return model.ValidPayload(model.Value{Readiness: model.Available, Isolation: model.Isolated}), nil + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + r := shell.New(ctx, b, "q", model.Eligible, io, c) + defer r.Close() + out := make(chan model.Value, 1) + go func() { out <- r.Observe(context.Background()) }() + <-entered + switch reason { + case "close": + r.Close() + case "parent-cancel": + cancel() + default: + r.Invalidate(model.Cause(reason)) + } + Expect(<-out).To(Equal(model.Unknown())) + <-canceled + close(release) + Expect(r.Observe(context.Background())).To(Equal(model.Unknown())) + }) + } + It("checks freshness after the carrier returns and on every later consumption", func() { + var mu sync.Mutex + valid := true + io.Validate = func(context.Context, model.Binding) error { + mu.Lock() + defer mu.Unlock() + if !valid { + return errors.New("replaced") + } + return nil + } + r := shell.New(context.Background(), b, "q", model.Eligible, io, c) + defer r.Close() + Expect(r.Observe(context.Background()).Isolation).To(Equal(model.Isolated)) + mu.Lock() + valid = false + mu.Unlock() + Expect(r.Observe(context.Background())).To(Equal(model.Unknown())) + }) + It("rejects a stale successful carrier reply before publishing", func() { + io.Validate = func(context.Context, model.Binding) error { return errors.New("incarnation replaced") } + r := shell.New(context.Background(), b, "q", model.Eligible, io, c) + defer r.Close() + Expect(r.Observe(context.Background())).To(Equal(model.Unknown())) + for _, tr := range r.Trace() { + Expect(tr.Rule).NotTo(Equal(model.R3)) + } + }) + It("keeps independent actors and requests separate", func() { + r := shell.New(context.Background(), b, "q", model.Eligible, io, c) + defer r.Close() + other := b + other.Actor = "other" + s := shell.New(context.Background(), other, "other-q", model.Eligible, io, c) + defer s.Close() + r.Invalidate("permission") + Expect(r.Observe(context.Background())).To(Equal(model.Unknown())) + Expect(s.Observe(context.Background()).Isolation).To(Equal(model.Isolated)) + }) + It("does not return a positive result to a consumer canceled during freshness validation", func() { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + validations := 0 + io.Validate = func(context.Context, model.Binding) error { + validations++ + if validations == 2 { + cancel() + } + return nil + } + r := shell.New(context.Background(), b, "q", model.Eligible, io, c) + defer r.Close() + Expect(r.Observe(ctx)).To(Equal(model.Unknown())) + }) + + for _, kind := range []string{"canceled", "deadline-exceeded"} { + It("keeps consumer validation "+kind+" local to the consumer", func() { + var consumer context.Context + var expire func() + wantErr := context.Canceled + if kind == "canceled" { + consumer, expire = context.WithCancel(context.Background()) + defer expire() + } else { + ctx := &deadlineConsumer{Context: context.Background(), deadline: c.Now().Add(time.Second), done: make(chan struct{})} + consumer = ctx + expire = func() { close(ctx.done) } + wantErr = context.DeadlineExceeded + } + reads := 0 + io.Read = func(context.Context, model.Binding, model.ObservationID, time.Time) (model.Payload, error) { + reads++ + return model.ValidPayload(model.Value{Readiness: model.Available, Isolation: model.Isolated}), nil + } + io.Validate = func(ctx context.Context, _ model.Binding) error { + if ctx == consumer { + expire() + return ctx.Err() + } + return nil + } + r := shell.New(context.Background(), b, "q", model.Eligible, io, c) + defer r.Close() + Expect(r.Observe(context.Background()).Isolation).To(Equal(model.Isolated)) + Expect(r.Observe(consumer)).To(Equal(model.Unknown())) + Expect(consumer.Err()).To(Equal(wantErr)) + Expect(r.Observe(context.Background()).Isolation).To(Equal(model.Isolated)) + Expect(reads).To(Equal(1)) + rules := []model.Rule{} + readEffects := 0 + for _, tr := range r.Trace() { + rules = append(rules, tr.Rule) + for _, effect := range tr.Effects { + if _, ok := effect.(model.Read); ok { + readEffects++ + } + } + } + Expect(rules).To(Equal([]model.Rule{model.R1, model.R2, model.R3, model.R7, model.R7})) + Expect(readEffects).To(Equal(1)) + }) + } + + It("matches the normative success/duplicate-probe/close rule and effect trace", func() { + r := shell.New(context.Background(), b, "q", model.Eligible, io, c) + Expect(r.Observe(context.Background()).Isolation).To(Equal(model.Isolated)) + Expect(r.Observe(context.Background()).Isolation).To(Equal(model.Isolated)) + r.Close() + trace := r.Trace() + rules := []model.Rule{} + for _, tr := range trace { + rules = append(rules, tr.Rule) + } + Expect(rules).To(Equal([]model.Rule{model.R1, model.R2, model.R3, model.R7, model.R10})) + Expect(trace[0].Effects).To(Equal([]model.Effect{model.Publish{Value: model.Unknown()}})) + Expect(trace[1].Effects).To(Equal([]model.Effect{model.Read{B: b, Q: "q", Deadline: int64(5 * time.Second)}, model.Publish{Value: model.Unknown()}})) + Expect(trace[2].Effects).To(Equal([]model.Effect{model.Publish{Value: model.Value{Readiness: model.Available, Isolation: model.Isolated}}})) + Expect(trace[3].Effects).To(BeEmpty()) + Expect(trace[4].Effects).To(Equal([]model.Effect{model.Publish{Value: model.Unknown()}})) + trace[1].Effects[0] = model.Cancel{Q: "tampered"} + Expect(r.Trace()[1].Effects[0]).To(BeAssignableToTypeOf(model.Read{})) + }) + It("expires before the first probe without dispatch", func() { + calls := 0 + io.Read = func(context.Context, model.Binding, model.ObservationID, time.Time) (model.Payload, error) { + calls++ + return model.InvalidPayload(), nil + } + r := shell.New(context.Background(), b, "q", model.Eligible, io, c) + defer r.Close() + c.advance(5 * time.Second) + Expect(r.Observe(context.Background())).To(Equal(model.Unknown())) + Expect(calls).To(BeZero()) + Expect(r.Trace()[1].Rule).To(Equal(model.R9)) + }) + It("fails closed when the freshness callback is absent", func() { + io.Validate = nil + r := shell.New(context.Background(), b, "q", model.Eligible, io, c) + defer r.Close() + Expect(r.Observe(context.Background())).To(Equal(model.Unknown())) + }) + + It("normalizes unavailable readiness and keeps Closed absorbing", func() { + io.Read = func(context.Context, model.Binding, model.ObservationID, time.Time) (model.Payload, error) { + return model.ValidPayload(model.Value{Readiness: model.Unavailable, Isolation: model.Isolated}), nil + } + r := shell.New(context.Background(), b, "q", model.Eligible, io, c) + Expect(r.Observe(context.Background())).To(Equal(model.Value{Readiness: model.Unavailable, Isolation: model.UnknownIsolation})) + r.Close() + r.Invalidate("late") + Expect(r.Observe(context.Background())).To(Equal(model.Unknown())) + Expect(r.Trace()[len(r.Trace())-1].State).To(Equal(model.Closed{})) + }) +}) diff --git a/core/services/studio/executionselection.go b/core/services/studio/executionselection.go new file mode 100644 index 000000000..199f9ed88 --- /dev/null +++ b/core/services/studio/executionselection.go @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MIT +package studio + +import ( + "slices" + + "github.com/mudler/LocalAI/core/services/studio/session" +) + +// AddonToolEligible is the common chat/owner eligibility policy. Agent grants +// only narrow access. Unattended chats without an agent never get action tools. +func AddonToolEligible(d AddonTool, rec session.ChatRecord, grants []string) bool { + return !slices.Contains(rec.AddonsOff, d.AddonID) && !reservedToolNames[d.Name] && + (rec.AgentID == "" || slices.Contains(grants, d.AddonID)) && + !(d.Action && rec.Origin != "" && rec.AgentID == "") +} + +// SelectExecutionProvider uses the same install ordering and pinned-idle rule +// as chat shadow resolution. Installed but ineligible pins never fall through. +func SelectExecutionProvider(defs []AddonTool, installed []AddonShadower, rec session.ChatRecord, grants []string, pin string) (AddonTool, bool) { + var ids []string + when := map[string]string{} + sh := shadowers{byName: map[string][]string{}} + for _, d := range installed { + if slices.Contains(d.Tools, "bash") { + sh.byName["bash"] = append(sh.byName["bash"], d.AddonID) + } + } + for _, d := range defs { + if d.Shadows == "bash" && AddonToolEligible(d, rec, grants) { + if !slices.Contains(ids, d.AddonID) { + ids = append(ids, d.AddonID) + } + when[d.AddonID] = d.InstalledAt + } + } + if sh.pinnedButIdle("bash", pin, ids) { + return AddonTool{}, false + } + chosen := ChooseProvider(ids, when, pin) + for _, d := range defs { + if chosen != "" && d.AddonID == chosen && d.Shadows == "bash" && AddonToolEligible(d, rec, grants) { + return d, true + } + } + return AddonTool{}, false +} diff --git a/core/services/studio/executionselection_test.go b/core/services/studio/executionselection_test.go new file mode 100644 index 000000000..782302b61 --- /dev/null +++ b/core/services/studio/executionselection_test.go @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +package studio + +import ( + "github.com/mudler/LocalAI/core/services/studio/session" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("execution binding selection policy", func() { + It("uses oldest install ordering and never substitutes another installed pinned-idle provider", func() { + defs := []AddonTool{{Name: "addon__new__run", AddonID: "new", Shadows: "bash", Action: true, InstalledAt: "2025-02-01T00:00:00Z"}, {Name: "addon__old__run", AddonID: "old", Shadows: "bash", Action: true, InstalledAt: "2025-01-01T00:00:00Z"}} + installed := []AddonShadower{{AddonID: "new", Tools: []string{"bash"}}, {AddonID: "old", Tools: []string{"bash"}}} + rec := session.ChatRecord{} + selected, ok := SelectExecutionProvider(defs, installed, rec, nil, "") + Expect(ok).To(BeTrue()) + Expect(selected.AddonID).To(Equal("old")) + rec.AddonsOff = []string{"old"} + _, ok = SelectExecutionProvider(defs, installed, rec, nil, "old") + Expect(ok).To(BeFalse()) + selected, ok = SelectExecutionProvider(defs, installed, rec, nil, "not-installed") + Expect(ok).To(BeTrue()) + Expect(selected.AddonID).To(Equal("new")) + _, ok = SelectExecutionProvider(defs[:1], installed, session.ChatRecord{}, nil, "old") + Expect(ok).To(BeFalse()) + }) +}) diff --git a/core/services/studio/service.go b/core/services/studio/service.go index 66851a65b..64b7d262c 100644 --- a/core/services/studio/service.go +++ b/core/services/studio/service.go @@ -424,7 +424,7 @@ func New(d Deps) (*Service, error) { s.shared = sharedfolders.New(sharedfolders.Options{Files: d.Files, Consents: d.PageConsents, Audit: d.SharedFolderAudit, Users: s.folderUsers()}) // A chat mounts the shared folders of its owner at /workspace/shared-. mgr, err := session.New(session.Options{Store: d.Store, Workspace: d.Files.Store(), Harness: h, StateDir: d.StateDir, Inputs: s.inputs, ChatTools: s.chatTools, - Shared: s.shared.Roots, Resolve: s.resolveAccess, Logger: log}) + Shared: s.shared.Roots, Resolve: s.resolveAccess, ProductScope: s.productScope, Logger: log}) if err != nil { return nil, err } diff --git a/core/services/studio/session/chat.go b/core/services/studio/session/chat.go index 067e8035b..1726bf4e0 100644 --- a/core/services/studio/session/chat.go +++ b/core/services/studio/session/chat.go @@ -854,6 +854,18 @@ func (c *Chat) runTurn(text string, refresh bool, media []Media) int { c.mu.Unlock() }() + if f := c.m.o.ProductScope; f != nil { + c.accMu.RLock() + x := c.acc + c.accMu.RUnlock() + var closeScope func() + ctx, closeScope = f(ctx, x) + defer closeScope() + c.mu.Lock() + c.turnCtx = ctx + c.mu.Unlock() + } + c.endMu.Lock() c.emit(Event{Kind: KindTurnStart, Turn: n}) c.endMu.Unlock() diff --git a/core/services/studio/session/chattools.go b/core/services/studio/session/chattools.go index f2f45fcef..99886b75a 100644 --- a/core/services/studio/session/chattools.go +++ b/core/services/studio/session/chattools.go @@ -563,6 +563,11 @@ func (c *Chat) primeChatTools(ctx context.Context) error { c.accMu.RLock() x := c.acc c.accMu.RUnlock() + if scope := c.m.o.ProductScope; scope != nil { + var closeScope func() + ctx, closeScope = scope(ctx, x) + defer closeScope() + } tt, err := callChatTools(ctx, f, x, c.m.log) if mandatoryChatToolsError(err) { return err diff --git a/core/services/studio/session/manager.go b/core/services/studio/session/manager.go index dd54b3e9f..2b11af354 100644 --- a/core/services/studio/session/manager.go +++ b/core/services/studio/session/manager.go @@ -58,6 +58,14 @@ type Options struct { // is dropped. An error from the hook does not fail the turn: it runs with // the sandbox tools only. Nil: only the sandbox tools. ChatTools func(ctx context.Context, x Access) (TurnTools, error) + // ProductScope optionally attaches request-local product state before any + // turn tool construction. Its context is used through the actual turn end; + // close releases the state after checkpoint and persistence, not when + // ChatTools returns. Priming gets a separate scope closed before Open returns. + // The hook must return a non-nil context derived from ctx and a non-nil + // close function. It must not retain state on the chat or manager. Nil + // leaves the context unchanged. Consumers must preserve cancellation. + ProductScope func(ctx context.Context, x Access) (context.Context, func()) // Resolve gives the access of a chat again, for a chat whose sandbox // could not mount its view (a shared folder went away). a is the chat's // actor. Nil: the view is built again for the actor, as UserAccess does. diff --git a/core/services/studio/session/productscope_test.go b/core/services/studio/session/productscope_test.go new file mode 100644 index 000000000..116b87842 --- /dev/null +++ b/core/services/studio/session/productscope_test.go @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: MIT +package session_test + +import ( + "context" + "encoding/json" + "fmt" + "sync/atomic" + "time" + + shell "github.com/mudler/LocalAI/core/services/studio/executionrequest" + "github.com/mudler/LocalAI/core/services/studio/session" + "github.com/mudler/LocalAI/core/services/studio/session/sessiontest" + "github.com/mudler/LocalAI/core/services/studio/workspace" + model "github.com/mudler/LocalAI/pkg/addons/model/executionstatus" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type productScopeKey struct{} +type productScope struct { + request *shell.Request + q model.ObservationID + closed atomic.Bool +} + +var _ = Describe("product request scopes", func() { + newChat := func(o session.Options) *session.Chat { + o.Store, o.StateDir = sessiontest.NewMemoryStore(), GinkgoT().TempDir() + m, err := session.New(o) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { Expect(m.Close(context.Background())).To(Succeed()) }) + chat, err := openAs(m, context.Background(), workspace.Actor{Principal: "alice", ChatID: "c1"}, "c1") + Expect(err).NotTo(HaveOccurred()) + return chat + } + waitTurn := func(chat *session.Chat) { + timer := time.NewTimer(5 * time.Second) + defer timer.Stop() + for { + select { + case e, open := <-chat.Events(): + Expect(open).To(BeTrue(), "event stream closed before turn end") + if e.Kind == session.KindTurnEnd { + return + } + case <-timer.C: + Fail("turn did not end") + } + } + } + + It("keeps one scope through prompt/status/egress and isolates priming and subsequent turns", func() { + errors := make(chan error, 16) + report := func(err error) error { errors <- err; return err } + var serial, reads atomic.Int32 + scopes := make(chan *productScope, 3) + consumed := make(chan model.ObservationID, 7) + b := model.Binding{Actor: "alice", Addon: "renamed-provider", Generation: "1", Incarnation: "one", ExecutionTool: "run", StatusTool: "health"} + expected := model.Value{Readiness: model.Available, Isolation: model.Isolated} + o := session.Options{Harness: &sessiontest.FakeHarness{Turns: [][]sessiontest.Step{ + {{Tool: "status", Args: "{}"}, {Tool: "egress", Args: "{}"}}, + {{Tool: "status", Args: "{}"}, {Tool: "egress", Args: "{}"}}, + }}} + o.ProductScope = func(ctx context.Context, x session.Access) (context.Context, func()) { + if x.Actor().ChatID != "c1" { + _ = report(fmt.Errorf("wrong chat ID")) // report queues the failure for the spec goroutine. + } + s := &productScope{q: model.ObservationID(fmt.Sprint(serial.Add(1)))} + s.request = shell.New(ctx, b, s.q, model.Eligible, shell.IO{ + Read: func(ctx context.Context, got model.Binding, q model.ObservationID, deadline time.Time) (model.Payload, error) { + reads.Add(1) + if got != b || q != s.q || time.Until(deadline) > 5*time.Second { + return model.InvalidPayload(), fmt.Errorf("wrong admission") + } + return model.ValidPayload(expected), nil + }, + Validate: func(context.Context, model.Binding) error { return nil }, + }, nil) + scopes <- s + return context.WithValue(ctx, productScopeKey{}, s), func() { s.request.Close(); s.closed.Store(true) } + } + o.ChatTools = func(ctx context.Context, _ session.Access) (session.TurnTools, error) { + s, ok := ctx.Value(productScopeKey{}).(*productScope) + if !ok { + return session.TurnTools{}, report(fmt.Errorf("missing scope")) + } + if s.closed.Load() || s.request.Observe(ctx) != expected { + return session.TurnTools{}, report(fmt.Errorf("prompt scope not live")) + } + consumed <- s.q + var tools []session.Tool + for _, name := range []string{"status", "egress"} { + tools = append(tools, session.Tool{Name: name, Description: name, Schema: json.RawMessage(`{"type":"object"}`), Run: func(ctx context.Context, _ json.RawMessage) (string, error) { + current, ok := ctx.Value(productScopeKey{}).(*productScope) + if !ok || current != s || s.closed.Load() || s.request.Observe(ctx) != expected { + return "", report(fmt.Errorf("scope not live or shared")) + } + consumed <- s.q + return "ok", nil + }}) + } + return session.TurnTools{Tools: tools, Prompt: "stable prompt"}, nil + } + chat := newChat(o) + var prime *productScope + Eventually(scopes, 5*time.Second).Should(Receive(&prime)) + Expect(prime.closed.Load()).To(BeTrue()) + for range 2 { + Expect(chat.Send(context.Background(), session.Message{Text: "hello"})).To(Succeed()) + waitTurn(chat) + var s *productScope + Eventually(scopes, 5*time.Second).Should(Receive(&s)) + Expect(s.closed.Load()).To(BeTrue()) + } + Expect(errors).To(BeEmpty()) + Expect(serial.Load()).To(Equal(int32(3))) + Expect(reads.Load()).To(Equal(int32(3))) + var got []model.ObservationID + for len(consumed) > 0 { + got = append(got, <-consumed) + } + Expect(got).To(Equal([]model.ObservationID{"1", "2", "2", "2", "3", "3", "3"})) + }) + + for _, stop := range []bool{false, true} { + It(fmt.Sprintf("closes pending observations at the real turn boundary (stop=%t)", stop), func() { + errors := make(chan error, 8) + var serial atomic.Int32 + scopes := make(chan *productScope, 2) + entered, canceled := make(chan struct{}, 2), make(chan struct{}, 2) + results := make(chan model.Value, 2) + block := make(chan struct{}) + h := &sessiontest.FakeHarness{} + if stop { + h.Turns = [][]sessiontest.Step{{{Block: block}}} + } + o := session.Options{Harness: h} + o.ProductScope = func(ctx context.Context, _ session.Access) (context.Context, func()) { + s := &productScope{q: model.ObservationID(fmt.Sprint(serial.Add(1)))} + s.request = shell.New(ctx, model.Binding{Actor: "alice", Addon: "renamed", ExecutionTool: "run", StatusTool: "health"}, s.q, model.Eligible, shell.IO{ + Read: func(ctx context.Context, _ model.Binding, _ model.ObservationID, _ time.Time) (model.Payload, error) { + entered <- struct{}{} + <-ctx.Done() + canceled <- struct{}{} + return model.InvalidPayload(), ctx.Err() + }, Validate: func(context.Context, model.Binding) error { return nil }, + }, nil) + scopes <- s + return context.WithValue(ctx, productScopeKey{}, s), func() { s.request.Close(); s.closed.Store(true) } + } + o.ChatTools = func(ctx context.Context, _ session.Access) (session.TurnTools, error) { + s, ok := ctx.Value(productScopeKey{}).(*productScope) + if !ok { + err := fmt.Errorf("missing scope") + errors <- err + return session.TurnTools{}, err + } + go func() { results <- s.request.Observe(context.Background()) }() + select { + case <-entered: + case <-time.After(2 * time.Second): + err := fmt.Errorf("observation did not start") + errors <- err + return session.TurnTools{}, err + } + return session.TurnTools{}, nil + } + chat := newChat(o) + var prime *productScope + Eventually(scopes, 5*time.Second).Should(Receive(&prime)) + Expect(prime.closed.Load()).To(BeTrue()) + Eventually(canceled).Should(Receive()) + Eventually(results).Should(Receive(Equal(model.Unknown()))) + Expect(chat.Send(context.Background(), session.Message{Text: "hello"})).To(Succeed()) + var turn *productScope + Eventually(scopes).Should(Receive(&turn)) + if stop { + Eventually(h.Sent).Should(HaveLen(1)) + Expect(turn.closed.Load()).To(BeFalse(), "scope must outlive ChatTools") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + Expect(chat.Stop(ctx)).To(Succeed()) + } + waitTurn(chat) + Expect(turn.closed.Load()).To(BeTrue()) + Eventually(canceled).Should(Receive()) + Eventually(results).Should(Receive(Equal(model.Unknown()))) + Expect(errors).To(BeEmpty()) + }) + } +}) diff --git a/core/services/studio/store/json.go b/core/services/studio/store/json.go index e5b7fbe1f..f27ed3e91 100644 --- a/core/services/studio/store/json.go +++ b/core/services/studio/store/json.go @@ -24,8 +24,7 @@ import ( // jsonStore keeps chats in memory and, when it has a directory, mirrors them to // files: one small record file per chat (rewritten atomically), one // append-only transcript file per chat and one harness state file per chat -// (written only when the state changes). One process owns the directory: the -// locks below are in-process only, so two LocalAI processes must not share it. +// (written only when the state changes). One process owns the directory. // A cluster uses the GORM store. // // mu guards the map only. Each chat has its own lock, held for the whole @@ -72,6 +71,10 @@ func NewJSON(dir string) (Store, error) { if dir == "" { return s, nil } + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, err + } + if err := os.MkdirAll(s.chatDir(), 0o700); err != nil { return nil, err } @@ -421,7 +424,7 @@ func (s *jsonStore) CreateChat(_ context.Context, rec session.ChatRecord) error if _, ok := s.chats[rec.ID]; ok { return session.ErrExists } - c := chatState{Rec: rec} + c := chatState{Rec: cloneRec(rec)} c.Rec.HarnessState = nil c.Rec.Egress = rec.Egress.Clone() c.Rec.Goal = rec.Goal.Clone() @@ -460,9 +463,30 @@ func (s *jsonStore) GetChat(_ context.Context, principal, id string) (session.Ch func cloneRec(r session.ChatRecord) session.ChatRecord { r.SkillsOff = append([]string(nil), r.SkillsOff...) r.AddonsOff = append([]string(nil), r.AddonsOff...) + r.McpOff = append([]string(nil), r.McpOff...) r.BuiltinsOff = append([]string(nil), r.BuiltinsOff...) r.BuiltinsOn = append([]string(nil), r.BuiltinsOn...) - r.McpOff = append([]string(nil), r.McpOff...) + if r.HostConsent != nil { + cp := *r.HostConsent + r.HostConsent = &cp + } + if r.Snapshot != nil { + cp := *r.Snapshot + r.Snapshot = &cp + } + if r.Settings.Temperature != nil { + cp := *r.Settings.Temperature + r.Settings.Temperature = &cp + } + if r.Settings.TopP != nil { + cp := *r.Settings.TopP + r.Settings.TopP = &cp + } + if r.Settings.TopK != nil { + cp := *r.Settings.TopK + r.Settings.TopK = &cp + } + r.HarnessState = append([]byte(nil), r.HarnessState...) r.Egress = r.Egress.Clone() r.Goal = r.Goal.Clone() @@ -499,6 +523,7 @@ func (s *jsonStore) edit(principal, id string, change func(next *chatState) erro } defer unlock() next := c.chatState + next.Rec = cloneRec(c.Rec) if err := change(&next); err != nil { return err } @@ -513,6 +538,7 @@ func (s *jsonStore) edit(principal, id string, change func(next *chatState) erro xlog.Warn("studio store: could not remove an old harness state file", "chat", id, "error", err) } } + next.Rec = cloneRec(next.Rec) c.chatState = next return nil } diff --git a/core/services/studio/toolnames_internal_test.go b/core/services/studio/toolnames_internal_test.go new file mode 100644 index 000000000..75b9dba47 --- /dev/null +++ b/core/services/studio/toolnames_internal_test.go @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +package studio + +import ( + "strings" + + "github.com/mudler/LocalAI/core/services/studio/session" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("add-on stand-in identities", func() { + It("recognizes valid long stand-ins but rejects malformed identities", func() { + for _, id := range []string{"runner", "r" + strings.Repeat("x", 63)} { + Expect(standInRunner([]session.Tool{{Name: unavailableToolName(id), StandInFor: []string{"bash"}}})).To(Equal(id)) + } + for _, name := range []string{"addon____unavailable", "addon__bad_id__unavailable", "addon__9runner__unavailable", "addon__runner__bash", "addon__runner__unavailable__extra", "runner__unavailable"} { + Expect(standInRunner([]session.Tool{{Name: name, StandInFor: []string{"bash"}}})).To(BeEmpty(), name) + } + Expect(standInRunner([]session.Tool{{Name: unavailableToolName("runner")}})).To(BeEmpty()) + }) +}) diff --git a/core/services/studio/triggersrestart_test.go b/core/services/studio/triggersrestart_test.go index 8d2a96fb3..7a6e23280 100644 --- a/core/services/studio/triggersrestart_test.go +++ b/core/services/studio/triggersrestart_test.go @@ -31,6 +31,12 @@ func openFrontend(dir string, sh *shared) *shared { return &shared{chats: st, files: sh.files, agents: ag, notices: ns, trg: ts, clock: sh.clock, addons: sh.addons} } +// Stop every producer before reopening the files in the next frontend. +func closeRestartFrontend(r *trigRig) { + r.host.Close() + Expect(r.svc.Close(context.Background())).To(Succeed()) +} + var _ = Describe("triggersrestart", func() { It("agents triggers and notifications survive a restart with no database", func() { ctx, dir := context.Background(), GinkgoT().TempDir() @@ -41,7 +47,7 @@ var _ = Describe("triggersrestart", func() { ag, err := r.svc.SaveAgent(ctx, alice, agents.Agent{Name: "Reviewer", Addons: []string{"office"}}) Expect(err).NotTo(HaveOccurred()) tr := r.schedule(studio.TriggerInput{AgentID: ag.ID, Kind: triggers.KindSchedule, Schedule: "once 2026-10-27 08:00", TZ: "UTC"}) - r.host.Close() + closeRestartFrontend(r) // The server was down across the due time. The new process finds everything. base.clock.Advance(2 * time.Hour) @@ -60,7 +66,7 @@ var _ = Describe("triggersrestart", func() { r2.pass() runs, _ := r2.host.TriggerRuns(ctx, alice, tr.ID, 0) Expect(runs).To(HaveLen(1), "a missed once-trigger ran %d times", len(runs)) - r2.host.Close() + closeRestartFrontend(r2) // A third start still has the notification and does not run the trigger again. third := openFrontend(dir, base) @@ -78,7 +84,7 @@ var _ = Describe("triggersrestart", func() { base := newShared() r := openFrontend(dir, base).rig(saySummary, studio.TriggerOptions{}) tr := r.schedule(studio.TriggerInput{}) - r.host.Close() + closeRestartFrontend(r) base.clock.Advance(3*24*time.Hour + time.Hour) r2 := openFrontend(dir, base).rig(saySummary, studio.TriggerOptions{}) diff --git a/deploy/addons/host-policy.json b/deploy/addons/host-policy.json new file mode 100644 index 000000000..152459252 --- /dev/null +++ b/deploy/addons/host-policy.json @@ -0,0 +1,11 @@ +{ + "schemaVersion": 1, + "grants": { + "fine-tuning": ["finetune."], + "quantization": ["quantize."], + "sandbox": ["workspace.", "egress."], + "office": ["workspace."], + "web": ["workspace."], + "telegram": ["connector."] + } +} diff --git a/docs/content/development/addon-testing.md b/docs/content/development/addon-testing.md index 9533977d8..6a25a8422 100644 --- a/docs/content/development/addon-testing.md +++ b/docs/content/development/addon-testing.md @@ -6,8 +6,53 @@ description = "Prepare and verify the toolchain for focused addon tests." The addon verification scripts separate explicit dependency setup from offline checks. Run all commands from the repository root. `scripts/addons/suites.json` lists the available suites and their exact case inventories. +For typed extensions, run `contracts`, `apps`, `runtime`, `gateway`, and `host-slots`. +The `contracts` suite includes all official package manifests and generic architecture guards. It runs `./pkg/addons/contract`, +`./pkg/addons/packaging`, and the root architecture suite, each with an exact +Ginkgo report inventory (including twelve official manifest audits, two policy +packaging checks, and two architecture guards). `apps` inventories the generic +UI fixtures; `gateway` inventories the real application owner binding fixture. +The latter installs two differently named packages through the actual catalog +and Manager, uses JSON chat/agent stores, selects the oldest provider, then pins +the second and checks each declared execution/status tool and runtime identity. +The gateway callback-only test separately checks honest binding pass-through. + +The shared `scripts/addons/ginkgo_report.py` comparator checks actual report +artifacts, not just JSON syntax. Every required case must pass exactly once. +A report may declare an explicit `focus` matching its command's Ginkgo focus; +only excluded specs may be skipped, never required cases. Harness self-tests +exercise missing artifacts, missing required cases, and skipped reports. Unknown suites, empty selections, skipped required tests, and failed commands return a nonzero status. +## Direct verification evidence and limits + +The bounded review verification used offline caches, `GOMAXPROCS=2`, and +`GOFLAGS='-mod=readonly -p=2 "-ldflags=-s -w"'`. Direct commands used +`go test -timeout=5m -count=1 -tags auth PACKAGE -ginkgo.json-report=REPORT` +for `./pkg/addons/contract`, `./pkg/addons/packaging`, `.`, +`./core/services/addons/apps`, +`./core/services/addons/gateway`, and `./core/application`. +Actual reports compared with the harness comparator contain 196 contract, +174 packaging, 2 architecture, 10 apps, and 115 gateway cases, all passed. +The focused `execution binding owner resolver` run passed all 8 required cases; +its excluded specs are not required cases. Full application testing exited zero +with 256 passed and 3 PostgreSQL-dependent Files cases skipped because +`LOCALAI_TEST_POSTGRES_URL` was unset; it is not an all-cases acceptance pass. + +`sh scripts/addons/prerequisites_test.sh` passed its exact 38-case inventory, +including missing/skipped report rejection. Model race testing and viewer tests +passed, and `go run ./pkg/addons/model/executionstatus/cmd/viewer -check` +verified trace coverage and deterministic data. Focused real owner artifacts +with a removed or skipped required case were also rejected by the comparator. +Existing contract coverage passed without an invented failing baseline. + +These are direct checks, **not a full harness pass**. The commands and report +inventories are wired, but `sh scripts/addons/prepare.sh --check` remains blocked +by missing `protoc` 26.1. No downloads or integration preparation were performed. +Browser acceptance and named-user SQL authority integration remain unverified; +the JSON owner fixture does not establish either. Local evidence reports and +logs are under `.cache/addons/logs/review2-*` and are not product artifacts. + ## Required tools Provide these tools before running setup: @@ -480,3 +525,58 @@ The recovery suite above remains required for crash boundaries during backup, staging, migration and publication, including format-changing recovery-required outcomes. The focused submission tests do not replace the real executable, real-auth HTTP or rendered-browser acceptance gates. + +### Current-read execution bindings + +`ApplicationConfig.ChatSelectionSnapshot` returns synchronized, detached current +provider pins and host execution mode. Store reads preserve actor scope and return +detached records. JSON stores use their existing mutexes and atomic writes; SQL +stores use ordinary current reads. No revision ledger or directory lock is needed. + +The gateway compares current selection inputs and runtime identity before dispatch +and after the reply. The reducer binds explicit actor, credential, conversation, +provider, tools and runtime identity. These checks are advisory, not a transaction +or a guarantee to detect revoke-and-restore changes between reads. + +### Model action contract milestone + +Run from the repository root (these packages share the root Go module): + +```sh +go test -count=1 -v -tags auth ./pkg/addons/contract ./core/services/addons/apps ./core/services/addons/packages ./core/services/addons/runtime ./addons/internal/protocol ./core/config +``` + +`modelactions_test.go` covers strict keys, capability exclusion, traversal and +SDK path/count bounds, full manifest round trips and owning manifests. Apps +specs exercise actual installed projection and slice isolation. The package and +protocol declaration matrices include model actions independently and together +with all other extensions. Runtime specs verify exact registration equality, +crash recovery, receipt replay and immutable registry snapshots. Core/config +owns vocabulary parity; the contract package does not import core/config. +Exact contract, Apps, package and protocol spec names are in +`scripts/addons/suites.json`. These unit checks do not certify browser dispatch, +PostgreSQL services, or full-system harness prerequisites. + + +### Model launch and canonical route regressions + +`core/http/react-ui/e2e/addon-model-actions.spec.js` exercises the production +React InstalledModels inspector, gallery Open and ModelPage Overview renderer. +Each surface covers arbitrary single providers, deterministic named collisions, +encoded selected model values, absence without install promotion and stale +installation refusal. The removed-route case asserts NotFound without redirect. +The tests mock HTTP responses; they do not verify backend authorization or +multi-node integration. Node tests in `addonmodelactions.test.mjs` cover bounded +metadata and dispatch rejection after actor, session, generation, entry or +page/path changes. `legacyroutes.test.mjs` retains native chat handoff checks. + +The canonical-address fixture audit includes audio-transform, chats-switches-real, +chrome-audit, diarization-profiles, media-history, navigation, no-edge-rails, +page-render-smoke, sound-expansion, every studio spec, talk, taste-polish, +threed-animation, threed-gen and addon-navigation. Existing cases remain; +standalone addon addresses become declared fixture pages, and selected models +travel as query values. Addon-local navigation adapter coverage stays in place. + +The execution-status viewer defaults to `viewer.html`. Use `-o /path/to/viewer.html` +to choose an output location explicitly; it does not read environment variables. +`-check` alone verifies the model without writing a file. diff --git a/docs/content/features/addons.md b/docs/content/features/addons.md index 5f738fa69..d389ef742 100644 --- a/docs/content/features/addons.md +++ b/docs/content/features/addons.md @@ -136,6 +136,212 @@ receipt is pruned conflicts on its stale revision rather than applying again. Acknowledgement follows durable persistence; uncertain durability requires reopening and reconciling the operation before further use. +## Optional typed manifest extensions + +These declarations extend the strict manifest contract. They keep +`schemaVersion: 1`, host/API/protocol/data-format level 1 and UI SDK 2. +The Go contract parser is authoritative; there is no separate JSON Schema. +The fragments below merge into an otherwise valid manifest, not a complete +installable package. Existing executable, identity, compatibility and UI +entry/SDK fields remain required. + +### Home page commands + +Inside `capabilities.ui`: + +```json +{ + "pages": ["studio"], + "navigation": ["studio"], + "mainPage": "studio", + "title": {"en": "Studio", "it": "Studio"}, + "icon": "ui/icon.png", + "homeCommands": [{"id": "open", "alias": "studio"}] +} +``` + +`homeCommands` is opt-in and has at most 8 records. Each has a unique `id` +accepted by `contract.ValidName`. Optional `alias` uses the same lower-case, +slash-free identifier syntax; it requests convenience naming, not ownership. +Optional `page` must belong to `pages`, even if not in `navigation`. Omission +uses a nonempty, declared `mainPage`, never the first page or an external URL. + +Optional `label` is a `Localized` string or language map (not a `default` +wrapper), with at most 60 Unicode characters per label and the existing +plain-text and language rules. Omission selects the installed localized title +and, for a non-main destination, the page identifier. Optional `icon` is an +allowlisted semantic host icon or exactly the packaged `ui.icon`; omission +selects the installed app icon, then `puzzle`. Omit unused overrides rather +than sending empty strings or null. No tool, URL, command text, install action +or component can be a command destination. + +### Home commands from installed apps + +Home reads only the authenticated installed Apps registry, never the catalog. +A contribution is actionable only while its app is enabled, running, compatible +with the host SDK, and has validated immutable assets and a declared destination. +Loading, list errors, forbidden access, absence, disabled installations and failed +installations remain distinct; a list error does not mean “not installed”. +Mounted consumers keep bounded polling alive after disable or failure so recovery +appears without reloading. Drawing commands does not load addon JavaScript. + +Every eligible contribution has `/addon::`. Optional aliases +cannot take any native command name, even a hidden native command, or any usable +skill name. If multiple contributions request the same alias, all lose that alias; +the canonical commands remain. Order is addon ID then contribution ID in the Apps +group. Home shows deterministic, actor-visible alias diagnostics to administrators +in its expandable command diagnostics section. Labels do not identify commands. + +Clicking a command or submitting its exact text uses the same navigation guard. +Commands accept no arguments and do not execute prefixes. The guard re-reads the +authenticated installed registry and checks actor, permissions, generation, +immutable entry and declared page before navigating. Disabled, failed, upgraded, +removed or inaccessible apps cannot be opened by a previously captured menu entry. +Logout aborts pending reads; even a response that ignores cancellation cannot +publish into another actor's registry. Installed commands are Home-only; `/apps` +remains generic native navigation to installed apps. Uninstalled apps never get +install commands or promotional examples. Studio owns its `/studio` alias in its +manifest rather than in Home code. Historical URL redirects and pin migration +remain unchanged. + +### Execution provider and status payload + +Inside `capabilities`: + +```json +{ + "executionProvider": { + "tools": ["bash"], + "statusTool": "execution-status" + } +} +``` + +`tools` contains 1–8 unique declared action tools with executable definitions. +The current chat binding requires each bound tool to shadow `bash` through +`shadows`; existing shadow validation still applies, including its uniqueness +rule. This declaration neither grants shadow authority nor changes provider +arbitration or execution authorization. + +`statusTool` is a separately declared `kind: "read"`, `uiOnly: true` tool, +not an execution binding or admin tool. Its input schema is an object accepting +`{}`, with no required inputs, no resource, and `additionalProperties: false`. +It must answer without mutation. An owner-side typed read must invoke it; +the model-facing tool adapter intentionally excludes `uiOnly` tools. + +The existing result envelope carries this JSON in `payload`: + +```json +{ + "readiness": "available", + "isolation": "isolated", + "explanation": "Commands use the configured isolated backend." +} +``` + +`contract.DecodeExecutionStatus` accepts at most 2048 UTF-8 bytes. Both enums +are required: readiness is `available`, `unavailable` or `unknown`; isolation +is `isolated`, `host` or `unknown`. Optional explanation is nonempty plain text, +at most 200 Unicode characters, without markup, controls or direction overrides. +Unknown or duplicate fields, trailing JSON, `outputs`, private `ok` fields, +backend names and warning booleans are not accepted. Invalid payloads return +an error and unknown/unknown, never partial provider data. Non-available +readiness normalizes isolation to unknown. Status describes only the bound +tools of that addon incarnation; it is not a host isolation guarantee. + +Studio consumes this declaration through the owner-selected conversation binding, +not an addon ID or a private settings payload. Prompt construction, command +status and nested egress reads share one observation within a turn. Priming and +standalone command-status or egress requests have separate, bounded scopes. +Consumer cancellation stops that wait; turn completion cancels the observation. +Failed reads settle as unknown and do not retry within the same scope. Each +consumption revalidates the complete owner binding, including conversation, +current selection identity and runtime incarnation. + +The application resolver reads the current principal-scoped chat and agent from +JSON or SQL/GORM stores and revalidates current account credentials in named-user +mode. Local-owner mode maps to the anonymous chat principal. Reads are advisory, +not transactional snapshots: revoke-and-restore changes between reads are not +remembered. Ordinary execution authority and consent remain unchanged. An undeclared +legacy provider does not acquire an isolation claim. Sandbox retains its existing +private Settings status response and separately declares the typed page-only +`execution-status` read. Unknown backends report unknown, never inferred isolation. + +Provider-reported isolation and a declared network control do not establish +host network-policy enforcement. The egress API therefore does not claim policy +applicability for addon command runners until the host can prove enforcement. +Built-in or explicitly host execution continues to report the host-execution +reason. Controls can still identify the addon that draws its settings row. + +The Apps tab binds each list request and registry ingestion to the authenticated +session epoch. A same-user refresh or account switch immediately hides the old +list and rejects its pending responses, even before effect cleanup. A new list +request can populate the refreshed session. These in-memory scopes contain no +credentials. + +### Requested host operations are not grants + +Inside `capabilities`: + +```json +{ + "hostOperations": { + "required": ["workspace.materialize", "workspace.commit"], + "optional": ["egress.check", "egress.audit"] + } +} +``` + +Each name must pass `contract.ValidHostOp` exactly: no wildcard or prefix grant. +There are at most 32 entries total, unique across both lists. An empty object +is invalid. Omission preserves legacy/unspecified requests, not an empty grant +and not a request for every operation. Required operations declare service +prerequisites; optional denials must support degraded operation. Unknown but +syntactically valid names are unsupported, never grants by inference. Requests +do not create host endpoints or change administrator-controlled grants. + +### Official operation audit + +The official manifests request the operations their production hostclient calls use. +Deployment prefixes in `deploy/addons/host-policy.json` remain separate administrator grants. +The generic binary selects no policy automatically. + +| Addon | Required operations | Optional operations | +| --- | --- | --- | +| Fine-tuning | `finetune.checkpoints`, `finetune.dataset`, `finetune.export`, `finetune.get`, `finetune.list`, `finetune.start`, `finetune.stop` | None | +| Quantization | `quantize.get`, `quantize.import`, `quantize.list`, `quantize.start`, `quantize.stop` | None | +| Office | `workspace.export`, `workspace.import` | None | +| Sandbox | `workspace.changes`, `workspace.commit`, `workspace.materialize` | `egress.audit`, `egress.check`, `workspace.admin` | +| Web | None | `workspace.admin` | +| Face, Slack, Studio, Tables, Talk, Telegram, Voice | None; omit `hostOperations` | None | + +Fine-tuning and Quantization include operation names passed through mutation and undo wrappers. +They do not request the host's unused progress operations. +Telegram's historical `connector.` deployment grant does not imply a hostclient call or manufacture a manifest request. + +Sandbox's proxy uses the optional egress operations when configured connections need policy decisions. +Missing grants do not authorize connections; the proxy cannot obtain an allow verdict. +Sandbox's settings and Web's provider settings use `workspace.admin` to check administrators. +Without that optional operation, these settings refuse changes; ordinary tools can remain available. +The shipped deployment policy covers every official required operation. +The package tests compare all twelve manifests with this source audit and the host's registered vocabulary. +Their package-builder fixtures use temporary executable and UI bytes, not official release binaries or frontend build artifacts. + +### Compatibility and release packaging + +New hosts accept old manifests without these fields. Old hosts reject unknown +extension fields during staging. Extension-bearing packages therefore require +the host release introducing these contracts; document that minimum LocalAI +release beside each addon release. An older host must retain its installed +package on refusal. Do not add automatic install retries or promise new error +wording from old binaries. + +Catalog version ranges do not negotiate individual fields and cannot guarantee +eligibility before staging. SDK 2 does not signal support for these manifest +extensions. If older hosts need support, release maintainers must decide whether +and how long to publish separate legacy packages. These declarations alone do +not install packages, grant operations or activate runtime/UI integrations. + ## Singleton owner and its limits Addon state lives in one place. Exactly one LocalAI instance is the **owner** of an @@ -228,7 +434,7 @@ available on this instance: While the key is `false`: - The UI shows no Add-ons entry, no pinned apps, and no fine-tuning or - quantization entry. `/app/addons`, `/app/fine-tune` and `/app/quantize` lead back + quantization entry. `/app/addons` and canonical add-on page addresses lead back to `/app`. The UI does not call `/api/addons/pins` or `/api/addons/builtin`. A server that sends no `addons` key counts as `true` in the UI. - Every path under `/api/addons`, `/api/fine-tuning` and `/api/quantization` @@ -1666,6 +1872,50 @@ person to confirm. ## Host operations +### Required and optional operation admission + +A verified manifest can request exact operation names with +`capabilities.hostOperations.required` and `.optional`. These are requests, +**not grants**. The owner compares them with the host operation vocabulary +and the immutable host policy selected at startup. Legacy manifests without +requests retain their configured behavior. + +Coverage uses the broker's literal, nonempty prefix rule. For example, +`workspace.` covers the requested name `workspace.materialize`; +`workspace.materialize.narrow.` does not. Registry membership is an exact-name +check: a grant cannot make `workspace.future` a supported operation. Requests +are exact names, not prefixes, patterns, or authority for selecting providers. + +The owner verifies and stages a new install or upgrade before admission. +Missing required requests refuse publication, before installed records, +migrations, runtime withdrawal, or process creation. A refused upgrade retains +the old receipt, digest, generation, and running incarnation; it needs no +rollback. Automatic upgrades suppress further attempts for the same failed +candidate until a different candidate appears or the owner restarts. They never +acquire grants or prompt for approval. + +After an operator removes required grants and restarts, the package remains +installed with its enabled intent. Its effective runtime is unavailable +(`failed` in Apps), and the owner admits no launch, tool, resource, connector, +or page access. It does not offer installation as a repair. Restore the host +policy and restart the owner to make it available again. Policy is restart-only: +subsequent manager `SetHostOps` calls do not replace the startup decision. + +Missing optional requests permit a degraded start. Existing admin Apps details +and lifecycle operation results include bounded required, optional, granted, +and missing name lists with counts. Here “granted” counts only supported +**requested** names covered by host policy, not every grant the addon holds. +Ordinary Apps readers receive the generic failed observation without policy +paths, grant details, or missing operation names. Split deployments carry the +owner's decision; a frontend cannot supply grants. + +Admission does not authorize an individual call. The broker still requires a +registered handler and rechecks the live incarnation, operation scope, actor +features/admin status, workspace/chat access, and connector binding. Handlers +are wired as host services start; admission uses the host's startup operation +vocabulary rather than treating not-yet-wired handlers as unknown operations. + + A host operation is how an add-on process asks the host to do something the add-on cannot do itself, such as starting a job of the engine. It is contract surface that third-party add-ons cannot use unless the host setting names them. @@ -1677,8 +1927,11 @@ The rules: default is refuse. - The host setting `--addon-host-ops` (`LOCALAI_ADDON_HOST_OPS`) names the add-ons that get the channel and the operation prefixes each may call, as a - comma list of `addon-id=op-prefix`. The default is - `fine-tuning=finetune.,quantization=quantize.,sandbox=workspace.,sandbox=egress.,office=workspace.,web=workspace.,telegram=connector.`. An empty value names nobody. + comma list of `addon-id=op-prefix`. The generic binary grants nothing when + unconfigured. An explicitly empty value names nobody, including in Docker. + An administrator can instead select a file with `--addon-host-policy`; see + [Administrator host operation policy](#administrator-host-operation-policy) + for precedence and explicit-empty behavior. This is a host setting, not a manifest field and not a model argument. - An add-on that the setting names finds two optional fields in its startup object: `hostEndpoint`, a loopback address of the form @@ -1731,7 +1984,7 @@ scope (today `connector.inbound`): owner is refused with `bad_request` before the operation runs. - The add-on ID is the one the host built the listener for, never a value of the call. The host setting must still name the operation prefix for the add-on - (`telegram=connector.` by default). + (`telegram=connector.` in the stock Docker policy). Every other operation keeps all its checks. An operation cannot become inbound by a field of the request: the scope is part of its registration. @@ -1953,6 +2206,15 @@ definition replaces one with `"shadows": ""`: ...: the built-in bash serves", or that the chat has no such tool when the built-in is off too), from `fallbacks` of `GET /api/chats/{id}/tools`. A tool without `shadows` keeps its add-on name. +- **Qualified identities.** The shared tool adapter formats and parses + `addon____`. Both components must match + `[a-z][a-z0-9-]{0,63}`. Model-callable names cannot exceed 64 characters. + Execution-status binding rejects plain names, malformed names, and names + whose add-on ID differs from the selected provider. The real adapter already + supplies canonical names; this check also guards the binding boundary. + Studio's unavailable stand-ins use the same component grammar but do not + apply the model-callable total length limit. A long stand-in identifies an + unavailable add-on; it does not make that name callable through the adapter. - **Who served it.** A call the add-on served carries the card `tool_provider` with the add-on's ID, so the chat shows the provider on the tool line. - **Several add-ons.** Only one add-on serves a name. An administrator picks it @@ -2960,13 +3222,12 @@ the caller and is sent with `Cache-Control: private, no-store`. days the pins are deleted. A background sweep runs every six hours; with several frontends, one of them runs it at a time. A disabled add-on is not removed, so its pins never expire. A sweep that cannot read the add-on owner changes nothing. -- **Upgrade seed.** A read or a save never pins anything. Once, at the upgrade that adds - this feature, the accounts that exist then get Studio and Talk pinned. Both are - add-ons, so a pin is drawn once the add-on is installed. A seeded pin does not - expire while its add-on was never installed, however long that takes; once the - add-on was installed, its pin follows the retention rule like any other (the sweep - records the first install in the table of seed runs, `addon_pin_seed_runs`, or in - the pins file). An account created later starts with no pins. An unpin stays after a restart. +- **Explicit selection.** New and existing accounts, including the local owner, start + without implicit pins. Startup, reads, and saves never add Studio, Talk, or any other + ID on their own. Existing saved user pins remain unchanged. Explicit pins and unpins + survive restarts and are shared across frontends with an account database. + Every absent ID follows the same 30-day retention rule, even if it was never installed. + The store no longer creates or uses seed metadata; it does not drop existing database tables. - **Startup.** If the pins file cannot be read (it is corrupt), the server does not start and logs the error. It does not fall back to memory, because that would lose every unpin at the next restart. @@ -3002,46 +3263,34 @@ former Installed tab showed is the **More details** section of an add-on in My a `?tab=installed` opens My add-ons. The management link of an app (`/app/addons?manage=ID`) opens My add-ons with the details of that add-on open. -### Old links +### Add-on page addresses and model actions -The generators that had their own page now open inside the Studio add-on, and the -Studio and Talk pages open their add-ons. The old links still work and keep the model, the -query string and the hash: +Open add-on pages through My add-ons or their declared navigation entries. +The canonical address is `/app/addons//`, with an optional relative +suffix beneath the declared page. Historical standalone generator and add-on +addresses are not registered and show Not Found. There is no compatibility +support window for these unreleased routes. -| Old link | Opens | -|---|---| -| `/app/studio` | `/app/addons/studio/studio` | -| `/app/studio/` | `/app/addons/studio/studio/` | -| `/app/talk` | `/app/addons/talk/talk` | -| `/app/image` | `/app/addons/studio/studio/images` | -| `/app/video` | `/app/addons/studio/studio/video` | -| `/app/3d` | `/app/addons/studio/studio/threed` | -| `/app/tts` | `/app/addons/studio/studio/tts` | -| `/app/sound` | `/app/addons/studio/studio/sound` | -| `/app/diarization` | `/app/addons/studio/studio/diarization` | -| `/app/transform` | `/app/addons/studio/studio/transform` | -| `/app/face` | `/app/addons/face/recognition` | -| `/app/face/` | `/app/addons/face/recognition?model=` | -| `/app/voice` | `/app/addons/voice/voice/recognition` | -| `/app/voice/` | `/app/addons/voice/voice/recognition?model=` | -| `/app/voice-library` | `/app/addons/voice/voice/library` | -| `/app/voice-library/new` | `/app/addons/voice/voice/library/new` | +Installed-model inspectors, gallery Open actions and model details resolve +`capabilities.ui.modelActions` from the authenticated installed Apps list. +A declaration names a model capability, a declared page and an optional safe +relative path. Core does not map provider IDs to private page paths. -None of these links has a core page behind it: the face, voice, Studio and Talk pages exist -only in their add-ons. Without the add-on, a link opens the add-on page, which says the -add-on is not available. -A model id can hold `/` or `:`, which an add-on path segment refuses, so the model travels -as `?model=` (for example `/app/voice/org%2Fname` opens `/app/addons/voice/voice/recognition?model=org%2Fname`; the face links and the -Studio links do the same). +One usable provider opens with one click. Multiple providers appear as named +choices in the action menu, sorted by provider ID. When none is usable, the +capability label remains without a dead link or an install promotion. +Chat and completion keep their native destinations. -A link with a model (`/app/image/`) opens `/app/addons/studio/studio/images?model=`. The -`/app/studio?tab=` links redirect to `/app/addons/studio/studio/`. A viewer without the `3d`, -`audio_diarization` or `audio_transform` feature still lands on the app home, as before. +The host sends the selected model as a URLSearchParams value under `model`. +Slashes, colons, Unicode and query delimiters remain part of the model name. +Each click refreshes the actor-filtered installed list and checks the captured +provider, page, path, generation, immutable entry and authentication authority. +A changed installation or session refuses the stale action instead of choosing +another provider. Projection does not load add-on code or query the catalog. +Talk still lands on its existing page; this does not change its pipeline. -The legacy switch (`LOCALAI_LEGACY_NAVIGATION`) restores the old sidebar and the Add-ons -hub only. These redirects do not depend on it: with the switch on, the -old generator links also open the Studio add-on. The legacy sidebar lists Home, Models, All chats, Agents, MCP connectors, Memory and -the hubs; Studio and Talk are in My add-ons, as add-ons. +The legacy navigation switch changes the sidebar and hubs only. It does not +restore removed addon-specific route registrations. ### The Studio add-on @@ -3146,8 +3395,7 @@ with **Install and turn on**. Once usable, they appear in My add-ons and can be | `fine-tuning` | Fine-Tune | `/app/addons/fine-tuning/jobs` | users with the `fine_tuning` feature; the page shows an off notice for anyone else | | `quantization` | Quantize | `/app/addons/quantization/jobs` | users with the `quantization` feature; the page shows an off notice for anyone else | -The old links `/app/fine-tune` and `/app/quantize` open the add-on page. Without the add-on, -they show the add-on page that says the add-on is not available. The pages call +Canonical links open the add-on shell, which reports unavailable installations. The pages call `/api/fine-tuning/*` and `/api/quantization/*` with the signed-in user's own session (see "Core routes from add-ons"); the core keeps these routes and checks every call again. Both add-ons offer chat tools that start a job. In the chats a tool call starts it at once; on other surfaces it is a proposal that waits for a person to confirm. They @@ -3604,7 +3852,12 @@ An upgrade started from the list, the details, **Find add-ons** or **Upgrade all running when you leave the page. It appears in the Activity page under the **Add-ons** filter, with progress, **Cancel** and **Retry**. Cancel stops the request, and the page then reads the operation to report what the owner did. The list of upgrades is kept by the -browser that started them, so it is lost on a page reload. +browser that started them, so it is lost on a page reload. Background tracking continues +across navigation only within the same authentication session. An authentication +refresh, account or permission change, logout initiation, or removal of the +authentication provider expires pending observations. Late replies then cannot +verify an upgrade or publish its result into the new session. This does not undo +an operation already accepted by the server. While the add-on service restarts (an upgrade restarts it), the page cannot read the operation and the server answers 503. The page then waits longer before each new read, 2, @@ -4484,3 +4737,215 @@ Logout clears the current user's observer records. Storage restrictions may prev observer recovery, but never cancel the owner operation. An unreadable owner or exhausted polling window is an unknown outcome: keep the ID and read it again, rather than starting a replacement operation. + +## Administrator host operation policy + +`--addon-host-policy` (environment: `LOCALAI_ADDON_HOST_POLICY`) selects an +administrator-controlled JSON file. Use an absolute path to a readable regular +file, at most 64 KiB. LocalAI reads it once at startup; changes require a restart. +There is no implicit search, remote download, or manifest-selected policy path. +Keep the file outside addon-writable directories. The deployment administrator +must protect its ownership and mount permissions. A symlink to a regular file is +accepted; path validation does not prove that the file is administrator-owned. + +```json +{ + "schemaVersion": 1, + "grants": { + "sandbox": ["workspace.", "egress."], + "office": ["workspace."] + } +} +``` + +```sh +local-ai run --addon-host-policy=/etc/localai/addon-host-policy.json +# Equivalent selection: +LOCALAI_ADDON_HOST_POLICY=/etc/localai/addon-host-policy.json local-ai run +# Explicitly grant nothing, without opening any selected policy: +local-ai run --addon-host-ops= +# Disable policy selection inherited from the environment: +local-ai run --addon-host-policy= +``` + +The file requires exactly `schemaVersion` (integer `1`) and `grants` (an object). +Keys are case-sensitive. Unknown fields, duplicate keys, duplicate prefixes, +malformed JSON and trailing data are errors. An empty `grants` object is valid. +The limits are 128 addon IDs and 32 prefixes per addon. IDs use lowercase ASCII +letters, digits and hyphens and start with a letter or digit. Prefixes use the +same grammar as `--addon-host-ops`: lowercase ASCII letter segments, each ending +in a dot, such as `workspace.` or `workspace.files.`. They are not regular +expressions or exact operation names. The broker must still register an +operation before any prefix can authorize it. + +Selection uses presence, not string length: + +1. An explicit `--addon-host-ops` wins, including an empty value. +2. Otherwise, an explicitly set `LOCALAI_ADDON_HOST_OPS` wins, including empty. +3. Otherwise, the selected policy supplies the complete grant map. +4. The generic configuration resolver grants nothing when unconfigured. + +Overrides replace grants; they never merge with the file. LocalAI does not open +an ignored policy and logs that it was ignored. An invalid selected policy stops +startup. An explicitly empty `--addon-host-policy` disables an environment policy +and supplies no grants. Startup logs report the source and counts, not file +contents. Manifests, addon requests and libraries cannot grant host operations. +In split deployments, the owner enforces authority; frontend settings cannot add +grants. Programmatic `WithAddonHostOps`, including an empty map, is an exact +startup decision and takes a copy of the supplied map and prefix slices. + +### Breaking upgrade change: standalone and custom deployments + +**The generic binary no longer grants host operations by default.** Upgrading +an unconfigured standalone or custom deployment removes the former implicit +grants, even when official addons are already installed. Those addons can lose +host-backed functionality. Startup warns about unconfigured grants even before +any addon is installed. LocalAI does not infer consent from installed addons, +write migration grants into addon storage, or automatically restore old grants. + +Existing explicit flag or environment settings keep their effective grants, +including explicitly empty settings. An override replaces the complete policy; +it never unions with it. A missing or invalid selected file fails startup. + +To retain the former grants, review `deploy/addons/host-policy.json` from your +trusted LocalAI release, then install and explicitly select it: + +```sh +sudo install -d -m 0755 /etc/localai/addons +sudo install -o root -g root -m 0644 deploy/addons/host-policy.json /etc/localai/addons/host-policy.json +local-ai run --addon-host-policy=/etc/localai/addons/host-policy.json +# Or configure this environment variable in your service, then restart: +# LOCALAI_ADDON_HOST_POLICY=/etc/localai/addons/host-policy.json +``` + +The exact former explicit setting remains valid: + +```sh +local-ai run --addon-host-ops='fine-tuning=finetune.,quantization=quantize.,sandbox=workspace.,sandbox=egress.,office=workspace.,web=workspace.,telegram=connector.' +``` + +The reviewed deployment file contains exactly: + +| Addon ID | Prefix grants | +| --- | --- | +| `fine-tuning` | `finetune.` | +| `quantization` | `quantize.` | +| `sandbox` | `workspace.`, `egress.` | +| `office` | `workspace.` | +| `web` | `workspace.` | +| `telegram` | `connector.` | + +This file is host-owned deployment policy, not authority supplied by a manifest +or catalog. Grants trust addon identity, not a verified signature or provenance. +Installing different code under a granted ID gives that code the selected grants. +Protect the file against writes by addons and untrusted users. Review and reduce +the grants if you do not intend to trust every listed ID. + +### Maintained launcher selection + +The stock Docker runtime copies the reviewed file to +`/etc/localai/addons/host-policy.json` and explicitly selects that path through +`LOCALAI_ADDON_HOST_POLICY`. No executable-relative or working-directory search +is involved. `--addon-host-ops=` overrides this selection with zero grants. +`--addon-host-policy=` disables the image's policy selection and gives zero +grants unless an explicit host-operations override is present. + +| Launcher or deployment | Host policy decision | +| --- | --- | +| Stock runtime `Dockerfile` | Ships and explicitly selects the reviewed file. | +| `docker-compose.yaml`, `docker-compose.distributed.yaml` | Inherit the stock runtime image selection; custom images must select their own policy. Only the addon owner enforces grants in split mode. | +| `website/static/install/kubernetes.yaml` | Inherits the stock image selection. A custom image must ship/select policy or mount an administrator file and select its absolute path. | +| External Helm chart referenced by the Kubernetes guide | Not shipped here. Check the selected image and chart environment; custom images are unconfigured unless the operator selects policy. | +| `.devcontainer/docker-compose-devcontainer.yml` | Uses the development target, not the stock runtime stage. A manually launched binary is unconfigured. | +| Release binaries, `website/static/install.sh`, AppImage/desktop launchers (`contrib/appimage`), macOS launcher packaging | Unconfigured standalone behavior. Operators must explicitly select policy or host operations; packaging does not migrate consent. | +| Custom systemd services | No maintained unit ships in this tree. Set `LOCALAI_ADDON_HOST_POLICY` or explicit host operations in the unit, protect the file, and restart. | +| `docs/docker-compose.yaml` | Documentation-site server only; it does not launch LocalAI. | + +These non-stock launchers do not promise the former implicit grants. Automatic +policy installation or selection for them is deferred; use the explicit upgrade +procedure above. Merely copying a file without selecting it does not grant +anything. Do not fetch host policy from an addon library. + +### Owner-resolved execution observations + +The owner resolves execution-status bindings from an authenticated actor and a +conversation ID. Callers cannot choose the provider, tools, runtime incarnation, +agent grants, or consent. The resolver uses the chat's current add-on switches, +agent grants, provider pins, and installed-provider ordering. An installed but +idle pinned provider does not fall through to another add-on. +Execution observations read the same current owner policy as normal execution, +not frontend-local provider pins. Owner policy outages fail closed; a CAS pin +change invalidates a binding whose current selection no longer matches. + +The binding path uses existing principal-scoped chat and agent reads on the +owner, supporting JSON and SQL/GORM stores. Named actors use their account ID; +the local owner uses the anonymous chat principal. Current account status, +feature permissions and credentials are checked through normal authorization. +The owner must have these stores; frontend tool lists are never authority. + +The status gateway re-resolves current authorization and selection before +dispatch and before publishing positive facts, and compares the exact generation, +runtime incarnation and tools. A changed current selection withdraws old facts; +a fresh request can succeed after access is restored. This is current-read +consistency, not a historical revoke-and-restore (ABA) guarantee or a database +transaction spanning the read. The host compares +current selection identity only; it is not a durable revision or owner nonce. +A binding does not authorize command execution, grant access or replace consent. +Request/session integration shares observations only within a bounded request. + +### Execution safety notices + +The chat Tools menu and command chip describe the actual selected execution +provider, not an installed app's name. Any provider that reports host execution +gets a no-isolation warning. Unknown readiness or isolation gets an explicit +warning, not an isolated or safe badge. A status read does not grant execution +permission or replace the built-in host command consent flow. + +Provider explanations are bounded plain text. Dismissing a notice applies only +to that mounted chat context and exact provider binding, including generation, +incarnation, readiness and isolation. Loading, switching providers, +changing isolation, changing actors or logging out resets the dismissal. +Actor or authentication-session changes also invalidate pending status reads +before the next effect runs; logging in as the same user starts a new epoch. +Built-in command execution, including fallback from an unavailable provider, +shows the generic host warning without Sandbox installation or repair advice. +This warning does not change command consent or administrator settings. + +The network panel retains the configured policy when the backend cannot verify +its enforcement. It explicitly says that the policy does not apply to commands. +Provider-reported isolation alone does not establish network enforcement. +Missing current authorization, selection or runtime context yields unknown; +installing an app does not confer authority or prove network enforcement. + +The chat `/about` card reports isolation from the selected command provider. +When the provider status is missing or unknown, the card does not claim isolation. +After an authentication refresh or account change, pending Apps reads and action +results cannot populate the new session. The server still authorizes every action. + +### Model destinations (contract and backend metadata) + +An installed addon can declare optional `capabilities.ui.modelActions`: + +```json +{"modelActions":[{"capability":"FLAG_IMAGE","page":"studio","path":"images"}]} +``` + +Each entry requires an existing non-native model capability and a declared UI +page. The optional relative suffix must stay beneath that page. One entry per +capability is allowed within an addon, with at most 27 entries. Native chat and +completion cannot be shadowed; aggregate `FLAG_LLM` and `FLAG_ANY` are excluded. +The remaining concrete host model flags form the vocabulary, guarded by parity +tests. No labels, priorities, permissions, URLs or query templates are accepted. + +The combined page/suffix follows SDK routing bounds: at most 256 ASCII characters +and eight segments, each 1–64 characters from `[A-Za-z0-9._~-]`; `.` and `..` +segments are forbidden. Absolute paths, empty suffixes, percent escapes, queries, +fragments, unknown keys, duplicate keys and noncanonical keys are rejected. +Installed Apps metadata preserves these typed declarations and existing +unavailable-UI omission; it does not discover or promote catalog entries. + +Studio declares its existing image, video, speech and sound tabs. Talk declares +its transcription landing page, Face face recognition and Voice speaker +recognition. Talk currently has no selected-model query semantics; its landing +behavior is unchanged. This backend milestone does not yet alter model Open +dispatch or remove historical frontend routes. diff --git a/docs/content/features/chats-agents.md b/docs/content/features/chats-agents.md index 8d7a09771..9c11136f8 100644 --- a/docs/content/features/chats-agents.md +++ b/docs/content/features/chats-agents.md @@ -635,3 +635,11 @@ behaves with several frontends: | Live notifications | Shared, live only | Sent over the message bus to every frontend, with the "read or deleted" signal. A client that missed one lists the inbox, which is the record. | | The supervisor of a running run | Per instance | The frontend that started the run watches it. The leader also ends any run that nobody watches whose chat is over, so a run is not lost when its frontend dies. | | Where the turn of a run runs | Stateless | On the frontend that started it, or on a chat worker when the frontends send unattended turns there. The turn claim, its heartbeat and the waiting messages are in the database, so either one can run it and only one does. | + +### JSON storage + +Without an auth database, persistent chats and agent definitions use per-instance +JSON storage. These stores use in-process mutexes and atomic writes. Run one +process per JSON directory on a local filesystem; clusters use the SQL stores. +There is no cross-process directory lock or historical selection ledger. +Status observations use current principal-scoped reads in either backend. diff --git a/docs/content/features/chats.md b/docs/content/features/chats.md index d30ff67d7..870d5f810 100644 --- a/docs/content/features/chats.md +++ b/docs/content/features/chats.md @@ -1019,7 +1019,7 @@ setting like the others. ### What the web interface is told `GET /api/chats/{id}/addons` says the same for the web interface in -`commands`: `available`, `off` (the Sandbox is installed but off for this chat, +`commands`: `available`, `off` (the selected provider is off for this chat, or its agent does not name it, and the built-in `bash` is not allowed), `unavailable` (installed, but it cannot run commands on this server; `commands_reason` says why) or `none` (no add-on that runs commands is @@ -1027,9 +1027,12 @@ installed for you). When commands run, `commands_provider` says who serves `bash` (`builtin`, or the add-on's ID) and `commands_isolation` says `isolated` or `host`. `commands_consent` is `ask` when each command waits for the card, and `commands_fallback` with `commands_fallback_reason` name the add-on that -would serve but does not (`off`, or its reason). The **Tools** menu and the -chip next to it say "Commands run on this machine" with the reason, and link an -administrator to the Sandbox. +would serve but does not (`off`, or its reason). The **Tools** menu uses this selected-provider response, not private add-on +status data. It labels commands as isolated only when the current provider +explicitly reports available, isolated execution. Missing, loading and unknown +observations remain unknown. Host execution shows a warning without recommending +a specific add-on. The first-run guide and native tool settings do not recommend +an add-on by ID. ```bash curl -s localhost:8080/api/chats/$ID/addons diff --git a/docs/content/features/face-recognition.md b/docs/content/features/face-recognition.md index 9bcc358c5..a2beb98eb 100644 --- a/docs/content/features/face-recognition.md +++ b/docs/content/features/face-recognition.md @@ -398,7 +398,7 @@ default only fires when the field is omitted. ## The WebUI page -The page is the **Recognition** page of the Face add-on (**Add-ons → Face**, `/app/addons/face/recognition`). Install the add-on from **Browse add-ons** and start it; the old `/app/face` link opens the same page in the core web UI until then. The page calls the `/v1/face/*` routes described on this page with your own session. It follows the same layout as the Voice add-on's Recognition page. **Who is this** looks up a photo against the people you enrolled (`POST /v1/face/identify`, cut-off 0.35 by default, adjustable on the scale). **Same person?** compares two photos (`POST /v1/face/verify`), optionally with the liveness check, and draws the face the model found in each photo. **Enrol a person** takes a photo, a name, optional labels and a permission tick; a copy of the photo stays in the browser only if you tick it. +The page is the **Recognition** page of the Face add-on (**Add-ons → Face**, `/app/addons/face/recognition`). Install the add-on from **Browse add-ons** and start it. Canonical links show the add-on shell when the installation is unavailable. The page calls the `/v1/face/*` routes described on this page with your own session. It follows the same layout as the Voice add-on's Recognition page. **Who is this** looks up a photo against the people you enrolled (`POST /v1/face/identify`, cut-off 0.35 by default, adjustable on the scale). **Same person?** compares two photos (`POST /v1/face/verify`), optionally with the liveness check, and draws the face the model found in each photo. **Enrol a person** takes a photo, a name, optional labels and a permission tick; a copy of the photo stays in the browser only if you tick it. The people list is kept in the browser because the server has no list call. After a search, a saved person the server did not return is marked "not on the server", and a server restart empties the server's index. Removing a person waits ten seconds behind an Undo toast before `POST /v1/face/forget` is sent. The server matches one face per photo. Detecting faces, attribute guesses (off by default, often wrong) and the raw embedding are under **More tools**. With no face model installed the page says what is missing and offers gallery models to install. diff --git a/docs/content/features/fine-tuning.md b/docs/content/features/fine-tuning.md index 83c869481..a39eb4a2f 100644 --- a/docs/content/features/fine-tuning.md +++ b/docs/content/features/fine-tuning.md @@ -225,11 +225,11 @@ A user needs the fine-tuning permission. Without it, the page says the account c The Fine-Tune page is the `fine-tuning` add-on. It does not ship with LocalAI. An administrator installs it from **Find add-ons** with **Install and turn on**. Once it is on, it opens from My add-ons at `/app/addons/fine-tuning/jobs` and can be shown in the sidebar. -The old link `/app/fine-tune` keeps working. It opens the add-on page while the add-on is installed and usable, and it opens the same page in the core web UI otherwise. +Open the canonical add-on page from My add-ons. Historical standalone links are not registered. The API does not change. The page calls `/api/fine-tuning/*` with the signed-in user's own session, and the server checks the fine-tuning permission on every call, so the add-on never widens what a user may do. -With `--addons-enabled=false` this feature is off, like every add-on. The UI shows no entry for it, `/app/fine-tune` leads back to `/app`, and every path under `/api/fine-tuning` answers `503`. See [The addons feature gate](../addons/#the-addons-feature-gate). +With `--addons-enabled=false` this feature is off, like every add-on. The UI shows no entry for it, `/app/addons/fine-tuning/jobs` leads back to `/app`, and every path under `/api/fine-tuning` answers `503`. See [The addons feature gate](../addons/#the-addons-feature-gate). The add-on also offers tools to the chat: list jobs, propose a job, store a small dataset, read the status and the checkpoints of a job, stop a job and export a checkpoint as a model. A tool can only control a job that a tool started. A job started in the page is listed, but a tool cannot stop or export it. In the chats, and in an agent or a trigger whose agent names the add-on, a tool call that starts a job runs at once, because you turned the add-on on; a trigger with no agent gets no such tool; the result says what an undo does (see below). On the OpenAI-compatible API and other surfaces, a tool only proposes the action and a person confirms it in an approval card. A model or a dataset given as a local path is refused. The tools call the engine through the host operations (see [Host operations](../addons/#host-operations)), which the host setting `LOCALAI_ADDON_HOST_OPS` grants to this add-on by the `finetune.` prefix, and they run with the permissions of the person in the chat. diff --git a/docs/content/features/quantization.md b/docs/content/features/quantization.md index 2c67880e5..e8dd10786 100644 --- a/docs/content/features/quantization.md +++ b/docs/content/features/quantization.md @@ -164,11 +164,11 @@ A user needs the quantization permission. Without it, the page says the account The Quantize page is the `quantization` add-on. It does not ship with LocalAI. An administrator installs it from **Find add-ons** with **Install and turn on**. Once it is on, it opens from My add-ons at `/app/addons/quantization/jobs` and can be shown in the sidebar. -The old link `/app/quantize` keeps working. It opens the add-on page while the add-on is installed and usable, and it opens the same page in the core web UI otherwise. +Open the canonical add-on page from My add-ons. Historical standalone links are not registered. The API does not change. The page calls `/api/quantization/*` with the signed-in user's own session, and the server checks the quantization permission on every call. -With `--addons-enabled=false` this feature is off, like every add-on. The UI shows no entry for it, `/app/quantize` leads back to `/app`, and every path under `/api/quantization` answers `503`. See [The addons feature gate](../addons/#the-addons-feature-gate). +With `--addons-enabled=false` this feature is off, like every add-on. The UI shows no entry for it, `/app/addons/quantization/jobs` leads back to `/app`, and every path under `/api/quantization` answers `503`. See [The addons feature gate](../addons/#the-addons-feature-gate). The add-on also offers tools to the chat: list jobs, propose a job, read the status of a job, stop a job and import the result as a model. A tool can only control a job that a tool started. A job started in the page is listed, but a tool cannot stop or import it. In the chats, an agent and a trigger, a tool call that starts a job runs at once, because you turned the add-on on for that chat; the result says what an undo does (see below). On the OpenAI-compatible API and other surfaces, a tool only proposes the action and a person confirms it in an approval card. A model given as a local path is refused. The tools call the engine through the host operations (see [Host operations](../addons/#host-operations)), which the host setting `LOCALAI_ADDON_HOST_OPS` grants to this add-on by the `quantize.` prefix, and they run with the permissions of the person in the chat. diff --git a/docs/content/features/studio.md b/docs/content/features/studio.md index f196d160c..788e0666b 100644 --- a/docs/content/features/studio.md +++ b/docs/content/features/studio.md @@ -6,7 +6,7 @@ url = "/features/studio/" +++ {{% notice note %}} -Studio is an add-on. It is not part of the web bundle: install it from **Find add-ons** in the [Add-ons](../addons/#the-studio-add-on) page with **Install and turn on**. Once it is usable, it is in My add-ons and can be shown in the sidebar, and it opens at `/app/addons/studio/studio`. The old links (`/app/studio`, `/app/image` and the other generator links) open it there. +Studio is an add-on. It is not part of the web bundle: install it from **Find add-ons** in the [Add-ons](../addons/#the-studio-add-on) page with **Install and turn on**. Once it is usable, it is in My add-ons and can be shown in the sidebar, and it opens at `/app/addons/studio/studio`. Use its canonical add-on page address. {{% /notice %}} Studio is the part of the web UI where you make images, video, 3D objects, speech, sound and transformed audio, and find what you made before. It opens on a front page with a prompt box and your own work. Each type still has its own workspace page (Images, Video, 3D, TTS, Sound, Transform, Diarization) where a run happens. @@ -75,4 +75,4 @@ Diarization lists who spoke when as one lane per speaker, the talk time of each ## What a workspace accepts from the front page -The workspaces read these query parameters, so a link of your own works too: `prompt`, `model`, `size`, `n` (count), `from` (the id of the result it starts from) and `edge` (how). For example `/app/studio/video?prompt=Slow%20push-in&from=&edge=animate`. +The workspaces read these query parameters, so a link of your own works too: `prompt`, `model`, `size`, `n` (count), `from` (the id of the result it starts from) and `edge` (how). For example `/app/addons/studio/studio/video?prompt=Slow%20push-in&from=&edge=animate`. diff --git a/docs/content/features/voice-recognition.md b/docs/content/features/voice-recognition.md index 4a935cfa3..3266322a3 100644 --- a/docs/content/features/voice-recognition.md +++ b/docs/content/features/voice-recognition.md @@ -559,7 +559,7 @@ default only applies when omitted. ## The WebUI page -The page is the **Recognition** tab of the Voice add-on (**Add-ons → Voice**, `/app/addons/voice/voice/recognition`). Install the add-on from **Browse add-ons** and start it; the old `/app/voice` link opens the same page in the core web UI until then. The page calls the `/v1/voice/*` routes described on this page with your own session. The Voice page has four tabs. **Recognition** is this feature. **Library** is the [Voice Library](/features/text-to-audio/#voice-library) for text-to-speech, a different store with its own recordings and transcripts. **Text to speech** speaks text with a voice model. **From a recording** links to the diarization workspace, where a speaker can be named and remembered. +The page is the **Recognition** tab of the Voice add-on (**Add-ons → Voice**, `/app/addons/voice/voice/recognition`). Install the add-on from **Browse add-ons** and start it. Canonical links show the add-on shell when the installation is unavailable. The page calls the `/v1/voice/*` routes described on this page with your own session. The Voice page has four tabs. **Recognition** is this feature. **Library** is the [Voice Library](/features/text-to-audio/#voice-library) for text-to-speech, a different store with its own recordings and transcripts. **Text to speech** speaks text with a voice model. **From a recording** links to the diarization workspace, where a speaker can be named and remembered. On the Recognition tab: diff --git a/docs/superpowers/plans/2026-10-16-addons-navigation.md b/docs/superpowers/plans/2026-10-16-addons-navigation.md index f832ce786..8bdaa92d6 100644 --- a/docs/superpowers/plans/2026-10-16-addons-navigation.md +++ b/docs/superpowers/plans/2026-10-16-addons-navigation.md @@ -1,5 +1,17 @@ # Add-on navigation Implementation Plan + +> Routing update: historical addon-specific redirect requirements below are +> superseded by the approved model-actions amendment (2026-11-02). Use declared +> canonical add-on pages; native chat handoff and addon-local adapters remain. + +> Pin behavior update: the unreleased seed proposal and seed code examples below are +> superseded. Startup adds no implicit pins and does not scan accounts. Existing saved +> pins remain intact. Explicit selections persist across reopen and frontends. Studio, +> Talk, and arbitrary never-installed IDs use equal retention. See the current +> [pin behavior](../../content/features/addons.md) and [design](../specs/2026-10-16-addons-navigation-design.md#explicit-pins). + + > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Replace the long sidebar with four top-level items (Chat, Models, Add-ons, Settings) and a per-user Pinned section, and move Studio, Talk, fine-tuning, quantization, the Voice add-on and the Face add-on behind the Add-ons drawer without breaking any old URL or weakening any permission. Voice, Face, fine-tuning and quantization are add-on packages that do not ship with LocalAI: users find them in Browse add-ons and, once installed, in the drawer. diff --git a/docs/superpowers/specs/2026-10-16-addons-navigation-design.md b/docs/superpowers/specs/2026-10-16-addons-navigation-design.md index ace4a65da..dcd4d3fb3 100644 --- a/docs/superpowers/specs/2026-10-16-addons-navigation-design.md +++ b/docs/superpowers/specs/2026-10-16-addons-navigation-design.md @@ -1,5 +1,10 @@ # Add-ons navigation design + +> Routing update: historical addon-specific redirect requirements below are +> superseded by the approved model-actions amendment (2026-11-02). Use declared +> canonical add-on pages; native chat handoff and addon-local adapters remain. + ## Status - Status: implemented, with the amendments of 2026-10-31 below, which win over the text that follows. One question stays open (see "Open questions and decisions"). Questions (a) and (b) and the rulings (d) to (j) were decided on 2026-10-16. The changelog at the end lists every change of that day. @@ -15,7 +20,7 @@ What shipped differs from this spec in these points. The owner decided each one; - **Studio and Talk are packaged add-ons, not built in** (replaces decisions 4 and 10 and "Built-in add-ons and packaged add-ons"). They are add-on packages like Voice and Face, listed in the official library and installed by an administrator. The built-in catalog is empty. Nothing is preinstalled, also not at upgrade. - **The core pages are removed** (replaces the redirect rule "Otherwise the old route opens the core page"). An old route always opens the add-on page; while the add-on is not installed, that page says it is not available. - **Users need the `addons` feature** (replaces "no user loses access" and the Studio and Talk rows of the gate table). Every add-on page is behind `addons`, which is off by default for a user and is not granted at upgrade. An administrator grants it in Settings → Users and keys. The upgrade notes say so. -- **Sidebar.** Studio and Talk appear only once installed; the upgrade seed pins them for existing accounts, and a seeded pin does not expire before its add-on was installed once. +- **Sidebar.** Studio and Talk appear only once installed and explicitly pinned, like any other add-on. All absent IDs follow the same retention rule. - **Browse add-ons is for administrators only**; a user with `addons` sees the installed add-ons. ### Approved decisions @@ -171,11 +176,13 @@ Browser storage (`localStorage`) is not enough. The sidebar already uses it for The pin state must work with several frontends. The table lives in the shared database, so every frontend reads the same list. This follows the distributed-state guide (`.agents/distributed-state.md`). No per-process cache may hold the only copy. -### The one-time seed +### Explicit pins -In plain words. When LocalAI is upgraded to the version that has pins, every account that already exists gets Studio and Talk pinned, once. They keep the sidebar entries they had before the upgrade. After that nothing is pinned for them again, so if one of them unpins Studio, it stays unpinned. An account created after the upgrade starts with no pins at all; it finds Studio and Talk in the drawer and pins what it uses. - -How it works. The seed runs once, when LocalAI starts the first version that has pins, not when a user reads the pin list. A marker in the shared database records that it ran, so a restart, a second frontend and a later release do not run it again; with several frontends that start together, exactly one runs it. It pins only for the accounts that exist at that moment. The pin routes never add a pin of their own. With authentication off there is one local owner, and that owner counts as an existing account: it gets the seed on the first start of that version, and the marker is kept in the pins file next to the pins. +New and existing accounts start without implicit pins. Startup never scans accounts to add pins. +Only an explicit user selection changes the saved list. Existing saved pins are preserved. +Pins and unpins survive a reopen and a second frontend. With authentication off, the local owner +uses the same rule. Every absent ID follows the same retention window, including never-installed IDs. +The store no longer creates or reads seed metadata and does not drop existing tables. ### API @@ -378,7 +385,7 @@ Rules for redirects: - The Create group is removed. Chat moves to the top level. Studio and Talk leave the sidebar unless the user pins them. Home and the Build and Operate hub entries stay where they are today. - The Build and Operate hubs keep their tabs, except for the tabs that moved to add-ons. The fine-tuning, quantization, Face, Voices and Add-ons tabs leave the Build hub. Voice and Face get no hub entry of any kind (question (d)): the add-ons are not shipped with LocalAI, so a user finds them in Browse add-ons and, once installed, in the drawer. The Build overview page keeps its cards for fine-tuning and quantization; they link to the old routes, which open the add-on while it is usable and the core page otherwise. The hub's `addonNavigation` second row (`hubConfig.js:74`) goes away: its job moves to the drawer and to the pinned group. -- Users who had Studio or Talk in the sidebar see them in the drawer. At upgrade, the server pins Studio and Talk once for the accounts that exist then, so the layout does not change under them. The seed runs once for the deployment and never again, so a later unpin sticks, and an account created after the upgrade starts with no pins (see "The one-time seed"). +- Studio and Talk are available in the drawer once installed. Users explicitly choose their pins; existing saved pins remain unchanged. ### Documentation @@ -412,9 +419,9 @@ Decided on 2026-10-16. They are add-on packages and do not ship with LocalAI. Se Decided on 2026-10-16. They move in this change, as two add-ons. The API and the job managers stay in core; the add-ons get the UI and a tool surface; the host operations they need are new contract surface, each with its own tests. See "Host operations for the engine". -### (g) The pin seed +### (g) Explicit pin selection -Decided on 2026-10-16. Existing accounts get Studio and Talk pinned once, at upgrade; new accounts start with no pins. See "The one-time seed". +The unreleased implementation uses explicit pin selection for every account. See "Explicit pins". ### (h) SDK additions @@ -446,7 +453,7 @@ Pin storage: - With authentication off, the local owner's pins persist across a reload. - Two frontends on one database return the same list (use the existing fake-bus pattern from the distributed-state guide). - The cap of eight is enforced in the UI. The server rejects a list over a hard limit. -- The upgrade seed pins Studio and Talk once, for the accounts that exist at the upgrade, and never again. An account created after the upgrade has no pins. A read or a save never adds a pin. With authentication off, the local owner is seeded once and an unpin sticks across a restart. Two frontends that start together seed once between them. +- New and existing accounts have no implicit pins. Explicit pins and unpins survive reopen and a second frontend. Every absent ID has equal retention; reads and startup add nothing. - The start of the 30-day window of a removed add-on survives a restart in both stores. Permission filtering: @@ -511,7 +518,7 @@ Quality gates: | A user who has not installed Voice, Face, fine-tuning or quantization no longer has a hub tab for it | The user does not know the tool exists | The old URLs keep opening the core page; the Build overview keeps its cards for fine-tuning and quantization; each package is listed in Browse add-ons. | | A platform is enabled before its native check passes | A broken package ships for that platform | A test fails when a platform is enabled without a recorded native check; the release workflow builds only enabled platforms. | | An add-on process calls a host operation | The add-on reaches the job engine | A call needs a live request of that add-on, runs as that request's actor, passes the feature check, and is allowed only for add-ons the host setting names. | -| The pin seed pins for the wrong accounts | An account created after the upgrade gets pins it did not ask for | The seed runs once at upgrade, for the accounts that exist then, and the pin routes never seed. Tested for both cases. | +| Startup adds unwanted pins | Accounts get pins they did not ask for | Startup never changes saved selections. Tested for existing accounts and the local owner. | ### Rollback @@ -528,7 +535,7 @@ Changes of 2026-10-16, after the pre-flight review of the plan. One line each, w - New section "Host operations for the engine": the four host operations, their rules, marked as new contract surface (scan C22, maintainer ruling). - New decisions 10 to 12 and the section "Distribution": Voice, Face, fine-tuning and quantization are not shipped; platform list `linux/amd64`, `linux/arm64`, `darwin/arm64`, `darwin/amd64`, each with its runner; a platform is enabled only after its native check (scan C19, maintainer ruling). - Question (d) and the Migration section: no hub tab for Voice or Face; discovery through Browse and the drawer; old routes open the core page until the add-on is installed (scan C18, maintainer ruling). -- New subsection "The one-time seed" and the Sidebar bullet: seed once at upgrade for existing accounts, new accounts start with no pins (scan C23, maintainer ruling). +- The earlier one-time seed proposal is superseded by explicit pin selection in this unreleased refactor. - New subsection "Additions accepted on 2026-10-16" and the `HomeUndoToast` row: the viewer, the WAV helper, 26 icon names and the undo bar are accepted (scan C20, maintainer ruling). - Section "API": the three routes are registered by `RegisterAddonNavigationRoutes`, so the existing route tests pass without edits (scan C1). - Section "Disabled or removed while pinned": the start of the retention window is stored with the pin and survives a restart (scan C2). @@ -536,7 +543,7 @@ Changes of 2026-10-16, after the pre-flight review of the plan. One line each, w - Section "Redirects": the model of a moved route travels as `?model=`, and a moved page opens in its add-on only while the add-on is usable (scan C17). - Section "Shared UI in the SDK": add-ons that use SDK 2 declare `min: 2, max: 2`; the tests that pin SDK 1 are updated with the SDK change (scan C14). - Section "Rollback": the legacy switch restores the old sidebar and hub tab; the generator redirects do not depend on it (scan C13). -- Test plan and gate table: the tile of a packaged add-on shows for anyone with the `addons` feature and its page shows the off notice; tests added for the seed, the host operations, the platforms and the inventory (scan C19, C22, C15). +- Test plan and gate table: the tile of a packaged add-on shows for anyone with the `addons` feature and its page shows the off notice; tests cover explicit pins, the host operations, the platforms and the inventory (scan C19, C22, C15). - Plan only: pin store keeps the owner and the missing mark in the file (scan C2); the two-frontends test moves to the service task (scan C3); `ClipInput` is built in the Voice library task (scan C8); the suite inventory is updated by Tasks 1, 4 and 9 (scan C15). - Section "Extension points": the `app.nav` slot is drawn on the Add-ons drawer page below the tile grid, and in the Build hub row only under the legacy switch (Task 6 review, maintainer ruling). - Section "Redirects" (Task 7 review, maintainer ruling): the redirect into Studio keeps the model as a path segment (`/app/studio//`), not `?model=`, because Studio is a core route. Revisit if Studio moves under the add-on host; cost if wrong: a redirect with a model needs a rewrite then. diff --git a/gallery/addons/index.yaml b/gallery/addons/index.yaml index 4d23936ca..ce66d526e 100644 --- a/gallery/addons/index.yaml +++ b/gallery/addons/index.yaml @@ -460,9 +460,10 @@ description: "Let your chats run commands and code in an isolated sandbox, with your files mounted." mainPage: "sandbox" icon: "ui/icon.png" - toolCount: 4 + toolCount: 5 tools: - "bash" + - "execution-status" - "status" - "set-backend" - "confirm-host" @@ -491,9 +492,10 @@ description: "Let your chats run commands and code in an isolated sandbox, with your files mounted." mainPage: "sandbox" icon: "ui/icon.png" - toolCount: 4 + toolCount: 5 tools: - "bash" + - "execution-status" - "status" - "set-backend" - "confirm-host" diff --git a/pkg/addons/contract/README.md b/pkg/addons/contract/README.md new file mode 100644 index 000000000..4b422ae4c --- /dev/null +++ b/pkg/addons/contract/README.md @@ -0,0 +1,62 @@ +# Typed addon contract + +`ParseManifest` is the strict v1 parser used by staging and package verification. +Unknown fields, duplicate keys, invalid declarations, and unsupported compatibility ranges fail validation. +The typed declarations remain additive strict v1 fields, not a new protocol version. + +- `capabilities.ui.homeCommands` names installed pages, never tools or install actions. +- `capabilities.executionProvider` names execution tools and one declared UI-only read status tool. +- `capabilities.hostOperations.required` and `.optional` request exact operation names, never grants. + +`ValidHostOp` validates syntax only. The host's registered vocabulary determines support. +Administrator policy and request admission remain authoritative, including for optional operations. +Status is advisory; an isolation report does not prove network enforcement or authorize execution. + +See [the addon guide](../../../docs/content/features/addons.md#optional-typed-manifest-extensions) +for JSON examples, bounds, collision rules, lifecycle behavior, policy migration, and old-host staging refusal. +Old hosts must retain the old package when an extension-bearing upgrade is refused. +Release maintainers select the minimum supporting release and the duration of any legacy package support. + +Typed owner observations currently support local-owner JSON selections only. +Named-user and SQL selections remain unavailable and fail closed; they are not completed scope. + +## Optional model actions + +`capabilities.ui.modelActions` maps a model capability to a declared page: + +```json +{"modelActions":[{"capability":"FLAG_IMAGE","page":"studio","path":"images"}]} +``` + +`capability` and `page` are required. `path` is an optional nonempty relative +suffix. There is one mapping per capability per addon, at most 27 mappings. +The SDK route bounds apply to `page/path`: 256 ASCII characters, eight segments, +and 1–64 characters per segment from `[A-Za-z0-9._~-]`, excluding `.` and `..`. +Absolute paths, URLs, queries, fragments, percent encoding and backslashes fail. +Unknown, duplicate and noncanonical keys fail strict parsing. + +The exact vocabulary is `ModelActionCapabilities()`: all concrete flags in +`config.GetAllModelConfigUsecases()` except native `FLAG_CHAT` and +`FLAG_COMPLETION` and aggregate `FLAG_LLM`. `FLAG_ANY` is not a concrete flag. +A core/config parity test guards this list without importing core into contract. +Declarations carry no priorities, labels, permissions or model query templates. +Apps exposes a cloned typed list only through its existing installed UI view; +unavailable UI omission is unchanged. Protocol registration uses the same +`contract.Capabilities`, and verifies exact equality including these actions. + +Studio owns image/video/TTS/sound destinations; Talk owns transcription at its +existing landing page; Face and Voice own face/speaker recognition respectively. +Talk does not currently consume a selected-model query value. Its pipeline and +landing semantics remain unchanged. Frontend dispatch and route cleanup are a +separate milestone; this contract alone does not change model Open behavior. + +The allowed capability strings are: + +```text +FLAG_EDIT FLAG_EMBEDDINGS FLAG_RERANK FLAG_IMAGE FLAG_TRANSCRIPT FLAG_TTS +FLAG_SOUND_GENERATION FLAG_TOKENIZE FLAG_VAD FLAG_VIDEO FLAG_DETECTION +FLAG_VISION FLAG_FACE_RECOGNITION FLAG_SPEAKER_RECOGNITION FLAG_AUDIO_TRANSFORM +FLAG_DIARIZATION FLAG_SOUND_CLASSIFICATION FLAG_REALTIME_AUDIO FLAG_SCORE +FLAG_DEPTH FLAG_TOKEN_CLASSIFY FLAG_3D FLAG_3D_ANIMATION FLAG_DECISIONS +FLAG_UPSCALE FLAG_MOTION FLAG_FORECAST +``` diff --git a/pkg/addons/contract/exactkeys.go b/pkg/addons/contract/exactkeys.go new file mode 100644 index 000000000..8b6362110 --- /dev/null +++ b/pkg/addons/contract/exactkeys.go @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: MIT +package contract + +import ( + "encoding/json" + "fmt" + "slices" + "strings" +) + +// exactObjectFields checks keys before encoding/json can match struct fields +// case-insensitively. strictJSON retains duplicate, trailing and size/depth bounds. +// Only the immediate object is checked: localized maps keep their locale keys. +func exactObjectFields(b []byte, max int64, keys ...string) (map[string]json.RawMessage, error) { + var fields map[string]json.RawMessage + if err := strictJSON(b, &fields, max); err != nil { + return nil, err + } + for key := range fields { + if !slices.Contains(keys, key) { + return nil, fmt.Errorf("noncanonical or unknown JSON key %q", key) + } + } + return fields, nil +} + +// rejectExtensionAliases narrows exact spelling to the new declaration entry +// keys, without changing historical spelling behavior for unrelated fields. +func rejectExtensionAliases(b []byte, keys ...string) error { + var fields map[string]json.RawMessage + if err := strictJSON(b, &fields, DefaultLimits().ManifestBytes); err != nil { + return err + } + for key := range fields { + for _, canonical := range keys { + if key != canonical && strings.EqualFold(key, canonical) { + return fmt.Errorf("noncanonical extension key %q", key) + } + } + } + return nil +} + +func (c *Capabilities) UnmarshalJSON(b []byte) error { + if err := rejectExtensionAliases(b, "executionProvider", "hostOperations"); err != nil { + return err + } + type wire Capabilities + var v wire + if err := strictJSON(b, &v, DefaultLimits().ManifestBytes); err != nil { + return err + } + *c = Capabilities(v) + return nil +} + +func (u *UI) UnmarshalJSON(b []byte) error { + if err := rejectExtensionAliases(b, "homeCommands", "modelActions"); err != nil { + return err + } + type wire UI + var v wire + if err := strictJSON(b, &v, DefaultLimits().ManifestBytes); err != nil { + return err + } + *u = UI(v) + return nil +} + +func (p *ExecutionProvider) UnmarshalJSON(b []byte) error { + if _, err := exactObjectFields(b, DefaultLimits().ManifestBytes, "tools", "statusTool"); err != nil { + return err + } + type wire ExecutionProvider + var v wire + if err := strictJSON(b, &v, DefaultLimits().ManifestBytes); err != nil { + return err + } + *p = ExecutionProvider(v) + return nil +} + +func (o *HostOperations) UnmarshalJSON(b []byte) error { + if _, err := exactObjectFields(b, DefaultLimits().ManifestBytes, "required", "optional"); err != nil { + return err + } + type wire HostOperations + var v wire + if err := strictJSON(b, &v, DefaultLimits().ManifestBytes); err != nil { + return err + } + *o = HostOperations(v) + return nil +} diff --git a/pkg/addons/contract/exactkeys_test.go b/pkg/addons/contract/exactkeys_test.go new file mode 100644 index 000000000..1d2591052 --- /dev/null +++ b/pkg/addons/contract/exactkeys_test.go @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MIT +package contract + +import ( + "encoding/json" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Extension canonical wire keys", func() { + DescribeTable("rejects status aliases without partial trust", func(raw string) { + v, err := DecodeExecutionStatus([]byte(raw)) + Expect(err).To(HaveOccurred()) + Expect(v).To(Equal(ExecutionStatus{Readiness: "unknown", Isolation: "unknown"})) + }, + Entry("Explanation markup", `{"readiness":"available","isolation":"host","Explanation":"unsafe"}`), + Entry("Readiness override", `{"readiness":"unknown","Readiness":"available","isolation":"host"}`), + Entry("uppercase enums", `{"READINESS":"available","ISOLATION":"host"}`), + Entry("Explanation control", `{"readiness":"available","isolation":"host","Explanation":"a\nb"}`), + Entry("Explanation oversized", `{"readiness":"available","isolation":"host","Explanation":"`+strings.Repeat("x", 201)+`"}`), + Entry("benign explanation alias", `{"readiness":"available","isolation":"host","EXPLANATION":"Ready"}`), + Entry("isolation override", `{"readiness":"available","isolation":"unknown","Isolation":"host"}`), + ) + DescribeTable("rejects declaration aliases in manifests and direct capability echoes", func(old, replacement string) { + raw := strings.Replace(string(extensionFixture()), old, replacement, 1) + Expect(raw).NotTo(Equal(string(extensionFixture()))) + _, err := ParseManifest([]byte(raw)) + Expect(err).To(HaveOccurred()) + var manifest map[string]json.RawMessage + Expect(json.Unmarshal([]byte(raw), &manifest)).To(Succeed()) + var c Capabilities + Expect(json.Unmarshal(manifest["capabilities"], &c)).NotTo(Succeed()) + }, + Entry("Alias empty", `"alias":"work"`, `"Alias":""`), + Entry("Label null", `"label":{"en":"Open work","it":"Apri"}`, `"Label":null`), + Entry("ID override", `"id":"open"`, `"id":"ignored","ID":"open"`), + Entry("Page variant", `"page":"history"`, `"Page":"history"`), + Entry("Icon variant", `"icon":"clock"`, `"Icon":"clock"`), + Entry("provider Tools", `"tools":["run"],"statusTool"`, `"Tools":["run"],"statusTool"`), + Entry("provider StatusTool", `"statusTool":"health"`, `"StatusTool":"health"`), + Entry("provider mixed duplicate", `"statusTool":"health"`, `"statusTool":"ignored","StatusTool":"health"`), + Entry("operation Required", `"required":["workspace.materialize"]`, `"Required":["workspace.materialize"]`), + Entry("operation Optional", `"optional":["egress.check"]`, `"OPTIONAL":["egress.check"]`), + Entry("operation mixed duplicate", `"optional":["egress.check"]`, `"optional":[],"Optional":["egress.check"]`), + Entry("provider entry variant", `"executionProvider":`, `"ExecutionProvider":`), + Entry("provider entry mixed duplicate", `"executionProvider":`, `"executionProvider":null,"ExecutionProvider":`), + Entry("operations entry variant", `"hostOperations":`, `"HOSTOPERATIONS":`), + Entry("operations entry mixed duplicate", `"hostOperations":`, `"hostOperations":null,"HostOperations":`), + Entry("commands entry variant", `"homeCommands":`, `"HomeCommands":`), + Entry("commands entry mixed duplicate", `"homeCommands":`, `"homeCommands":[],"HomeCommands":`), + ) + It("preserves arbitrary valid locale keys at the localized map boundary", func() { + raw := strings.Replace(string(extensionFixture()), `"it":"Apri"`, `"zh-Hant":"開啟","pt-BR":"Abrir"`, 1) + _, err := ParseManifest([]byte(raw)) + Expect(err).NotTo(HaveOccurred()) + }) +}) diff --git a/pkg/addons/contract/executionstatus.go b/pkg/addons/contract/executionstatus.go new file mode 100644 index 000000000..3301d8ede --- /dev/null +++ b/pkg/addons/contract/executionstatus.go @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MIT +package contract + +import ( + "fmt" + "unicode/utf8" +) + +const ( + MaxExecutionStatusBytes = 2048 + MaxExecutionExplanationChars = 200 +) + +// ExecutionStatus is provider-reported observation, not authority or a host +// isolation guarantee. Only DecodeExecutionStatus validates a wire payload. +type ExecutionStatus struct { + Readiness string `json:"readiness"` + Isolation string `json:"isolation"` + Explanation string `json:"explanation,omitempty"` +} + +// DecodeExecutionStatus decodes Result.Payload, never the result envelope. +// Unknown/duplicate/trailing fields (including outputs) are refused. On any +// error it returns unknown/unknown, not partially trusted provider data. +func DecodeExecutionStatus(b []byte) (ExecutionStatus, error) { + unknown := ExecutionStatus{Readiness: "unknown", Isolation: "unknown"} + var v ExecutionStatus + if !utf8.Valid(b) { + return unknown, fmt.Errorf("invalid execution status UTF-8") + } + fields, err := exactObjectFields(b, MaxExecutionStatusBytes, "readiness", "isolation", "explanation") + if err != nil { + return unknown, err + } + if err := strictJSON(b, &v, MaxExecutionStatusBytes); err != nil { + return unknown, err + } + if v.Readiness != "available" && v.Readiness != "unavailable" && v.Readiness != "unknown" { + return unknown, fmt.Errorf("invalid execution readiness") + } + if v.Isolation != "isolated" && v.Isolation != "host" && v.Isolation != "unknown" { + return unknown, fmt.Errorf("invalid execution isolation") + } + // Distinguish absent explanation from explicit null or empty text. + if _, present := fields["explanation"]; present && !ValidPlainText(v.Explanation, MaxExecutionExplanationChars) { + return unknown, fmt.Errorf("invalid execution explanation") + } + if v.Readiness != "available" { + v.Isolation = "unknown" + } + return v, nil +} diff --git a/pkg/addons/contract/executionstatus_test.go b/pkg/addons/contract/executionstatus_test.go new file mode 100644 index 000000000..beef7afac --- /dev/null +++ b/pkg/addons/contract/executionstatus_test.go @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT +package contract + +import ( + "strings" + + model "github.com/mudler/LocalAI/pkg/addons/model/executionstatus" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Execution status payload", func() { + It("validates every enum pair and normalizes nonavailable isolation", func() { + for _, r := range []string{"available", "unavailable", "unknown"} { + for _, i := range []string{"isolated", "host", "unknown"} { + v, err := DecodeExecutionStatus([]byte(`{"readiness":"` + r + `","isolation":"` + i + `","explanation":"` + strings.Repeat("界", 200) + `"}`)) + Expect(err).NotTo(HaveOccurred()) + Expect(v.Readiness).To(Equal(r)) + if r != "available" { + Expect(v.Isolation).To(Equal("unknown")) + } else { + Expect(v.Isolation).To(Equal(i)) + } + } + } + }) + DescribeTable("rejects invalid payloads without partial success", func(b string) { + v, err := DecodeExecutionStatus([]byte(b)) + Expect(err).To(HaveOccurred()) + Expect(v.Readiness).To(Equal("unknown")) + Expect(v.Isolation).To(Equal("unknown")) + }, + Entry("missing isolation", `{"readiness":"available"}`), + Entry("missing readiness", `{"isolation":"host"}`), + Entry("bad readiness", `{"readiness":"ok","isolation":"host"}`), + Entry("bad isolation", `{"readiness":"available","isolation":"container"}`), + Entry("duplicate", `{"readiness":"available","readiness":"unknown","isolation":"host"}`), + Entry("unknown field", `{"readiness":"available","isolation":"host","ok":true}`), + Entry("outputs", `{"readiness":"available","isolation":"host","outputs":[]}`), + Entry("trailing", `{"readiness":"available","isolation":"host"}{}`), + Entry("null", `null`), + Entry("null explanation", `{"readiness":"available","isolation":"host","explanation":null}`), + Entry("empty explanation", `{"readiness":"available","isolation":"host","explanation":""}`), + Entry("oversized explanation", `{"readiness":"available","isolation":"host","explanation":"`+strings.Repeat("界", 201)+`"}`), + Entry("markup", `{"readiness":"available","isolation":"host","explanation":"OK"}`), + Entry("control", `{"readiness":"available","isolation":"host","explanation":"a\nb"}`), + Entry("direction", `{"readiness":"available","isolation":"host","explanation":"a\u202eb"}`), + Entry("oversized payload", strings.Repeat(" ", 2049)+`{}`), + ) + It("bridges validation into the existing pure model boundary", func() { + for _, tc := range []struct { + raw string + valid bool + value model.Value + }{ + {`{"readiness":"available","isolation":"host"}`, true, model.Value{Readiness: model.Available, Isolation: model.Host}}, + {`{"readiness":"unavailable","isolation":"isolated"}`, true, model.Value{Readiness: model.Unavailable, Isolation: model.UnknownIsolation}}, + {`{"readiness":"available","isolation":"host","outputs":[]}`, false, model.Unknown()}, + } { + v, err := DecodeExecutionStatus([]byte(tc.raw)) + payload := model.InvalidPayload() + if err == nil { + payload = model.ValidPayload(model.Value{Readiness: model.Readiness(v.Readiness), Isolation: model.Isolation(v.Isolation)}) + } + b := model.Binding{Addon: "renamed"} + tr := model.Reduce(model.Pending{B: b, Q: "q", Deadline: 10}, model.Reply{B: b, Q: "q", Payload: payload, Now: 1}) + if tc.valid { + Expect(tr.Rule).To(Equal(model.R3)) + } else { + Expect(tr.Rule).To(Equal(model.R4)) + } + } + }) +}) diff --git a/pkg/addons/contract/extensions.go b/pkg/addons/contract/extensions.go new file mode 100644 index 000000000..c938d8cf7 --- /dev/null +++ b/pkg/addons/contract/extensions.go @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: MIT +package contract + +import ( + "encoding/json" + "fmt" + "slices" +) + +const ( + MaxHomeCommands = 8 + MaxExecutionTools = 8 + MaxRequestedHostOperations = 32 +) + +// HomeCommand contributes a declared page, never a tool or an external URL. +// Empty Page resolves to UI.MainPage. Alias requests a name, not ownership. +type HomeCommand struct { + ID string `json:"id"` + Alias string `json:"alias,omitempty"` + Page string `json:"page,omitempty"` + Label *Localized `json:"label,omitempty"` + Icon string `json:"icon,omitempty"` +} + +// UnmarshalJSON keeps omission distinct from an explicit empty or null +// override. Only omission selects the installed display/destination defaults. +func (c *HomeCommand) UnmarshalJSON(b []byte) error { + type wire HomeCommand + var v wire + fields, err := exactObjectFields(b, DefaultLimits().ManifestBytes, "id", "alias", "page", "label", "icon") + if err != nil { + return err + } + if err := strictJSON(b, &v, DefaultLimits().ManifestBytes); err != nil { + return err + } + for key, value := range map[string]string{"alias": v.Alias, "page": v.Page, "icon": v.Icon} { + if _, present := fields[key]; present && value == "" { + return fmt.Errorf("home command %s must be nonempty when present", key) + } + } + if _, present := fields["label"]; present && v.Label == nil { + return fmt.Errorf("home command label must not be null") + } + *c = HomeCommand(v) + return nil +} + +// ExecutionProvider binds observations to these action tools only. It grants +// neither shadowing authority nor permission to execute commands. +type ExecutionProvider struct { + Tools []string `json:"tools"` + StatusTool string `json:"statusTool"` +} + +// HostOperations declares requests, never host grants. Omission preserves +// legacy/unspecified requests; an explicitly empty declaration is invalid. +type HostOperations struct { + Required []string `json:"required,omitempty"` + Optional []string `json:"optional,omitempty"` +} + +// IsExecutionAction reports declaration facts only, never execution authority. +func IsExecutionAction(c Capabilities, d ToolDefinition) bool { + return slices.Contains(c.Tools, d.Name) && d.Kind == ToolKindAction && !d.UIOnly && d.Shadows == "bash" +} + +// IsExecutionStatusTool reports the status declaration shape, not read authority. +// Schema acceptance is checked separately to preserve manifest diagnostics. +func IsExecutionStatusTool(c Capabilities, d ToolDefinition) bool { + return slices.Contains(c.Tools, d.Name) && !slices.Contains(c.AdminTools, d.Name) && d.Kind == ToolKindRead && d.UIOnly && IsResourceless(d) +} + +// ExecutionStatusAcceptsEmptyArguments applies the manifest schema rules even +// when a capability source supplies raw definitions without parsing a manifest. +func ExecutionStatusAcceptsEmptyArguments(d ToolDefinition) bool { + if ValidateToolSchema(d.InputSchema, ToolKindRead) != nil { + return false + } + var schema struct { + Required []string `json:"required"` + Enum json.RawMessage `json:"enum"` + } + // Allowed constraints otherwise apply to absent properties or non-object types. + return json.Unmarshal(d.InputSchema, &schema) == nil && len(schema.Required) == 0 && len(schema.Enum) == 0 +} + +func validateExtensions(c Capabilities) error { + if u := c.UI; u != nil { + if err := ValidateModelActions(u); err != nil { + return err + } + if len(u.HomeCommands) > MaxHomeCommands { + return fmt.Errorf("too many home commands") + } + seen := map[string]bool{} + for _, command := range u.HomeCommands { + if !ValidName(command.ID) || seen[command.ID] { + return fmt.Errorf("invalid or duplicate home command") + } + seen[command.ID] = true + if command.Alias != "" && !ValidName(command.Alias) { + return fmt.Errorf("invalid home command alias") + } + page := command.Page + if page == "" { + page = u.MainPage + } + if page == "" || !slices.Contains(u.Pages, page) { + return fmt.Errorf("home command requires a declared page or mainPage") + } + if command.Label != nil && !command.Label.valid(MaxLabelChars) { + return fmt.Errorf("invalid home command label") + } + if command.Icon != "" && !validIconName(command.Icon) && command.Icon != u.Icon { + return fmt.Errorf("home command icon must be a host icon name or the packaged icon") + } + } + } + if p := c.ExecutionProvider; p != nil { + if len(p.Tools) == 0 || len(p.Tools) > MaxExecutionTools || !uniqueIDs(p.Tools) || !ValidName(p.StatusTool) || slices.Contains(p.Tools, p.StatusTool) { + return fmt.Errorf("invalid execution provider bindings") + } + defs := map[string]ToolDefinition{} + for _, d := range c.ToolDefinitions { + defs[d.Name] = d + } + for _, name := range p.Tools { + d, ok := defs[name] + if !ok || !IsExecutionAction(c, d) { + return fmt.Errorf("execution provider must bind declared action tools shadowing bash") + } + } + d, ok := defs[p.StatusTool] + if !ok || !IsExecutionStatusTool(c, d) { + return fmt.Errorf("execution status must be a separate page-only resourceless read tool") + } + if !ExecutionStatusAcceptsEmptyArguments(d) { + return fmt.Errorf("execution status must accept empty arguments") + } + } + if ops := c.HostOperations; ops != nil { + n := len(ops.Required) + len(ops.Optional) + if n == 0 || n > MaxRequestedHostOperations { + return fmt.Errorf("host operations require 1 to %d requests", MaxRequestedHostOperations) + } + seen := map[string]bool{} + for _, list := range [][]string{ops.Required, ops.Optional} { + for _, name := range list { + if !ValidHostOp(name) || seen[name] { + return fmt.Errorf("invalid or duplicate host operation request") + } + seen[name] = true + } + } + } + return nil +} diff --git a/pkg/addons/contract/extensions_test.go b/pkg/addons/contract/extensions_test.go new file mode 100644 index 000000000..68f8b00fa --- /dev/null +++ b/pkg/addons/contract/extensions_test.go @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: MIT +package contract + +import ( + "encoding/json" + "fmt" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func extensionFixture() []byte { + return []byte(strings.Replace(string(fixture()), `"service":true`, `"service":true,"actions":true,"tools":["run","health"],"toolDefinitions":[{"name":"run","description":"Run commands","kind":"action","shadows":"bash","inputSchema":{"type":"object","properties":{"command":{"type":"string"}},"required":["command"],"additionalProperties":false}},{"name":"health","description":"Report readiness","kind":"read","uiOnly":true,"inputSchema":{"type":"object","properties":{},"additionalProperties":false}}],"executionProvider":{"tools":["run"],"statusTool":"health"},"hostOperations":{"required":["workspace.materialize"],"optional":["egress.check"]},"ui":{"entry":"ui/main.js","sdk":{"min":2,"max":2},"pages":["work","history"],"mainPage":"work","icon":"ui/icon.png","homeCommands":[{"id":"open","alias":"work","label":{"en":"Open work","it":"Apri"}},{"id":"history","page":"history","icon":"clock"}]}`, 1)) +} + +var _ = Describe("Optional extension contracts", func() { + DescribeTable("keeps execution declaration predicates aligned with manifest validation", func(field, value, message string) { + m, err := ParseManifest(extensionFixture()) + Expect(err).NotTo(HaveOccurred()) + c := &m.Capabilities + switch field { + case "schema": + c.ToolDefinitions[1].InputSchema = json.RawMessage(value) + case "admin": + c.AdminTools = []string{"health"} + case "status-ui": + c.ToolDefinitions[1].UIOnly = false + case "status-kind": + c.ToolDefinitions[1].Kind = value + case "action-ui": + c.ToolDefinitions[0].UIOnly = true + case "action-kind": + c.ToolDefinitions[0].Kind = value + case "shadow": + c.ToolDefinitions[0].Shadows = value + } + action := IsExecutionAction(*c, c.ToolDefinitions[0]) + status := IsExecutionStatusTool(*c, c.ToolDefinitions[1]) && ExecutionStatusAcceptsEmptyArguments(c.ToolDefinitions[1]) + Expect(action && status).To(BeFalse()) + Expect(validateExtensions(*c)).To(MatchError(message)) + raw, err := json.Marshal(m) + Expect(err).NotTo(HaveOccurred()) + _, err = ParseManifest(raw) + Expect(err).To(HaveOccurred()) + }, + Entry("wrong object", "schema", `{"type":"string","additionalProperties":false}`, "execution status must accept empty arguments"), + Entry("additional properties true", "schema", `{"type":"object","additionalProperties":true}`, "execution status must accept empty arguments"), + Entry("additional properties absent", "schema", `{"type":"object"}`, "execution status must accept empty arguments"), + Entry("required", "schema", `{"type":"object","properties":{"query":{"type":"string"}},"required":["query"],"additionalProperties":false}`, "execution status must accept empty arguments"), + Entry("enum", "schema", `{"type":"object","enum":["x"],"additionalProperties":false}`, "execution status must accept empty arguments"), + Entry("unknown schema keyword", "schema", `{"type":"object","minProperties":1,"additionalProperties":false}`, "execution status must accept empty arguments"), + Entry("malformed required", "schema", `{"type":"object","required":null,"additionalProperties":false}`, "execution status must accept empty arguments"), + Entry("resource", "schema", `{"type":"object","properties":{"resourceId":{"type":"string"}},"required":["resourceId"],"additionalProperties":false}`, "execution status must be a separate page-only resourceless read tool"), + Entry("admin", "admin", "", "execution status must be a separate page-only resourceless read tool"), + Entry("status uiOnly", "status-ui", "", "execution status must be a separate page-only resourceless read tool"), + Entry("status kind", "status-kind", "action", "execution status must be a separate page-only resourceless read tool"), + Entry("action uiOnly", "action-ui", "", "execution provider must bind declared action tools shadowing bash"), + Entry("action kind", "action-kind", "read", "execution provider must bind declared action tools shadowing bash"), + Entry("shadow", "shadow", "other", "execution provider must bind declared action tools shadowing bash"), + ) + It("accepts empty arguments with optional property constraints", func() { + m, err := ParseManifest(extensionFixture()) + Expect(err).NotTo(HaveOccurred()) + d := &m.Capabilities.ToolDefinitions[1] + d.InputSchema = json.RawMessage(`{"type":"object","properties":{"query":{"type":"string","enum":["ok"]}},"required":[],"additionalProperties":false}`) + Expect(IsExecutionStatusTool(m.Capabilities, *d)).To(BeTrue()) + Expect(ExecutionStatusAcceptsEmptyArguments(*d)).To(BeTrue()) + raw, err := json.Marshal(m) + Expect(err).NotTo(HaveOccurred()) + _, err = ParseManifest(raw) + Expect(err).NotTo(HaveOccurred()) + }) + + It("preserves all three declarations through manifest and capability echo", func() { + m, err := ParseManifest(extensionFixture()) + Expect(err).NotTo(HaveOccurred()) + b, err := json.Marshal(m) + Expect(err).NotTo(HaveOccurred()) + again, err := ParseManifest(b) + Expect(err).NotTo(HaveOccurred()) + Expect(again).To(Equal(m)) + b, err = json.Marshal(m.Capabilities) + Expect(err).NotTo(HaveOccurred()) + var echo Capabilities + Expect(json.Unmarshal(b, &echo)).To(Succeed()) + Expect(echo).To(Equal(m.Capabilities)) + Expect(SchemaVersion).To(Equal(1)) + Expect(HostSDKVersion).To(Equal(uint64(2))) + }) + It("keeps old manifests free of extension keys", func() { + m, err := ParseManifest(fixture()) + Expect(err).NotTo(HaveOccurred()) + b, err := json.Marshal(m) + Expect(err).NotTo(HaveOccurred()) + for _, key := range []string{"homeCommands", "executionProvider", "hostOperations"} { + Expect(string(b)).NotTo(ContainSubstring(key)) + } + }) + It("bounds commands and operation requests at their declared limits", func() { + m, err := ParseManifest(extensionFixture()) + Expect(err).NotTo(HaveOccurred()) + c := m.Capabilities + c.UI.HomeCommands = nil + for i := 0; i < MaxHomeCommands; i++ { + c.UI.HomeCommands = append(c.UI.HomeCommands, HomeCommand{ID: fmt.Sprintf("open-%d", i)}) + } + Expect(validateExtensions(c)).To(Succeed()) + c.UI.HomeCommands = append(c.UI.HomeCommands, HomeCommand{ID: "overflow"}) + Expect(validateExtensions(c)).NotTo(Succeed()) + c.UI.HomeCommands = nil + c.HostOperations = &HostOperations{} + for i := 0; i < MaxRequestedHostOperations; i++ { + c.HostOperations.Required = append(c.HostOperations.Required, "future.op"+string(rune('a'+i/26))+string(rune('a'+i%26))) + } + Expect(validateExtensions(c)).To(Succeed()) + c.HostOperations.Optional = []string{"future.overflow"} + Expect(validateExtensions(c)).NotTo(Succeed()) + c.HostOperations = nil + c.ExecutionProvider.Tools = make([]string, MaxExecutionTools+1) + Expect(validateExtensions(c)).NotTo(Succeed()) + }) + It("accepts localized string labels, packaged icons and explicit non-navigation pages", func() { + b := strings.Replace(string(extensionFixture()), `"label":{"en":"Open work","it":"Apri"}`, `"label":"`+strings.Repeat("界", MaxLabelChars)+`"`, 1) + b = strings.Replace(b, `"icon":"clock"`, `"icon":"ui/icon.png"`, 1) + _, err := ParseManifest([]byte(b)) + Expect(err).NotTo(HaveOccurred()) + }) + DescribeTable("requires status schemas to accept the empty object", func(schema string, valid bool) { + b := strings.Replace(string(extensionFixture()), `"type":"object","properties":{},"additionalProperties":false`, schema, 1) + Expect(b).NotTo(Equal(string(extensionFixture()))) + _, err := ParseManifest([]byte(b)) + if valid { + Expect(err).NotTo(HaveOccurred()) + } else { + Expect(err).To(HaveOccurred()) + } + }, + Entry("root enum excludes objects", `"type":"object","properties":{},"additionalProperties":false,"enum":["never-an-object"]`, false), + Entry("root required property excludes empty objects", `"type":"object","properties":{"query":{"type":"string"}},"required":["query"],"additionalProperties":false`, false), + Entry("root type excludes objects", `"type":"string","properties":{},"additionalProperties":false`, false), + Entry("optional constrained properties accept empty objects", `"type":"object","properties":{"query":{"type":"string","enum":["ready"],"minLength":1},"options":{"type":"object","properties":{"detail":{"type":"boolean"}},"required":["detail"],"additionalProperties":false}},"additionalProperties":false`, true), + Entry("keywords for other types do not constrain objects", `"type":"object","properties":{},"additionalProperties":false,"minLength":1,"maxLength":0,"minItems":1,"maxItems":0,"minimum":1,"maximum":0`, true), + ) + DescribeTable("rejects malformed extension declarations", func(old, replacement string) { + b := strings.Replace(string(extensionFixture()), old, replacement, 1) + Expect(b).NotTo(Equal(string(extensionFixture()))) + _, err := ParseManifest([]byte(b)) + Expect(err).To(HaveOccurred()) + }, + Entry("non bash execution tool", `"shadows":"bash",`, ``), + Entry("empty optional alias", `"alias":"work"`, `"alias":""`), + Entry("null optional alias", `"alias":"work"`, `"alias":null`), + Entry("empty optional page", `"page":"history"`, `"page":""`), + Entry("null optional label", `"label":{"en":"Open work","it":"Apri"}`, `"label":null`), + Entry("empty optional icon", `"icon":"clock"`, `"icon":""`), + Entry("unknown command key", `"id":"open"`, `"id":"open","target":"https://example.org"`), + Entry("duplicate command key", `"id":"open"`, `"id":"open","id":"again"`), + Entry("invalid alias", `"alias":"work"`, `"alias":"/Work"`), + Entry("missing id", `"id":"open",`, ``), + Entry("duplicate id", `"id":"history"`, `"id":"open"`), + Entry("undeclared page", `"page":"history"`, `"page":"outside"`), + Entry("no default destination", `"mainPage":"work",`, ``), + Entry("foreign icon", `"icon":"clock"`, `"icon":"ui/other.png"`), + Entry("long label", `"en":"Open work"`, `"en":"`+strings.Repeat("界", 61)+`"`), + Entry("bad locale", `"it":"Apri"`, `"bad_tag":"Apri"`), + Entry("markup label", `"en":"Open work"`, `"en":"Open"`), + Entry("duplicate locale", `"en":"Open work"`, `"en":"Open work","en":"Again"`), + Entry("status schema open", `"properties":{},"additionalProperties":false`, `"properties":{},"additionalProperties":true`), + Entry("status wrong kind", `"name":"health","description":"Report readiness","kind":"read"`, `"name":"health","description":"Report readiness","kind":"action"`), + Entry("empty provider", `"tools":["run"],"statusTool":"health"`, ``), + Entry("unknown execution tool", `"tools":["run"],"statusTool"`, `"tools":["missing"],"statusTool"`), + Entry("duplicate execution binding", `"tools":["run"],"statusTool"`, `"tools":["run","run"],"statusTool"`), + Entry("same status binding", `"statusTool":"health"`, `"statusTool":"run"`), + Entry("non UI status", `"uiOnly":true`, `"uiOnly":false`), + Entry("status required input", `"properties":{},"additionalProperties"`, `"properties":{"query":{"type":"string"}},"required":["query"],"additionalProperties"`), + Entry("status resource", `"properties":{},"additionalProperties"`, `"properties":{"resourceId":{"type":"string"}},"additionalProperties"`), + Entry("empty requests", `"required":["workspace.materialize"],"optional":["egress.check"]`, ``), + Entry("duplicate requests", `"optional":["egress.check"]`, `"optional":["workspace.materialize"]`), + Entry("wildcard request", `"egress.check"`, `"egress.*"`), + Entry("unknown request key", `"optional":["egress.check"]`, `"optional":["egress.check"],"grants":true`), + ) +}) diff --git a/pkg/addons/contract/manifest.go b/pkg/addons/contract/manifest.go index 7a8e6155f..81f7e731f 100644 --- a/pkg/addons/contract/manifest.go +++ b/pkg/addons/contract/manifest.go @@ -111,8 +111,10 @@ type Capabilities struct { Connector *ConnectorCapability `json:"connector,omitempty"` // SkillDefinitions are the skills the package ships under skills/ (see // skills.go). The chat's skill index offers them while the add-on runs. - SkillDefinitions []SkillDefinition `json:"skillDefinitions,omitempty"` - UI *UI `json:"ui,omitempty"` + SkillDefinitions []SkillDefinition `json:"skillDefinitions,omitempty"` + UI *UI `json:"ui,omitempty"` + ExecutionProvider *ExecutionProvider `json:"executionProvider,omitempty"` + HostOperations *HostOperations `json:"hostOperations,omitempty"` } // ResourceTools maps the host's UI-only resource management operations to @@ -203,11 +205,13 @@ func validateResourceTools(c Capabilities) error { } type UI struct { - Entry string `json:"entry"` - CSS []string `json:"css,omitempty"` - SDK VersionRange `json:"sdk"` - Pages []string `json:"pages,omitempty"` - Navigation []string `json:"navigation,omitempty"` + HomeCommands []HomeCommand `json:"homeCommands,omitempty"` + ModelActions []ModelAction `json:"modelActions,omitempty"` + Entry string `json:"entry"` + CSS []string `json:"css,omitempty"` + SDK VersionRange `json:"sdk"` + Pages []string `json:"pages,omitempty"` + Navigation []string `json:"navigation,omitempty"` // ChatActions are the chat actions: a bare identifier (inert) or a full // declaration with label, icon, placement and target. ChatActions []ChatAction `json:"chatActions,omitempty"` @@ -358,6 +362,9 @@ func ValidateManifest(m Manifest, h HostCompatibility) error { if e := validateToolDefinitions(c.ToolDefinitions, c.Tools); e != nil { return e } + if e := validateExtensions(c); e != nil { + return e + } if e := validateResourceTools(c); e != nil { return e } diff --git a/pkg/addons/contract/modelactions.go b/pkg/addons/contract/modelactions.go new file mode 100644 index 000000000..448dd52fb --- /dev/null +++ b/pkg/addons/contract/modelactions.go @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: MIT +package contract + +import ( + "fmt" + "regexp" + "slices" + "strings" +) + +// ModelAction contributes navigation for a non-native model capability. Page +// must be declared; Path is an optional suffix, never a URL or query template. +type ModelAction struct { + Capability string `json:"capability"` + Page string `json:"page"` + Path string `json:"path,omitempty"` +} + +// ModelActionCapabilities returns the host's concrete non-native model flags. +// FLAG_ANY and aggregate FLAG_LLM are not destinations. Chat and completion +// remain native and cannot be shadowed. Keep vocabulary parity in core/config. +func ModelActionCapabilities() []string { + return []string{ + "FLAG_EDIT", "FLAG_EMBEDDINGS", "FLAG_RERANK", "FLAG_IMAGE", "FLAG_TRANSCRIPT", + "FLAG_TTS", "FLAG_SOUND_GENERATION", "FLAG_TOKENIZE", "FLAG_VAD", "FLAG_VIDEO", + "FLAG_DETECTION", "FLAG_VISION", "FLAG_FACE_RECOGNITION", "FLAG_SPEAKER_RECOGNITION", + "FLAG_AUDIO_TRANSFORM", "FLAG_DIARIZATION", "FLAG_SOUND_CLASSIFICATION", + "FLAG_REALTIME_AUDIO", "FLAG_SCORE", "FLAG_DEPTH", "FLAG_TOKEN_CLASSIFY", + "FLAG_3D", "FLAG_3D_ANIMATION", "FLAG_DECISIONS", "FLAG_UPSCALE", "FLAG_MOTION", "FLAG_FORECAST", + } +} + +// MaxModelActions bounds the declaration independently of the wire byte limit. +const MaxModelActions = 27 + +var modelRouteSegment = regexp.MustCompile(`^[A-Za-z0-9._~-]{1,64}$`) + +func (a *ModelAction) UnmarshalJSON(b []byte) error { + fields, err := exactObjectFields(b, DefaultLimits().ManifestBytes, "capability", "page", "path") + if err != nil { + return err + } + type wire ModelAction + var v wire + if err := strictJSON(b, &v, DefaultLimits().ManifestBytes); err != nil { + return err + } + if _, present := fields["path"]; present && v.Path == "" { + return fmt.Errorf("model action path must be nonempty when present") + } + *a = ModelAction(v) + return nil +} + +// ValidateModelActions uses the SDK resolveSubpath grammar: at most eight +// segments and 256 ASCII characters including the page, 64 per segment. +// Declarations intentionally disallow the SDK's optional leading slash/query. +func ValidateModelActions(u *UI) error { + if len(u.ModelActions) > MaxModelActions { + return fmt.Errorf("too many model actions") + } + seen := map[string]bool{} + for _, a := range u.ModelActions { + if !slices.Contains(ModelActionCapabilities(), a.Capability) || seen[a.Capability] { + return fmt.Errorf("unsupported or duplicate model action capability") + } + seen[a.Capability] = true + if !ValidName(a.Page) || !slices.Contains(u.Pages, a.Page) { + return fmt.Errorf("model action requires a declared page") + } + route := a.Page + if a.Path != "" { + route += "/" + a.Path + } + segments := strings.Split(route, "/") + if len(route) > 256 || len(segments) > 8 { + return fmt.Errorf("model action route exceeds SDK bounds") + } + for _, s := range segments { + if !modelRouteSegment.MatchString(s) || s == "." || s == ".." { + return fmt.Errorf("invalid model action route segment") + } + } + } + return nil +} diff --git a/pkg/addons/contract/modelactions_test.go b/pkg/addons/contract/modelactions_test.go new file mode 100644 index 000000000..87eaaa62c --- /dev/null +++ b/pkg/addons/contract/modelactions_test.go @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: MIT +package contract + +import ( + "encoding/json" + "fmt" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "os" + "strings" +) + +func modelActionFixture(actions string) []byte { + return []byte(strings.Replace(string(extensionFixture()), `"homeCommands":`, `"modelActions":`+actions+`,"homeCommands":`, 1)) +} + +var _ = Describe("Optional model actions", func() { + It("bounds model actions to one mapping per supported capability", func() { + m, err := ParseManifest(extensionFixture()) + Expect(err).NotTo(HaveOccurred()) + for _, capability := range ModelActionCapabilities() { + m.Capabilities.UI.ModelActions = append(m.Capabilities.UI.ModelActions, ModelAction{Capability: capability, Page: "work"}) + } + Expect(m.Capabilities.UI.ModelActions).To(HaveLen(MaxModelActions)) + b, err := json.Marshal(m) + Expect(err).NotTo(HaveOccurred()) + _, err = ParseManifest(b) + Expect(err).NotTo(HaveOccurred()) + m.Capabilities.UI.ModelActions = append(m.Capabilities.UI.ModelActions, ModelAction{Capability: "FLAG_IMAGE", Page: "work"}) + b, err = json.Marshal(m) + Expect(err).NotTo(HaveOccurred()) + _, err = ParseManifest(b) + Expect(err).To(HaveOccurred()) + }) + It("declares only owning model actions for existing addon destinations", func() { + expected := map[string][]ModelAction{ + "studio": {{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"}}, + "talk": {{Capability: "FLAG_TRANSCRIPT", Page: "talk"}}, + "face": {{Capability: "FLAG_FACE_RECOGNITION", Page: "recognition"}}, + "voice": {{Capability: "FLAG_SPEAKER_RECOGNITION", Page: "recognition"}}, + } + for id, actions := range expected { + b, err := os.ReadFile(fmt.Sprintf("../../../addons/%s/manifest.json", id)) + Expect(err).NotTo(HaveOccurred()) + m, err := ParseManifest(b) + Expect(err).NotTo(HaveOccurred()) + Expect(m.Capabilities.UI.ModelActions).To(Equal(actions)) + } + }) + + It("round trips declared model destinations without changing protocol or SDK", func() { + m, err := ParseManifest(modelActionFixture(`[{"capability":"FLAG_IMAGE","page":"work","path":"images"},{"capability":"FLAG_TRANSCRIPT","page":"history"}]`)) + Expect(err).NotTo(HaveOccurred()) + b, err := json.Marshal(m) + Expect(err).NotTo(HaveOccurred()) + Expect(string(b)).To(ContainSubstring(`"modelActions":[{"capability":"FLAG_IMAGE","page":"work","path":"images"},{"capability":"FLAG_TRANSCRIPT","page":"history"}]`)) + again, err := ParseManifest(b) + Expect(err).NotTo(HaveOccurred()) + Expect(again).To(Equal(m)) + }) + It("accepts SDK route segment and combined path boundaries", func() { + for _, path := range []string{"a_~.-9", strings.Repeat("a", 64), "a/b/c/d/e/f/g", strings.Repeat("a", 64) + "/" + strings.Repeat("b", 64) + "/" + strings.Repeat("c", 64) + "/" + strings.Repeat("d", 56)} { + _, err := ParseManifest(modelActionFixture(`[{"capability":"FLAG_IMAGE","page":"work","path":"` + path + `"}]`)) + Expect(err).NotTo(HaveOccurred(), path) + } + }) + DescribeTable("rejects unsafe or ambiguous model actions", func(actions string) { + _, err := ParseManifest(modelActionFixture(actions)) + Expect(err).To(HaveOccurred()) + }, + Entry("unknown field", `[{"capability":"FLAG_IMAGE","page":"work","url":"https://example.org"}]`), + Entry("duplicate field", `[{"capability":"FLAG_IMAGE","page":"work","page":"history"}]`), + Entry("case field", `[{"Capability":"FLAG_IMAGE","page":"work"}]`), + Entry("case page", `[{"capability":"FLAG_IMAGE","Page":"work"}]`), + Entry("case path", `[{"capability":"FLAG_IMAGE","page":"work","Path":"images"}]`), + Entry("duplicate capability", `[{"capability":"FLAG_IMAGE","page":"work"},{"capability":"FLAG_IMAGE","page":"history"}]`), + Entry("missing page", `[{"capability":"FLAG_IMAGE"}]`), + Entry("undeclared page", `[{"capability":"FLAG_IMAGE","page":"outside"}]`), + Entry("unknown capability", `[{"capability":"FLAG_FUTURE","page":"work"}]`), + Entry("native chat", `[{"capability":"FLAG_CHAT","page":"work"}]`), + Entry("native completion", `[{"capability":"FLAG_COMPLETION","page":"work"}]`), + Entry("aggregate LLM", `[{"capability":"FLAG_LLM","page":"work"}]`), + Entry("any capability", `[{"capability":"FLAG_ANY","page":"work"}]`), + Entry("noncanonical capability", `[{"capability":"flag_image","page":"work"}]`), + Entry("absolute", `[{"capability":"FLAG_IMAGE","page":"work","path":"/images"}]`), + Entry("URL", `[{"capability":"FLAG_IMAGE","page":"work","path":"https://example.org"}]`), + Entry("traversal", `[{"capability":"FLAG_IMAGE","page":"work","path":"a/../b"}]`), + Entry("dot", `[{"capability":"FLAG_IMAGE","page":"work","path":"."}]`), + Entry("encoded traversal", `[{"capability":"FLAG_IMAGE","page":"work","path":"%2e%2e"}]`), + Entry("query", `[{"capability":"FLAG_IMAGE","page":"work","path":"images?model=x"}]`), + Entry("hash", `[{"capability":"FLAG_IMAGE","page":"work","path":"images#x"}]`), + Entry("backslash", `[{"capability":"FLAG_IMAGE","page":"work","path":"a\\b"}]`), + Entry("empty segment", `[{"capability":"FLAG_IMAGE","page":"work","path":"a//b"}]`), + Entry("trailing slash", `[{"capability":"FLAG_IMAGE","page":"work","path":"a/"}]`), + Entry("empty path", `[{"capability":"FLAG_IMAGE","page":"work","path":""}]`), + Entry("null path", `[{"capability":"FLAG_IMAGE","page":"work","path":null}]`), + Entry("long segment", `[{"capability":"FLAG_IMAGE","page":"work","path":"`+strings.Repeat("a", 65)+`"}]`), + Entry("too many segments", `[{"capability":"FLAG_IMAGE","page":"work","path":"a/b/c/d/e/f/g/h"}]`), + Entry("long combined route", `[{"capability":"FLAG_IMAGE","page":"work","path":"`+strings.Repeat("a", 64)+`/`+strings.Repeat("b", 64)+`/`+strings.Repeat("c", 64)+`/`+strings.Repeat("d", 57)+`"}]`), + ) + It("rejects noncanonical modelActions entry keys and duplicate keys", func() { + for _, key := range []string{`"ModelActions":[]`, `"modelActions":[],"modelActions":[]`, `"modelActions":[],"ModelActions":[]`} { + b := strings.Replace(string(extensionFixture()), `"homeCommands":`, key+`,"homeCommands":`, 1) + _, err := ParseManifest([]byte(b)) + Expect(err).To(HaveOccurred()) + } + }) +}) diff --git a/pkg/addons/model/executionstatus/README.md b/pkg/addons/model/executionstatus/README.md new file mode 100644 index 000000000..c4b3841ea --- /dev/null +++ b/pkg/addons/model/executionstatus/README.md @@ -0,0 +1,65 @@ +# Execution status model and trace viewer + +This pure model describes one enclosing request's status observation, not +execution authority. Bindings include actor, credential, conversation, provider, generation, +incarnation and tool names. `Reduce` returns a state, ordered +effects, rule and guard. It does not perform I/O or read a clock. + +`Traces()` supplies independent expected rules, states, ordered effects and final +projections for all eleven normative trace families. `RunTrace` runs the actual +reducer. `Explore(6)` checks bounded histories, safety, liveness and all 49 +state/event cells. R8 is retired; ignored transitions use I. A settled failure +cannot retry without ending the binding lifetime through authoritative admission. + +## Generate and inspect + +From the repository root: + +```sh +go test -count=1 ./pkg/addons/model/executionstatus/... +go run ./pkg/addons/model/executionstatus/cmd/viewer -check +go run ./pkg/addons/model/executionstatus/cmd/viewer -o /path/to/executionstatus-viewer.html +``` + +Open the resulting HTML directly in a browser. Select a trace and use Previous +and Next to inspect each event, before/after state, ordered effects, rule and +guard. The page is self-contained: no server, network, JavaScript reducer or +frontend build is needed. JavaScript only presents transitions computed by Go. +Reply descriptions retain the model payload's validation details; they are not +wire-format payloads. Fixtures contain synthetic opaque identities, not secrets. + +Without `-o`, generation writes `executionstatus-viewer.html` under +`SDD_LOCAL_ROOT` when that environment variable is present. Otherwise it writes +portable `viewer.html` in the current directory. +`go generate ./pkg/addons/model/executionstatus` uses the same default: the +configured artifact root, or the model directory when the variable is absent. +An explicit `-o` always overrides this default. Generated HTML is not committed. + +## Check contract + +`-check` writes nothing. It recomputes real traces, compares each step with the +independent expected rule/state/ordered effects, checks final projections and +nonempty guards, and requires all families, rules, states and effect kinds. +It runs shared bounded invariant exploration, including complete matrix coverage. +It then generates twice in memory, compares bytes for determinism and checks the +embedded JSON against freshly computed data. Map keys are sorted by Go's JSON +encoder; HTML-sensitive payload text is escaped. + +To verify an existing generated artifact, supply both flags: + +```sh +go run ./pkg/addons/model/executionstatus/cmd/viewer -check -o /path/to/executionstatus-viewer.html +``` + +This additionally checks embedded data and exact generated HTML bytes, detecting +stale data or presentation. A missing file, stale file, invariant counterexample, +trace mismatch, incomplete coverage, nondeterminism or malformed template returns +nonzero. Bare `-check` deliberately needs no existing artifact; it verifies the +current model and template in memory. Regenerate and check after changing the +reducer, fixtures, exploration domain or viewer template. + +The viewer's Ginkgo/Gomega suite tests data correspondence and error paths, +including changed expected rules/states/effects, missing families, invariant +failure, incomplete exploration, deterministic HTML, stale files and CLI errors. +The model suite remains the independent source of reducer and constructor checks. +These bounded checks do not certify runtime security or wire contracts. diff --git a/pkg/addons/model/executionstatus/cmd/viewer/main.go b/pkg/addons/model/executionstatus/cmd/viewer/main.go new file mode 100644 index 000000000..980e06cb2 --- /dev/null +++ b/pkg/addons/model/executionstatus/cmd/viewer/main.go @@ -0,0 +1,233 @@ +// SPDX-License-Identifier: MIT + +// Command viewer exports steps computed by the real executionstatus reducer. +package main + +import ( + "bytes" + _ "embed" + "encoding/json" + "flag" + "fmt" + "io" + "os" + "reflect" + "strings" + + es "github.com/mudler/LocalAI/pkg/addons/model/executionstatus" +) + +//go:embed viewer.tmpl.html +var page string + +const dataStart = `` +const placeholder = "/*__DATA__*/null" +const explorationDepth = 6 + +type stepJSON struct { + Number int `json:"step"` + Event string `json:"event"` + Before string `json:"before"` + After string `json:"after"` + Effects []string `json:"effects"` + Rule es.Rule `json:"rule"` + Guard string `json:"guard"` +} +type traceJSON struct { + Family int `json:"family"` + Name string `json:"name"` + Steps []stepJSON `json:"steps"` + Value es.Value `json:"value"` +} +type dataJSON struct { + Traces []traceJSON `json:"traces"` + Rules []es.Rule `json:"rules"` + Explore es.Exploration `json:"explore"` +} + +// describe retains the concrete union tag, including empty constructors. Using +// Go's structural formatting also exposes Reply's private validation payload; +// JSON-marshalling the model interfaces directly would silently lose it. +func describe(v any) string { return fmt.Sprintf("%T %+v", v, v) } + +// collect verifies the independent fixture oracle before exporting reducer data. +// Function arguments let tests prove failures are propagated, not decorated as +// successful traces. Production always supplies the shared model functions. +func collect(traces []es.Trace, runTrace func(es.State, []es.Event) []es.Transition, explore func(int) (es.Exploration, error)) (dataJSON, error) { + d := dataJSON{Rules: es.Rules()} + families := map[int]bool{} + rules := map[es.Rule]bool{} + states := map[reflect.Type]bool{reflect.TypeOf(es.Unbound{}): true} + effects := map[reflect.Type]bool{} + for _, tr := range traces { + families[tr.Family] = true + steps := runTrace(es.Unbound{}, tr.Events) + if len(steps) == 0 || len(steps) != len(tr.Want) || len(steps) != len(tr.Events) { + return d, fmt.Errorf("trace %q: incomplete steps", tr.Name) + } + t := traceJSON{Family: tr.Family, Name: tr.Name, Value: tr.Value} + var before es.State = es.Unbound{} + for i, s := range steps { + want := tr.Want[i] + if s.Rule != want.Rule || !reflect.DeepEqual(s.State, want.State) || !reflect.DeepEqual(s.Effects, want.Effects) || s.Guard == "" { + return d, fmt.Errorf("trace %q step %d: rule/state/ordered effects/guard mismatch: got %+v, want %+v", tr.Name, i+1, s, want) + } + rules[s.Rule] = true + states[reflect.TypeOf(s.State)] = true + fx := []string{} + for _, e := range s.Effects { + effects[reflect.TypeOf(e)] = true + fx = append(fx, describe(e)) + } + t.Steps = append(t.Steps, stepJSON{i + 1, describe(tr.Events[i]), describe(before), describe(s.State), fx, s.Rule, s.Guard}) + before = s.State + } + if es.Project(before) != tr.Value { + return d, fmt.Errorf("trace %q: final projection mismatch", tr.Name) + } + d.Traces = append(d.Traces, t) + } + if len(families) != 11 { + return d, fmt.Errorf("incomplete trace families: %d", len(families)) + } + for i := 1; i <= 11; i++ { + if !families[i] { + return d, fmt.Errorf("missing trace family %d", i) + } + } + if len(rules) != len(d.Rules) { + return d, fmt.Errorf("incomplete rule coverage") + } + for _, r := range d.Rules { + if !rules[r] { + return d, fmt.Errorf("missing rule %s", r) + } + } + if len(states) != len(es.AllStates()) { + return d, fmt.Errorf("incomplete state coverage") + } + for _, s := range es.AllStates() { + if !states[reflect.TypeOf(s)] { + return d, fmt.Errorf("missing state %T", s) + } + } + for _, e := range []es.Effect{es.Read{}, es.Cancel{}, es.Publish{}} { + if !effects[reflect.TypeOf(e)] { + return d, fmt.Errorf("missing effect %T", e) + } + } + rep, err := explore(explorationDepth) + if err != nil { + return d, fmt.Errorf("exploration: %w", err) + } + if rep.States == 0 || rep.Transitions == 0 || rep.LivenessChecks != rep.States || len(rep.Cells) != len(es.AllStates())*len(es.AllEvents()) { + return d, fmt.Errorf("incomplete invariant exploration") + } + for _, s := range es.AllStates() { + for _, e := range es.AllEvents() { + if !rep.Cells[fmt.Sprintf("%T/%T", s, e)] { + return d, fmt.Errorf("missing matrix cell %T/%T", s, e) + } + } + } + d.Explore = rep + return d, nil +} +func computedData() ([]byte, error) { + d, err := collect(es.Traces(), es.RunTrace, es.Explore) + if err != nil { + return nil, err + } + // encoding/json sorts map keys and escapes HTML, including script terminators. + return json.Marshal(d) +} +func generate() ([]byte, error) { + data, err := computedData() + if err != nil { + return nil, err + } + if strings.Count(page, placeholder) != 1 { + return nil, fmt.Errorf("template must contain exactly one data placeholder") + } + return []byte(strings.Replace(page, placeholder, string(data), 1)), nil +} +func validatePage(html []byte) error { + start := bytes.Index(html, []byte(dataStart)) + if start < 0 { + return fmt.Errorf("missing embedded data") + } + start += len(dataStart) + end := bytes.Index(html[start:], []byte(dataEnd)) + if end < 0 { + return fmt.Errorf("unterminated embedded data") + } + expected, err := computedData() + if err != nil { + return err + } + if !bytes.Equal(html[start:start+end], expected) { + return fmt.Errorf("embedded data differs from real reducer traces") + } + return nil +} +func run(args []string, out io.Writer) error { + flags := flag.NewFlagSet("viewer", flag.ContinueOnError) + flags.SetOutput(out) + defaultPath := "viewer.html" + path := flags.String("o", defaultPath, "output file (with -check, verify this existing file)") + check := flags.Bool("check", false, "verify traces, invariants and deterministic embedded data without writing") + if err := flags.Parse(args); err != nil { + return err + } + if flags.NArg() != 0 { + return fmt.Errorf("unexpected positional arguments") + } + explicit := false + flags.Visit(func(f *flag.Flag) { + if f.Name == "o" { + explicit = true + } + }) + html, err := generate() + if err != nil { + return err + } + if *check { + again, err := generate() + if err != nil { + return err + } + if !bytes.Equal(html, again) { + return fmt.Errorf("nondeterministic generation") + } + if err := validatePage(html); err != nil { + return err + } + if explicit { + existing, err := os.ReadFile(*path) + if err != nil { + return err + } + if err := validatePage(existing); err != nil { + return err + } + if !bytes.Equal(existing, html) { + return fmt.Errorf("stale viewer: %s", *path) + } + } + _, err = fmt.Fprintln(out, "executionstatus: traces, rule/state/effect coverage, invariants and deterministic embedded data verified") + return err + } + if err := os.WriteFile(*path, html, 0600); err != nil { + return err + } + _, err = fmt.Fprintf(out, "wrote %s\n", *path) + return err +} +func main() { + if err := run(os.Args[1:], os.Stdout); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/pkg/addons/model/executionstatus/cmd/viewer/main_test.go b/pkg/addons/model/executionstatus/cmd/viewer/main_test.go new file mode 100644 index 000000000..20b8c26eb --- /dev/null +++ b/pkg/addons/model/executionstatus/cmd/viewer/main_test.go @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: MIT +package main + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "testing" + + es "github.com/mudler/LocalAI/pkg/addons/model/executionstatus" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestViewer(t *testing.T) { RegisterFailHandler(Fail); RunSpecs(t, "Execution status viewer") } + +var _ = Describe("Real reducer trace export", func() { + It("propagates output failures for checks and writes", func() { + path := filepath.Join(GinkgoT().TempDir(), "viewer.html") + Expect(run([]string{"-check"}, failedOutput{})).To(MatchError("output failed")) + Expect(run([]string{"-o", path}, failedOutput{})).To(MatchError("output failed")) + }) + It("exports every trace step with state, event, effects, rule and guard", func() { + d, err := collect(es.Traces(), es.RunTrace, es.Explore) + Expect(err).NotTo(HaveOccurred()) + Expect(d.Traces).To(HaveLen(len(es.Traces()))) + for i, tr := range d.Traces { + Expect(tr.Steps).To(HaveLen(len(es.Traces()[i].Events))) + for j, s := range tr.Steps { + Expect(s.Number).To(Equal(j + 1)) + Expect(s.Event).NotTo(BeEmpty()) + Expect(s.Before).NotTo(BeEmpty()) + Expect(s.After).NotTo(BeEmpty()) + Expect(s.Guard).NotTo(BeEmpty()) + Expect(s.Rule).To(Equal(es.Traces()[i].Want[j].Rule)) + Expect(s.Effects).NotTo(BeNil()) + } + } + Expect(d.Explore.Cells).To(HaveLen(49)) + }) + It("rejects wrong expected rules", func() { + tr := es.Traces() + tr[0].Want[0].Rule = es.I + _, err := collect(tr, es.RunTrace, es.Explore) + Expect(err).To(MatchError(ContainSubstring("step 1"))) + }) + It("rejects wrong expected states", func() { + tr := es.Traces() + tr[0].Want[0].State = es.Closed{} + _, err := collect(tr, es.RunTrace, es.Explore) + Expect(err).To(HaveOccurred()) + }) + It("rejects missing ordered effects", func() { + tr := es.Traces() + tr[0].Want[1].Effects = nil + _, err := collect(tr, es.RunTrace, es.Explore) + Expect(err).To(HaveOccurred()) + }) + It("rejects wrong final projection", func() { + tr := es.Traces() + tr[0].Value = es.Unknown() + _, err := collect(tr, es.RunTrace, es.Explore) + Expect(err).To(HaveOccurred()) + }) + It("rejects missing trace families", func() { _, err := collect(es.Traces()[:1], es.RunTrace, es.Explore); Expect(err).To(HaveOccurred()) }) + It("propagates invariant counterexamples", func() { + _, err := collect(es.Traces(), es.RunTrace, func(int) (es.Exploration, error) { return es.Exploration{}, errors.New("counterexample") }) + Expect(err).To(MatchError(ContainSubstring("counterexample"))) + }) + It("rejects incomplete exploration", func() { + _, err := collect(es.Traces(), es.RunTrace, func(int) (es.Exploration, error) { return es.Exploration{}, nil }) + Expect(err).To(HaveOccurred()) + }) + It("generates deterministic self-contained HTML and detects embedded data drift", func() { + a, err := generate() + Expect(err).NotTo(HaveOccurred()) + b, err := generate() + Expect(err).NotTo(HaveOccurred()) + Expect(a).To(Equal(b)) + Expect(validatePage(a)).To(Succeed()) + Expect(validatePage(bytes.Replace(a, []byte(`"R1"`), []byte(`"R8"`), 1))).NotTo(Succeed()) + Expect(string(a)).To(ContainSubstring(`id="trace"`)) + Expect(string(a)).To(ContainSubstring(`id="next"`)) + }) + It("writes explicit output outside the working directory", func() { + cwd, err := os.Getwd() + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(os.Chdir, cwd) + work := GinkgoT().TempDir() + root := GinkgoT().TempDir() + Expect(os.Chdir(work)).To(Succeed()) + GinkgoT().Setenv("SDD_LOCAL_ROOT", root) + var out bytes.Buffer + Expect(run([]string{"-o", filepath.Join(root, "executionstatus-viewer.html")}, &out)).To(Succeed()) + html, err := os.ReadFile(filepath.Join(root, "executionstatus-viewer.html")) + Expect(err).NotTo(HaveOccurred()) + Expect(validatePage(html)).To(Succeed()) + entries, err := os.ReadDir(work) + Expect(err).NotTo(HaveOccurred()) + Expect(entries).To(BeEmpty()) + }) + It("defaults to portable viewer.html", func() { + cwd, err := os.Getwd() + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(os.Chdir, cwd) + Expect(os.Chdir(GinkgoT().TempDir())).To(Succeed()) + GinkgoT().Setenv("SDD_LOCAL_ROOT", "") + Expect(os.Unsetenv("SDD_LOCAL_ROOT")).To(Succeed()) + var out bytes.Buffer + Expect(run(nil, &out)).To(Succeed()) + html, err := os.ReadFile("viewer.html") + Expect(err).NotTo(HaveOccurred()) + Expect(validatePage(html)).To(Succeed()) + }) + It("keeps bare checks write-free even with an unusable default root", func() { + root := GinkgoT().TempDir() + GinkgoT().Setenv("SDD_LOCAL_ROOT", filepath.Join(root, "missing")) + var out bytes.Buffer + Expect(run([]string{"-check"}, &out)).To(Succeed()) + entries, err := os.ReadDir(root) + Expect(err).NotTo(HaveOccurred()) + Expect(entries).To(BeEmpty()) + }) + It("checks in memory without writing and supports explicit output plus stale-file checks", func() { + GinkgoT().Setenv("SDD_LOCAL_ROOT", filepath.Join(GinkgoT().TempDir(), "missing")) + dir := GinkgoT().TempDir() + path := filepath.Join(dir, "viewer.html") + var out bytes.Buffer + Expect(run([]string{"-check"}, &out)).To(Succeed()) + Expect(run([]string{"-o", path}, &out)).To(Succeed()) + Expect(run([]string{"-check", "-o", path}, &out)).To(Succeed()) + Expect(os.WriteFile(path, []byte("stale"), 0600)).To(Succeed()) + Expect(run([]string{"-check", "-o", path}, &out)).NotTo(Succeed()) + }) + It("reports missing check targets, write failures, bad flags and positional arguments", func() { + var out bytes.Buffer + path := filepath.Join(GinkgoT().TempDir(), "missing", "viewer.html") + Expect(run([]string{"-check", "-o", path}, &out)).NotTo(Succeed()) + Expect(run([]string{"-o", path}, &out)).NotTo(Succeed()) + Expect(run([]string{"-bad"}, &out)).NotTo(Succeed()) + Expect(run([]string{"unexpected"}, &out)).NotTo(Succeed()) + }) +}) + +type failedOutput struct{} + +func (failedOutput) Write([]byte) (int, error) { return 0, errors.New("output failed") } diff --git a/pkg/addons/model/executionstatus/cmd/viewer/viewer.tmpl.html b/pkg/addons/model/executionstatus/cmd/viewer/viewer.tmpl.html new file mode 100644 index 000000000..55f151e95 --- /dev/null +++ b/pkg/addons/model/executionstatus/cmd/viewer/viewer.tmpl.html @@ -0,0 +1,38 @@ + + + + + +Execution status model traces + +

Execution status traces

+

Computed by the Go executionstatus reducer. This page presents recorded transitions; it does not simulate the reducer. Observations are not authority or egress enforcement.

+

+ +

+
+ + + diff --git a/pkg/addons/model/executionstatus/explore.go b/pkg/addons/model/executionstatus/explore.go new file mode 100644 index 000000000..277a208af --- /dev/null +++ b/pkg/addons/model/executionstatus/explore.go @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: MIT +package executionstatus + +import ( + "fmt" + "reflect" +) + +type Exploration struct { + States, Transitions, LivenessChecks int + Cells map[string]bool +} +type history struct { + state State + reads, inflight int + now int64 + path []Event +} + +// Explore performs bounded BFS over reachable histories, not just states. +// The representative domain has two identities, two Qs, both admission outcomes, +// payload validity, and times before/equal/after deadline. Rebinding resets the +// lifetime counter only on R1. Deduplication includes counters and monotonic time; +// it never merges a retry violation into an already-visited state. +func Explore(depth int) (Exploration, error) { + result := Exploration{Cells: map[string]bool{}} + if depth < 1 { + return result, fmt.Errorf("depth must be positive") + } + events := explorationEvents() + queue := []history{{state: Unbound{}}} + seen := map[string]bool{} + for len(queue) > 0 { + h := queue[0] + queue = queue[1:] + key := fmt.Sprintf("%#v/%d/%d/%d", h.state, h.reads, h.inflight, h.now) + if seen[key] { + continue + } + seen[key] = true + result.States++ + if err := live(h.state); err != nil { + return result, fmt.Errorf("%w; path=%#v", err, h.path) + } + result.LivenessChecks++ + // Check frontier transitions too: depth bounds expansion, not invariants. + for _, e := range events { + now, hasTime := eventTime(e) + if hasTime && now < h.now { + continue + } + tr := Reduce(h.state, e) + result.Transitions++ + result.Cells[fmt.Sprintf("%T/%T", h.state, e)] = true + next := history{tr.State, h.reads, h.inflight, h.now, append(append([]Event{}, h.path...), e)} + if hasTime { + next.now = now + } + if tr.Rule == R1 { + next.reads = 0 + } + for _, fx := range tr.Effects { + switch fx := fx.(type) { + case Read: + next.reads++ + next.inflight++ + b, q, _ := identity(tr.State) + if fx.B != b || fx.Q != q || tr.Rule != R2 { + return result, counterexample("read identity", next) + } + case Cancel: + _, q, _ := identity(h.state) + if fx.Q != q || next.inflight != 1 { + return result, counterexample("cancel without exact pending read", next) + } + next.inflight-- + case Publish: + if fx.Value != Project(tr.State) { + return result, counterexample("publication differs from projection", next) + } + } + } + if tr.Rule == R3 { + next.inflight-- + } // reply completed the outstanding read. + if next.reads > 1 || next.inflight > 1 || next.inflight < 0 { + return result, counterexample("one read per lifetime/in flight", next) + } + _, pending := tr.State.(Pending) + if pending != (next.inflight == 1) { + return result, counterexample("pending/read correspondence", next) + } + if _, closed := h.state.(Closed); closed && (!reflect.DeepEqual(h.state, tr.State) || len(tr.Effects) != 0 || tr.Rule != I) { + return result, counterexample("Closed absorbing", next) + } + if _, withdrawn := h.state.(Withdrawn); withdrawn { + if _, _, bound := identity(tr.State); bound && tr.Rule != R1 { + return result, counterexample("rebind needs admission", next) + } + } + if v := Project(tr.State); v.Readiness != Available && v.Isolation != UnknownIsolation { + return result, counterexample("nonavailable isolation", next) + } + if o, ok := tr.State.(Observed); ok && !reflect.DeepEqual(h.state, tr.State) { + reply, exact := e.(Reply) + p, wasPending := h.state.(Pending) + if !exact || !wasPending || reply.B != o.B || reply.Q != o.Q || reply.Now >= p.Deadline || !reply.Payload.valid || tr.Rule != R3 { + return result, counterexample("positive without exact current reply", next) + } + } + // Binding data is immutable except for an explicit authoritative R1. + oldB, oldQ, oldBound := identity(h.state) + newB, newQ, newBound := identity(tr.State) + if oldBound && newBound && (oldB != newB || oldQ != newQ) { + return result, counterexample("binding mutation", next) + } + if len(h.path) < depth { + queue = append(queue, next) + } + } + } + return result, nil +} +func counterexample(message string, h history) error { + return fmt.Errorf("%s: state=%#v reads=%d inflight=%d path=%#v", message, h.state, h.reads, h.inflight, h.path) +} + +// All non-Pending states are waiting-for-input or terminal. For Pending, BFS +// must reach a settled state, and each timeout and cancellation exit is checked. +func live(s State) error { + p, ok := s.(Pending) + if !ok { + return nil + } + exits := []Event{Tick{p.Deadline}, Invalidate{"disabled"}, Close{}} + queue := []State{s} + seen := map[State]bool{} + for len(queue) > 0 { + s := queue[0] + queue = queue[1:] + if seen[s] { + continue + } + seen[s] = true + if _, pending := s.(Pending); !pending { + return nil + } + for _, e := range exits { + t := Reduce(s, e) + if _, pending := t.State.(Pending); pending { + return fmt.Errorf("pending exit failed: %T", e) + } + if len(t.Effects) != 2 { + return fmt.Errorf("pending exit lacks cancel/publication: %T", e) + } + if _, ok := t.Effects[0].(Cancel); !ok { + return fmt.Errorf("pending exit does not cancel first: %T", e) + } + queue = append(queue, t.State) + } + } + return fmt.Errorf("no settled/waiting state reachable") +} +func eventTime(e Event) (int64, bool) { + switch e := e.(type) { + case Probe: + return e.Now, true + case Reply: + return e.Now, true + case Failure: + return e.Now, true + case Tick: + return e.Now, true + case Bind, Invalidate, Close: + return 0, false + } + panic("unhandled event constructor") +} +func explorationEvents() []Event { + a := sampleBinding() + b := a + b.Incarnation = "incarnation-b" + out := []Event{Invalidate{"disabled"}, Close{}} + for _, b := range []Binding{a, b} { + for _, q := range []ObservationID{"q", "q2"} { + for _, eligibility := range []Eligibility{Eligible, "denied", "no-contract"} { + out = append(out, Bind{b, q, eligibility}) + } + for _, now := range []int64{0, 1, 2, 3} { + out = append(out, Probe{b, q, now, 2}, Failure{b, q, "failed", now}) + for _, payload := range []Payload{InvalidPayload(), ValidPayload(Value{Available, Isolated}), ValidPayload(Value{Available, Host}), ValidPayload(Value{Unavailable, Isolated}), ValidPayload(Value{Available, UnknownIsolation})} { + out = append(out, Reply{b, q, payload, now}) + } + } + } + } + for _, now := range []int64{0, 1, 2, 3} { + out = append(out, Tick{now}) + } + return out +} diff --git a/pkg/addons/model/executionstatus/gen.go b/pkg/addons/model/executionstatus/gen.go new file mode 100644 index 000000000..92c65fd60 --- /dev/null +++ b/pkg/addons/model/executionstatus/gen.go @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MIT +package executionstatus + +// Generate a self-contained step-through viewer using the real reducer and +// shared trace oracles. The default artifact is local, not committed. +// +//go:generate go run ./cmd/viewer diff --git a/pkg/addons/model/executionstatus/model_test.go b/pkg/addons/model/executionstatus/model_test.go new file mode 100644 index 000000000..1e34bc86b --- /dev/null +++ b/pkg/addons/model/executionstatus/model_test.go @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: MIT +package executionstatus + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "reflect" + "testing" + + ginkgo "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestExecutionStatus(t *testing.T) { + RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, "Execution status model") +} + +var _ = ginkgo.Describe("Executable status model", func() { + for _, trace := range Traces() { + ginkgo.It(fmt.Sprintf("trace family %d: %s", trace.Family, trace.Name), func() { + steps := RunTrace(Unbound{}, trace.Events) + Expect(steps).To(HaveLen(len(trace.Want)), trace.Name) + for i, step := range steps { + Expect(step.Rule).To(Equal(trace.Want[i].Rule), "%s step %d", trace.Name, i) + Expect(step.State).To(Equal(trace.Want[i].State), "%s step %d", trace.Name, i) + Expect(step.Effects).To(Equal(trace.Want[i].Effects), "%s step %d", trace.Name, i) + Expect(step.Guard).NotTo(BeEmpty()) + } + Expect(Project(steps[len(steps)-1].State)).To(Equal(trace.Value), trace.Name) + }) + } + ginkgo.It("includes all eleven normative trace families", func() { + families := map[int]bool{} + for _, trace := range Traces() { + families[trace.Family] = true + } + Expect(families).To(HaveLen(11)) + }) + + ginkgo.It("enumerates every declared marker constructor (Go sums are not sealed)", func() { + f, err := parser.ParseFile(token.NewFileSet(), "types.go", nil, 0) + Expect(err).NotTo(HaveOccurred()) + for marker, examples := range map[string][]any{"isState": stateExamples(), "isEvent": eventExamples(), "isEffect": {Read{}, Cancel{}, Publish{}}} { + declared := map[string]bool{} + for _, d := range f.Decls { + fn, ok := d.(*ast.FuncDecl) + if ok && fn.Name.Name == marker { + declared[fn.Recv.List[0].Type.(*ast.Ident).Name] = true + } + } + listed := map[string]bool{} + for _, x := range examples { + listed[reflect.TypeOf(x).Name()] = true + } + Expect(listed).To(Equal(declared), marker) + } + Expect(AllStates()).To(HaveLen(7)) + Expect(AllEvents()).To(HaveLen(7)) + Expect(Rules()).To(Equal([]Rule{R1, R2, R3, R4, R5, R6, R7, R9, R10, R11, I})) + }) + + ginkgo.It("checks all 49 matrix cells with an independent representative rule oracle", func() { + matrix := [][]Rule{ + {R1, I, I, I, I, R6, R10}, {I, R2, I, I, I, R6, R10}, {I, R7, R3, R4, R5, R6, R10}, + {I, R7, I, I, I, R6, R10}, {I, R7, I, I, I, R6, R10}, {R1, I, I, I, I, R6, R10}, {I, I, I, I, I, I, I}, + } + states := AllStates() + events := AllEvents() + for i, s := range states { + for j, e := range events { + tr := Reduce(s, e) + Expect(tr.Rule).To(Equal(matrix[i][j]), "%T x %T", s, e) + Expect(tr.Guard).NotTo(BeEmpty()) + } + } + }) + + ginkgo.It("explores bounded histories with safety, lifetime, matrix and liveness checks", func() { + report, err := Explore(5) + Expect(err).NotTo(HaveOccurred()) + Expect(report.Cells).To(HaveLen(49)) + Expect(report.States).To(BeNumerically(">", 100)) + Expect(report.Transitions).To(BeNumerically(">", 10000)) + Expect(report.LivenessChecks).To(Equal(report.States)) + _, err = fmt.Fprintf(ginkgo.GinkgoWriter, "BFS: states=%d transitions=%d cells=%d liveness=%d\n", report.States, report.Transitions, len(report.Cells), report.LivenessChecks) + Expect(err).NotTo(HaveOccurred()) + }) + + ginkgo.It("covers deadline guard splits for Reply, Failure, Tick and expired Probe", func() { + b := sampleBinding() + p := Pending{b, "q", 10} + for _, now := range []int64{9, 10, 11} { + for _, payload := range []Payload{InvalidPayload(), ValidPayload(Value{Available, Isolated})} { + tr := Reduce(p, Reply{b, "q", payload, now}) + want := R5 + if now < 10 { + want = R4 + if payload.valid { + want = R3 + } + } + Expect(tr.Rule).To(Equal(want)) + if want == R5 { + Expect(tr.State).To(Equal(SettledUnknown{b, "q", Timeout})) + } + } + want := R5 + if now < 10 { + want = R4 + } + Expect(Reduce(p, Failure{b, "q", "failed", now}).Rule).To(Equal(want)) + want = R5 + if now < 10 { + want = I + } + Expect(Reduce(p, Tick{now}).Rule).To(Equal(want)) + want = R9 + if now < 10 { + want = R2 + } + Expect(Reduce(Unobserved{b, "q"}, Probe{b, "q", now, 10}).Rule).To(Equal(want)) + } + }) + ginkgo.It("rejects invalid local payload enums and conservatively projects nonavailable values", func() { + b := sampleBinding() + for _, v := range []Value{{"invalid", Isolated}, {Available, "invalid"}, {"", ""}} { + Expect(Reduce(Pending{b, "q", 10}, Reply{b, "q", ValidPayload(v), 1}).Rule).To(Equal(R4)) + } + for _, r := range []Readiness{Unavailable, UnknownReadiness} { + for _, i := range []Isolation{Isolated, Host, UnknownIsolation} { + tr := Reduce(Pending{b, "q", 10}, Reply{b, "q", ValidPayload(Value{r, i}), 1}) + Expect(tr.Rule).To(Equal(R3)) + Expect(Project(tr.State)).To(Equal(Value{r, UnknownIsolation})) + } + } + }) + + ginkgo.It("distinguishes every binding component and Q, even at the deadline", func() { + b := sampleBinding() + pending := Pending{b, "q", 10} + typ := reflect.TypeOf(b) + for i := 0; i < typ.NumField(); i++ { + other := b + v := reflect.ValueOf(&other).Elem().Field(i) + v.SetString(v.String() + "-other") + for _, e := range []Event{Probe{other, "q", 10, 20}, Reply{other, "q", ValidPayload(Value{Available, Isolated}), 10}, Failure{other, "q", "failed", 10}} { + Expect(Reduce(pending, e)).To(Equal(Transition{pending, nil, I, "ignored: pair or identity guard"}), fmt.Sprint(e)) + } + } + for _, s := range []State{Unobserved{b, "q"}, pending, Observed{b, "q", Value{Available, Isolated}}, SettledUnknown{b, "q", "failed"}} { + Expect(Reduce(s, Bind{b, "other", Eligible}).Rule).To(Equal(I)) + Expect(Reduce(s, Probe{b, "other", 1, 10}).Rule).To(Equal(I)) + } + }) +}) + +func stateExamples() []any { + var out []any + for _, s := range AllStates() { + out = append(out, s) + } + return out +} +func eventExamples() []any { + var out []any + for _, e := range AllEvents() { + out = append(out, e) + } + return out +} diff --git a/pkg/addons/model/executionstatus/reducer.go b/pkg/addons/model/executionstatus/reducer.go new file mode 100644 index 000000000..d29130e33 --- /dev/null +++ b/pkg/addons/model/executionstatus/reducer.go @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MIT +package executionstatus + +// Reduce consumes serialized inputs. All time is caller-provided monotonic +// time in a caller-chosen unit. Cancellation effects precede publication. +func Reduce(s State, event Event) Transition { + ignored := Transition{s, nil, I, "ignored: pair or identity guard"} + if _, ok := s.(Closed); ok { + return ignored + } + b, q, bound := identity(s) + emit := func(next State, r Rule, g string, effects ...Effect) Transition { + return Transition{next, effects, r, g} + } + withdraw := func(next State, r Rule, g string, c Cause) Transition { + effects := []Effect{} + if p, ok := s.(Pending); ok { + effects = append(effects, Cancel{p.Q}) + } + effects = append(effects, Publish{Unknown(), c}) + return emit(next, r, g, effects...) + } + settle := func(p Pending, r Rule, c Cause) Transition { + return emit(SettledUnknown{p.B, p.Q, c}, r, "exact pending completion; deadline takes precedence", Cancel{p.Q}, Publish{Unknown(), c}) + } + switch e := event.(type) { + case Bind: + if bound { + if b != e.B { + return withdraw(Withdrawn{"binding-changed"}, R11, "bound identity changed", "binding-changed") + } + return ignored // Q never resets an admitted observation. + } + switch s.(type) { + case Unbound, Withdrawn: + if e.Eligibility == Eligible { + return emit(Unobserved{e.B, e.Q}, R1, "authoritative eligible bind", Publish{Unknown(), ""}) + } + c := Cause(e.Eligibility) + return emit(SettledUnknown{e.B, e.Q, c}, R1, "authoritative ineligible bind", Publish{Unknown(), c}) + } + case Probe: + if !bound || b != e.B || q != e.Q { + return ignored + } + switch s.(type) { + case Unobserved: + if e.Deadline <= e.Now { + return emit(SettledUnknown{b, q, Timeout}, R9, "probe already expired", Publish{Unknown(), Timeout}) + } + return emit(Pending{b, q, e.Deadline}, R2, "exact never-probed and future deadline", Read{b, q, e.Deadline}, Publish{Unknown(), ""}) + case Pending, Observed, SettledUnknown: + return emit(s, R7, "exact probe already attempted or settled") + } + case Reply: + if p, ok := s.(Pending); ok && p.B == e.B && p.Q == e.Q { + if e.Now >= p.Deadline { + return settle(p, R5, Timeout) + } + if !e.Payload.valid { + return settle(p, R4, Malformed) + } + v := normalize(e.Payload.value) + return emit(Observed{b, q, v}, R3, "exact valid reply before deadline", Publish{v, ""}) + } + case Failure: + if p, ok := s.(Pending); ok && p.B == e.B && p.Q == e.Q { + if e.Now >= p.Deadline { + return settle(p, R5, Timeout) + } + return settle(p, R4, e.Cause) + } + case Tick: + if p, ok := s.(Pending); ok && e.Now >= p.Deadline { + return settle(p, R5, Timeout) + } + case Invalidate: + return withdraw(Withdrawn{e.Cause}, R6, "lifecycle invalidation", e.Cause) + case Close: + return withdraw(Closed{}, R10, "enclosing request closed", "") + } + return ignored +} diff --git a/pkg/addons/model/executionstatus/traces.go b/pkg/addons/model/executionstatus/traces.go new file mode 100644 index 000000000..951bfca7b --- /dev/null +++ b/pkg/addons/model/executionstatus/traces.go @@ -0,0 +1,258 @@ +// SPDX-License-Identifier: MIT +package executionstatus + +// Trace contains independent expected transitions for consumers and the viewer. +// Guard text is explanatory, not part of the trace oracle. +type Trace struct { + Family int + Name string + Events []Event + Want []Transition + Value Value +} + +func RunTrace(initial State, events []Event) []Transition { + out := make([]Transition, 0, len(events)) + s := initial + for _, e := range events { + t := Reduce(s, e) + out = append(out, t) + s = t.State + } + return out +} +func sampleBinding() Binding { + // #nosec G101 -- Symbolic credential identity for reducer traces, not an authentication secret. + return Binding{Actor: "actor-a", Credential: "credential-a", Conversation: "conversation-a", Mode: "named-user", Addon: "provider-a", Generation: "generation-a", Incarnation: "incarnation-a", ExecutionTool: "execute-a", StatusTool: "status-a"} +} +func AllStates() []State { + b := sampleBinding() + return []State{Unbound{}, Unobserved{b, "q"}, Pending{b, "q", 10}, Observed{b, "q", Value{Available, Isolated}}, SettledUnknown{b, "q", "failed"}, Withdrawn{"disabled"}, Closed{}} +} +func AllEvents() []Event { + b := sampleBinding() + return []Event{Bind{b, "q", Eligible}, Probe{b, "q", 1, 10}, Reply{b, "q", ValidPayload(Value{Available, Isolated}), 2}, Failure{b, "q", "failed", 2}, Tick{10}, Invalidate{"disabled"}, Close{}} +} + +// Traces are fixtures, not generated by Reduce. Each step specifies ordered +// effects explicitly so a reducer regression cannot rewrite its own oracle. +func Traces() []Trace { + a := sampleBinding() + b := a + b.Addon = "provider-b" + b.ExecutionTool = "execute-b" + b.StatusTool = "status-b" + b.Conversation = "conversation-b" + iso := Value{Available, Isolated} + host := Value{Available, Host} + unknown := Unknown() + var out []Trace + var t Trace + var current State + start := func(f int, name string) { t = Trace{Family: f, Name: name}; current = Unbound{} } + step := func(e Event, r Rule, s State, fx ...Effect) { + t.Events = append(t.Events, e) + t.Want = append(t.Want, Transition{State: s, Rule: r, Effects: fx}) + current = s + } + ignore := func(e Event) { step(e, I, current) } + finish := func(v Value) { t.Value = v; out = append(out, t) } + bind := func(b Binding, q ObservationID) { + step(Bind{b, q, Eligible}, R1, Unobserved{b, q}, Publish{unknown, ""}) + } + probe := func(b Binding, q ObservationID) { + step(Probe{b, q, 1, 10}, R2, Pending{b, q, 10}, Read{b, q, 10}, Publish{unknown, ""}) + } + reply := func(b Binding, q ObservationID, v Value) { + vnorm := v + if v.Readiness != Available { + vnorm.Isolation = UnknownIsolation + } + step(Reply{b, q, ValidPayload(v), 2}, R3, Observed{b, q, vnorm}, Publish{vnorm, ""}) + } + repeat := func(b Binding, q ObservationID) { step(Probe{b, q, 1, 10}, R7, current) } + invalidate := func(c Cause, pending bool, q ObservationID) { + fx := []Effect{} + if pending { + fx = append(fx, Cancel{q}) + } + fx = append(fx, Publish{unknown, c}) + step(Invalidate{c}, R6, Withdrawn{c}, fx...) + } + start(1, "happy path and duplicate") + bind(a, "q") + probe(a, "q") + reply(a, "q", iso) + ignore(Reply{a, "q", ValidPayload(host), 3}) + finish(iso) + start(2, "two racing consumers") + bind(a, "q") + probe(a, "q") + repeat(a, "q") + reply(a, "q", host) + repeat(a, "q") + finish(host) + start(3, "timeout cannot retry or reset Q") + bind(a, "q") + probe(a, "q") + step(Tick{10}, R5, SettledUnknown{a, "q", Timeout}, Cancel{"q"}, Publish{unknown, Timeout}) + ignore(Reply{a, "q", ValidPayload(iso), 11}) + repeat(a, "q") + ignore(Probe{a, "q2", 11, 20}) + ignore(Bind{a, "q", Eligible}) + ignore(Bind{a, "q2", Eligible}) + finish(unknown) + for _, failure := range []bool{false, true} { + name := "malformed" + var e Event = Reply{a, "q", InvalidPayload(), 2} + c := Malformed + if failure { + name = "failure" + e = Failure{a, "q", "failed", 2} + c = "failed" + } + start(4, name+" settles without retry") + bind(a, "q") + probe(a, "q") + step(e, R4, SettledUnknown{a, "q", c}, Cancel{"q"}, Publish{unknown, c}) + repeat(a, "q") + ignore(Reply{a, "q", ValidPayload(iso), 3}) + finish(unknown) + } + start(4, "fresh model succeeds") + bind(a, "q") + probe(a, "q") + reply(a, "q", iso) + finish(iso) + start(5, "disable and authoritative re-enable") + bind(a, "q") + probe(a, "q") + invalidate("disabled", true, "q") + ignore(Reply{a, "q", ValidPayload(iso), 2}) + ignore(Probe{a, "q", 2, 10}) + bind(a, "q2") + probe(a, "q2") + reply(a, "q2", iso) + finish(iso) + for _, replacement := range []Binding{b, func() Binding { x := a; x.Incarnation = "incarnation-new"; return x }()} { + start(6, "replacement "+replacement.Incarnation+replacement.Addon) + bind(a, "q") + probe(a, "q") + reply(a, "q", iso) + invalidate("replaced", false, "q") + bind(replacement, "q2") + probe(replacement, "q2") + ignore(Reply{a, "q", ValidPayload(iso), 2}) + reply(replacement, "q2", host) + finish(host) + } + for _, prior := range []string{"pending", "observed", "settled"} { + start(7, "unexpected bind from "+prior) + bind(a, "q") + probe(a, "q") + if prior == "observed" { + reply(a, "q", iso) + } + if prior == "settled" { + step(Failure{a, "q", "failed", 2}, R4, SettledUnknown{a, "q", "failed"}, Cancel{"q"}, Publish{unknown, "failed"}) + } + fx := []Effect{} + if prior == "pending" { + fx = append(fx, Cancel{"q"}) + } + fx = append(fx, Publish{unknown, "binding-changed"}) + step(Bind{b, "q2", Eligible}, R11, Withdrawn{"binding-changed"}, fx...) + ignore(Reply{a, "q", ValidPayload(iso), 2}) + bind(b, "q2") + probe(b, "q2") + reply(b, "q2", host) + finish(host) + } + start(8, "mismatches and deadline equality") + ignore(Probe{a, "q", 1, 10}) + ignore(Reply{a, "q", ValidPayload(iso), 1}) + bind(a, "q") + ignore(Probe{a, "q2", 1, 10}) + ignore(Probe{b, "q", 1, 10}) + ignore(Reply{a, "q", ValidPayload(iso), 1}) + probe(a, "q") + ignore(Tick{9}) + ignore(Reply{b, "q", ValidPayload(iso), 10}) + step(Reply{a, "q", InvalidPayload(), 10}, R5, SettledUnknown{a, "q", Timeout}, Cancel{"q"}, Publish{unknown, Timeout}) + finish(unknown) + start(9, "close pending then all events") + bind(a, "q") + probe(a, "q") + step(Close{}, R10, Closed{}, Cancel{"q"}, Publish{unknown, ""}) + for _, e := range AllEvents() { + ignore(e) + } + finish(unknown) + for i, s := range AllStates() { + start(9, "close constructor "+string(rune('0'+i))) + switch s.(type) { + case Unbound: + case Unobserved: + bind(a, "q") + case Pending: + bind(a, "q") + probe(a, "q") + case Observed: + bind(a, "q") + probe(a, "q") + reply(a, "q", iso) + case SettledUnknown: + step(Bind{a, "q", "denied"}, R1, SettledUnknown{a, "q", "denied"}, Publish{unknown, "denied"}) + case Withdrawn: + invalidate("disabled", false, "q") + case Closed: + step(Close{}, R10, Closed{}, Publish{unknown, ""}) + } + if _, ok := s.(Closed); ok { + ignore(Close{}) + } else if _, ok := s.(Pending); ok { + step(Close{}, R10, Closed{}, Cancel{"q"}, Publish{unknown, ""}) + } else { + step(Close{}, R10, Closed{}, Publish{unknown, ""}) + } + for _, e := range AllEvents() { + ignore(e) + } + finish(unknown) + } + for _, reason := range []Eligibility{"no-contract", "denied"} { + start(10, string(reason)) + step(Bind{a, "q", reason}, R1, SettledUnknown{a, "q", Cause(reason)}, Publish{unknown, Cause(reason)}) + repeat(a, "q") + ignore(Reply{a, "q", ValidPayload(iso), 2}) + ignore(Bind{a, "q", Eligible}) + ignore(Bind{a, "q2", Eligible}) + finish(unknown) + } + start(10, "expired probe") + bind(a, "q") + step(Probe{a, "q", 10, 10}, R9, SettledUnknown{a, "q", Timeout}, Publish{unknown, Timeout}) + repeat(a, "q") + finish(unknown) + other := a + other.Actor = "actor-b" + for _, actor := range []Binding{a, other} { + start(11, "actor separation "+actor.Actor) + bind(actor, "q") + probe(actor, "q") + foreign := other + if actor == other { + foreign = a + } + ignore(Reply{foreign, "q", ValidPayload(iso), 2}) + reply(actor, "q", Value{Unavailable, Isolated}) + invalidate("permission-lost", false, "q") + finish(unknown) + } + start(11, "available unknown is not enforcement") + bind(a, "q") + probe(a, "q") + reply(a, "q", Value{Available, UnknownIsolation}) + finish(Value{Available, UnknownIsolation}) + return out +} diff --git a/pkg/addons/model/executionstatus/types.go b/pkg/addons/model/executionstatus/types.go new file mode 100644 index 000000000..56e6456ae --- /dev/null +++ b/pkg/addons/model/executionstatus/types.go @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: MIT + +// Package executionstatus models one enclosing request's observational status. +// Bind is authoritative host admission; effects are data, never operations. +package executionstatus + +// Binding contains opaque identity tokens, never credentials. All fields take +// part in equality, including incarnation within the same package generation. +type Binding struct { + Actor, Credential, Conversation, Mode, Addon, Generation, Incarnation, ExecutionTool, StatusTool string +} +type ObservationID string +type Cause string +type Eligibility string + +const Eligible Eligibility = "eligible" +const ( + Timeout Cause = "timeout" + Malformed Cause = "malformed" +) + +type Readiness string +type Isolation string + +const ( + Available Readiness = "available" + Unavailable Readiness = "unavailable" + UnknownReadiness Readiness = "unknown" + Isolated Isolation = "isolated" + Host Isolation = "host" + UnknownIsolation Isolation = "unknown" +) + +type Value struct { + Readiness Readiness + Isolation Isolation +} + +func Unknown() Value { return Value{UnknownReadiness, UnknownIsolation} } +func normalize(v Value) Value { + if v.Readiness != Available { + v.Isolation = UnknownIsolation + } + return v +} + +// Payload is a local validation boundary, not a wire decoder. A future strict +// contract decoder supplies ValidPayload only after validating its full schema. +// Zero Payload represents malformed input; no contract dependency is needed. +type Payload struct { + value Value + valid bool +} + +func ValidPayload(v Value) Payload { + valid := (v.Readiness == Available || v.Readiness == Unavailable || v.Readiness == UnknownReadiness) && (v.Isolation == Isolated || v.Isolation == Host || v.Isolation == UnknownIsolation) + return Payload{v, valid} +} +func InvalidPayload() Payload { return Payload{} } + +type State interface{ isState() } +type Unbound struct{} +type Unobserved struct { + B Binding + Q ObservationID +} +type Pending struct { + B Binding + Q ObservationID + Deadline int64 +} +type Observed struct { + B Binding + Q ObservationID + Value Value +} +type SettledUnknown struct { + B Binding + Q ObservationID + Cause Cause +} +type Withdrawn struct{ Cause Cause } +type Closed struct{} + +func (Unbound) isState() {} +func (Unobserved) isState() {} +func (Pending) isState() {} +func (Observed) isState() {} +func (SettledUnknown) isState() {} +func (Withdrawn) isState() {} +func (Closed) isState() {} + +type Event interface{ isEvent() } +type Bind struct { + B Binding + Q ObservationID + Eligibility Eligibility +} +type Probe struct { + B Binding + Q ObservationID + Now, Deadline int64 +} +type Reply struct { + B Binding + Q ObservationID + Payload Payload + Now int64 +} +type Failure struct { + B Binding + Q ObservationID + Cause Cause + Now int64 +} +type Tick struct{ Now int64 } +type Invalidate struct{ Cause Cause } +type Close struct{} + +func (Bind) isEvent() {} +func (Probe) isEvent() {} +func (Reply) isEvent() {} +func (Failure) isEvent() {} +func (Tick) isEvent() {} +func (Invalidate) isEvent() {} +func (Close) isEvent() {} + +type Effect interface{ isEffect() } +type Read struct { + B Binding + Q ObservationID + Deadline int64 +} +type Cancel struct{ Q ObservationID } +type Publish struct { + Value Value + Cause Cause +} + +func (Read) isEffect() {} +func (Cancel) isEffect() {} +func (Publish) isEffect() {} + +type Rule string + +const ( + R1 Rule = "R1" + R2 Rule = "R2" + R3 Rule = "R3" + R4 Rule = "R4" + R5 Rule = "R5" + R6 Rule = "R6" + R7 Rule = "R7" + R9 Rule = "R9" + R10 Rule = "R10" + R11 Rule = "R11" + I Rule = "I" +) + +type Transition struct { + State State + Effects []Effect + Rule Rule + Guard string +} + +func Rules() []Rule { return []Rule{R1, R2, R3, R4, R5, R6, R7, R9, R10, R11, I} } + +func identity(s State) (Binding, ObservationID, bool) { + switch s := s.(type) { + case Unobserved: + return s.B, s.Q, true + case Pending: + return s.B, s.Q, true + case Observed: + return s.B, s.Q, true + case SettledUnknown: + return s.B, s.Q, true + case Unbound, Withdrawn, Closed: + return Binding{}, "", false + } + panic("unhandled state constructor") +} +func Project(s State) Value { + switch s := s.(type) { + case Observed: + return normalize(s.Value) + case Unbound, Unobserved, Pending, SettledUnknown, Withdrawn, Closed: + return Unknown() + } + panic("unhandled state constructor") +} diff --git a/pkg/addons/packaging/host_policy_test.go b/pkg/addons/packaging/host_policy_test.go new file mode 100644 index 000000000..13994fcd1 --- /dev/null +++ b/pkg/addons/packaging/host_policy_test.go @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MIT +package packaging_test + +import ( + "os" + "path/filepath" + "strings" + + "github.com/mudler/LocalAI/core/config" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("official host policy deployment", func() { + It("loads exactly the historical grants through the actual host loader", func() { + root, err := filepath.Abs(filepath.Join("..", "..", "..")) + Expect(err).NotTo(HaveOccurred()) + grants, err := config.LoadAddonHostPolicy(filepath.Join(root, "deploy", "addons", "host-policy.json")) + Expect(err).NotTo(HaveOccurred()) + // Deployment-owned exception: historical identity grants, not core defaults. + legacy := "fine-tuning=finetune.,quantization=quantize.,sandbox=workspace.,sandbox=egress.,office=workspace.,web=workspace.,telegram=connector." + want, err := config.ParseAddonHostOps(legacy) + Expect(err).NotTo(HaveOccurred()) + Expect(grants).To(Equal(want)) + }) + It("explicitly selects the shipped policy in the final Docker runtime stage", func() { + data, err := os.ReadFile(filepath.Join("..", "..", "..", "Dockerfile")) + Expect(err).NotTo(HaveOccurred()) + start := strings.LastIndex(string(data), "\nFROM ") + Expect(start).To(BeNumerically(">=", 0)) + runtime := string(data[start:]) + Expect(runtime).To(ContainSubstring("COPY deploy/addons/host-policy.json /etc/localai/addons/host-policy.json")) + Expect(runtime).To(ContainSubstring("ENV LOCALAI_ADDON_HOST_POLICY=/etc/localai/addons/host-policy.json")) + }) +}) diff --git a/pkg/addons/packaging/official_contract_test.go b/pkg/addons/packaging/official_contract_test.go new file mode 100644 index 000000000..2cf5dfc90 --- /dev/null +++ b/pkg/addons/packaging/official_contract_test.go @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: MIT +package packaging + +import ( + "go/ast" + "go/parser" + "go/token" + "io/fs" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + + "github.com/mudler/LocalAI/core/config" + "github.com/mudler/LocalAI/core/services/addons/hostops" + "github.com/mudler/LocalAI/pkg/addons/contract" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// This audit records calls, including the mutation/undo wrappers' operation +// arguments. It is intentionally independent of deployment prefix grants. +var officialRequests = map[string]contract.HostOperations{ + "fine-tuning": {Required: strings.Fields("finetune.checkpoints finetune.dataset finetune.export finetune.get finetune.list finetune.start finetune.stop")}, + "quantization": {Required: strings.Fields("quantize.get quantize.import quantize.list quantize.start quantize.stop")}, + "office": {Required: strings.Fields("workspace.export workspace.import")}, + "sandbox": {Required: strings.Fields("workspace.changes workspace.commit workspace.materialize"), Optional: strings.Fields("egress.audit egress.check workspace.admin")}, + "web": {Optional: []string{"workspace.admin"}}, +} + +var _ = Describe("official typed package contracts", func() { + for _, id := range firstPartyAddons { + It("audits actual host calls and strict package contract for "+id, func() { + root := filepath.Join("../../..", "addons", id) + data, err := os.ReadFile(filepath.Join(root, "manifest.json")) + Expect(err).NotTo(HaveOccurred()) + m, err := contract.ParseManifest(data) + Expect(err).NotTo(HaveOccurred()) + want, requests := officialRequests[id] + if requests { + Expect(m.Capabilities.HostOperations).To(Equal(&want)) + } else { + Expect(m.Capabilities.HostOperations).To(BeNil(), "no hostclient calls: do not manufacture requests from policy grants") + } + // Collect exact operation literals in production source, including fields + // passed to jobundo. An unknown operation-shaped literal is not evidence + // of support; compare independently with the real registered vocabulary. + var used []string + 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()) + if slices.Contains(hostops.SupportedOperations(), value) { + used = append(used, value) + } + return true + }) + return nil + })).To(Succeed()) + declared := append(slices.Clone(want.Required), want.Optional...) + slices.Sort(used) + used = slices.Compact(used) + Expect(used).To(ConsistOf(declared), "update the audited call inventory when production calls change") + for _, name := range declared { + Expect(hostops.SupportedOperations()).To(ContainElement(name)) + } + policyPath, err := filepath.Abs("../../../deploy/addons/host-policy.json") + Expect(err).NotTo(HaveOccurred()) + grants, err := config.LoadAddonHostPolicy(policyPath) + Expect(err).NotTo(HaveOccurred()) + summary := hostops.Requests(m.Capabilities.HostOperations, hostops.SupportedOperations(), grants[id]) + Expect(summary.MissingRequired).To(BeEmpty()) + denied := hostops.Requests(m.Capabilities.HostOperations, hostops.SupportedOperations(), nil) + Expect(denied.Granted).To(BeEmpty(), "requests never self-grant") + Expect(denied.MissingRequired).To(ConsistOf(want.Required)) + p := newProjectFor(id, map[string][]string{amd: nil}) + layout := p.mustBuild(amd, "1.2.3", "fixture executable") + Expect(p.verify(layout, amd, "1.2.3")).To(Succeed()) + }) + } +}) diff --git a/pkg/addons/packaging/packaging_test.go b/pkg/addons/packaging/packaging_test.go index 6d189764d..cc7564377 100644 --- a/pkg/addons/packaging/packaging_test.go +++ b/pkg/addons/packaging/packaging_test.go @@ -939,7 +939,7 @@ var _ = Describe("catalog template", func() { } }) It("reports the chat tools of the manifest on each entry and not the admin tools", func() { - counts := map[string]int{"tables": 16, "fine-tuning": 8, "quantization": 6, "voice": 0, "office": 11, "sandbox": 4, "web": 5} + counts := map[string]int{"tables": 16, "fine-tuning": 8, "quantization": 6, "voice": 0, "office": 11, "sandbox": 5, "web": 5} for id, want := range counts { p := newProjectFor(id, both) m := p.merged("1.2.3", rev, nil) @@ -1013,7 +1013,7 @@ var _ = Describe("catalog template", func() { for _, e := range entries { got[e.ID] = e.ToolCount } - Expect(got).To(Equal(map[string]int{"tables": 16, "fine-tuning": 8, "quantization": 6, "voice": 0, "face": 0, "studio": 0, "talk": 0, "telegram": 0, "slack": 0, "office": 11, "sandbox": 4, "web": 5})) + Expect(got).To(Equal(map[string]int{"tables": 16, "fine-tuning": 8, "quantization": 6, "voice": 0, "face": 0, "studio": 0, "talk": 0, "telegram": 0, "slack": 0, "office": 11, "sandbox": 5, "web": 5})) }) It("refuses missing inputs, backend repositories, bad channels and unknown placeholders", func() { reg := newRegistry() diff --git a/scripts/addons/ginkgo_report.py b/scripts/addons/ginkgo_report.py new file mode 100644 index 000000000..8c57e5748 --- /dev/null +++ b/scripts/addons/ginkgo_report.py @@ -0,0 +1,34 @@ +# SPDX-License-Identifier: MIT +"""Exact required-case comparison shared by the harness and direct evidence checks.""" +import json +import re + + +def ginkgo_report(path, expected, focus=None): + try: + reports = json.loads(path.read_text()) + assert isinstance(reports, list) and reports + cases = [] + for report in reports: + assert report['SuiteSucceeded'] is True + specs = report['SpecReports'] + assert specs + for spec in specs: + # Suite hooks are execution evidence, not focused It cases. Ginkgo + # gives them a null hierarchy; still require every hook to pass. + if spec['LeafNodeType'] != 'It': + assert spec['State'] == 'passed' + continue + # Ginkgo reports filtered-out specs as skipped. Only an explicit + # report focus may exclude them; a required skipped case still fails. + text = ' '.join((spec.get('ContainerHierarchyTexts') or []) + [spec['LeafNodeText']]) + if focus and not re.search(focus, text): + assert spec['State'] == 'skipped' + continue + assert spec['State'] == 'passed' + if spec['LeafNodeType'] == 'It': + cases.append(spec['LeafNodeText']) + assert cases and sorted(cases) == sorted(expected) + except (OSError, ValueError, KeyError, TypeError, AssertionError): + raise SystemExit('Ginkgo report missing, empty, skipped, failed or inventory mismatch: ' + str(path)) + return cases diff --git a/scripts/addons/ginkgo_report_test.py b/scripts/addons/ginkgo_report_test.py new file mode 100644 index 000000000..27c73f0cf --- /dev/null +++ b/scripts/addons/ginkgo_report_test.py @@ -0,0 +1,85 @@ +# SPDX-License-Identifier: MIT +"""Regression checks for the exact Ginkgo evidence gate (no Go toolchain needed).""" +import json +import pathlib +import tempfile +import unittest + +from ginkgo_report import ginkgo_report + + +def spec(text='required', state='passed', kind='It', containers=None): + return dict(LeafNodeText=text, State=state, LeafNodeType=kind, + ContainerHierarchyTexts=containers) + + +class ReportTests(unittest.TestCase): + def setUp(self): + scratch_root = pathlib.Path(__file__).resolve().parents[2] / '.cache/addons/tmp' + scratch_root.mkdir(parents=True, exist_ok=True) + self.scratch = tempfile.TemporaryDirectory(dir=scratch_root) + self.addCleanup(self.scratch.cleanup) + self.path = pathlib.Path(self.scratch.name) / 'report.json' + + def check(self, specs, expected=None, focus=None, succeeded=True): + self.path.write_text(json.dumps([dict(SuiteSucceeded=succeeded, SpecReports=specs)])) + return ginkgo_report(self.path, ['required'] if expected is None else expected, focus) + + def test_suite_hooks_with_null_hierarchy(self): + self.assertEqual(self.check([spec('', kind='BeforeSuite'), spec(containers=['group']), + spec('', kind='DeferCleanup (Suite)')]), ['required']) + + def test_focused_suite_hooks_are_not_filtered_specs(self): + self.assertEqual(self.check([spec('', kind='BeforeSuite'), spec(containers=['selected']), + spec('other', 'skipped', containers=['elsewhere']), + spec('', kind='DeferCleanup (Suite)')], focus='selected'), ['required']) + + def test_top_level_it_with_null_hierarchy(self): + self.assertEqual(self.check([spec()]), ['required']) + + def test_failed_hook_rejected_even_outside_focus(self): + with self.assertRaises(SystemExit): + self.check([spec('', 'failed', 'BeforeSuite'), spec(containers=['selected'])], focus='selected') + + def test_skipped_hook_rejected(self): + with self.assertRaises(SystemExit): + self.check([spec('', 'skipped', 'BeforeSuite'), spec()]) + + def test_failed_suite_rejected(self): + with self.assertRaises(SystemExit): + self.check([spec()], succeeded=False) + + def test_failed_skipped_pending_required_rejected(self): + for state in ['failed', 'skipped', 'pending', 'panicked', 'interrupted']: + with self.subTest(state=state), self.assertRaises(SystemExit): + self.check([spec(state=state, containers=[])]) + + def test_missing_extra_and_duplicate_cases_rejected(self): + for specs in [[spec('other')], [spec(), spec('extra')], [spec(), spec()]]: + with self.subTest(specs=specs), self.assertRaises(SystemExit): + self.check(specs) + + def test_required_case_cannot_be_excluded_by_focus(self): + with self.assertRaises(SystemExit): + self.check([spec(containers=['selected']), spec('other', 'skipped', containers=[])], + expected=['required', 'other'], focus='selected') + + def test_excluded_passed_spec_rejected(self): + with self.assertRaises(SystemExit): + self.check([spec(containers=['selected']), spec('other', containers=[])], focus='selected') + + def test_hooks_only_are_not_tests(self): + with self.assertRaises(SystemExit): + self.check([spec('', kind='BeforeSuite')]) + + def test_missing_empty_and_malformed_artifacts_rejected(self): + with self.assertRaises(SystemExit): + ginkgo_report(self.path, ['required']) + for text in ['', '[]', '{}', 'null', '[', '[{"SuiteSucceeded":true,"SpecReports":[]}]']: + self.path.write_text(text) + with self.subTest(text=text), self.assertRaises(SystemExit): + ginkgo_report(self.path, ['required']) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/addons/prerequisites_test.sh b/scripts/addons/prerequisites_test.sh index 688d63de0..c46979e7d 100644 --- a/scripts/addons/prerequisites_test.sh +++ b/scripts/addons/prerequisites_test.sh @@ -8,7 +8,7 @@ mkdir -p "$TMPDIR" fixture=$(mktemp -d) trap 'rm -rf "$fixture"' EXIT HUP INT TERM mkdir -p "$fixture/scripts/addons" "$fixture/bin" -for file in prepare.sh verify.sh suites.json ui_cases.py; do +for file in prepare.sh verify.sh suites.json ui_cases.py ginkgo_report.py; do test -f "$root/scripts/addons/$file" || { echo "missing implementation: $file" >&2; exit 1; } cp "$root/scripts/addons/$file" "$fixture/scripts/addons/$file" done @@ -29,6 +29,10 @@ echo "${FIXTURE_PROTOC:-libprotoc 26.1}" TOOL chmod +x "$fixture/bin/"* count=0 +# Exercise successful real-format hooks as well as fail-closed report evidence. +python3 "$root/scripts/addons/ginkgo_report_test.py" +count=$((count + 1)) +printf '{"case":"ginkgo-reporter-regressions","status":"passed"}\n' reject() { name=$1; expected=$2; shift 2 if "$@" > "$fixture/output" 2>&1; then @@ -101,6 +105,11 @@ TOOL reject ginkgo-empty 'Ginkgo report' env PATH="$fixture/bin:$PATH" FIXTURE_REPORT='[]' sh "$verify" prerequisites reject ginkgo-skipped 'Ginkgo report' env PATH="$fixture/bin:$PATH" FIXTURE_REPORT='[{"SuiteSucceeded":true,"SpecReports":[{"State":"skipped","LeafNodeType":"It","LeafNodeText":"sqlite transaction"}]}]' sh "$verify" prerequisites reject ginkgo-missing 'Ginkgo report' env PATH="$fixture/bin:$PATH" FIXTURE_REPORT='{}' sh "$verify" prerequisites +# Valid JSON is insufficient: the actual comparator must reject a missing case +# and a missing report artifact, even when the child exits successfully. +reject ginkgo-inventory-missing 'Ginkgo report' env PATH="$fixture/bin:$PATH" FIXTURE_REPORT='[{"SuiteSucceeded":true,"SpecReports":[{"State":"passed","LeafNodeType":"It","LeafNodeText":"different case"}]}]' sh "$verify" prerequisites +printf '#!/bin/sh\nexit 0\n' > "$fixture/bin/go" +reject ginkgo-artifact-missing 'Ginkgo report' env PATH="$fixture/bin:$PATH" sh "$verify" prerequisites # A failed Ginkgo run names its failed spec and the first line of its failure. cat > "$fixture/bin/go" <<'TOOL' #!/bin/sh @@ -130,6 +139,13 @@ cat > "$fixture/scripts/addons/suites.json" <<'JSON' JSON reject gateway-missing-report 'Ginkgo report' env PATH="$fixture/bin:$PATH" FIXTURE_REPORT='[{"SuiteSucceeded":true,"SpecReports":[{"State":"passed","LeafNodeType":"It","LeafNodeText":"sqlite transaction"}]}]' sh "$verify" gateway reject gateway-skipped-report 'Ginkgo report' env PATH="$fixture/bin:$PATH" FIXTURE_REPORT='[{"SuiteSucceeded":true,"SpecReports":[{"State":"skipped","LeafNodeType":"It","LeafNodeText":"sqlite transaction"}]}]' sh "$verify" gateway +# Pin/browser setup fixtures never build or download: make their disk +# prerequisite deterministic without bypassing the real preparation gate. +cat > "$fixture/bin/df" <<'TOOL' +#!/bin/sh +printf 'Filesystem 1024-blocks Used Available Capacity Mounted on\nfixture 41943040 0 41943040 0%% /\n' +TOOL +chmod +x "$fixture/bin/df" # A commit at a tagged module resolves to the tag, with an exact module sum. cat > "$fixture/bin/go" <<'TOOL' #!/bin/sh diff --git a/scripts/addons/suites.json b/scripts/addons/suites.json index 604381df4..47fbeee29 100644 --- a/scripts/addons/suites.json +++ b/scripts/addons/suites.json @@ -43,7 +43,10 @@ "ui-report-empty", "ui-report-missing", "leak-ignores-command-line", - "leak-finds-working-directory" + "leak-finds-working-directory", + "ginkgo-inventory-missing", + "ginkgo-artifact-missing", + "ginkgo-reporter-regressions" ], "readiness": { "ginkgo": { @@ -105,39 +108,57 @@ "auth", "./pkg/addons/contract", "-ginkgo.json-report=../../../.cache/addons/logs/contracts.json" + ], + [ + "go", + "test", + "-count=1", + "-p", + "2", + "-tags", + "auth", + "./pkg/addons/packaging", + "-ginkgo.json-report=../../../.cache/addons/logs/contracts-packaging.json" + ], + [ + "go", + "test", + "-count=1", + "-p", + "2", + "-tags", + "auth", + ".", + "-ginkgo.json-report=.cache/addons/logs/contracts-architecture.json" ] ], "cases": [ - "accepts independent service tool page and chat capabilities", - "rejects invalid namespace IDs and executable paths", - "rejects add-on IDs that the host serves as a route segment", - "rejects invalid versions ranges and capabilities", - "rejects unknown duplicate trailing and oversized manifest JSON", - "rejects incompatible host API protocol SDK data and platform fixtures", - "validates UI only when declared and rejects inconsistent contributions", "uses canonical version one wire fixtures", "rejects incomplete expired or forged authority shapes", "rejects unknown and oversized wire payloads", "validates finite defaults overrides and cross field bounds", "validates preview receipt states and output descriptions", - "accepts the apps surface fields and keeps headless manifests valid", - "rejects unknown and reserved launch modes", - "rejects remote data absolute and traversing icon paths", - "requires the main page to be one of the declared pages", - "bounds localized titles and descriptions and rejects markup", - "requires the existence tool of an addon whose resources are created or deleted", - "reserves the segments of the navigation routes", - "refuses an add-on that needs a newer SDK than the host provides, and says so", - "accepts a range that contains the host SDK", - "accepts a range that spans the host SDK", - "refuses an add-on built for SDK 1 only on a version 2 host", - "keeps the generic message for a range that is malformed", - "accepts a read tool that names no resource", - "keeps a create tool free of the resource argument", - "refuses a read tool that offers the resource without requiring it", - "refuses a write or schema tool without a resource", - "still accepts a read tool that requires its resource", - "still accepts a write or schema tool that requires its resource", + "validates every enum pair and normalizes nonavailable isolation", + "missing isolation", + "missing readiness", + "bad readiness", + "bad isolation", + "duplicate", + "unknown field", + "outputs", + "trailing", + "null", + "null explanation", + "empty explanation", + "oversized explanation", + "markup", + "control", + "direction", + "oversized payload", + "bridges validation into the existing pure model boundary", + "is valid when the add-on says it acts", + "is refused when the add-on does not say it acts", + "leaves the other kinds alone", "accepts a pull kind with a declared page", "accepts drafts and a preview interval within the bounds", "lets an add-on declare nothing but the connector", @@ -149,10 +170,6 @@ "a page the UI does not declare", "a preview interval too short", "a preview interval too long", - "is valid when the add-on says it acts", - "is refused when the add-on does not say it acts", - "leaves the other kinds alone", - "cuts a long name on a character boundary", "a plain name", "a path keeps its last element", "a Windows path", @@ -161,11 +178,17 @@ "empty", "a hidden name loses its leading dot", "a long name keeps its extension", + "accepts a read tool that names no resource", + "still accepts a read tool that requires its resource", + "refuses a read tool that offers the resource without requiring it", + "refuses a write or schema tool without a resource", + "still accepts a write or schema tool that requires its resource", + "keeps a create tool free of the resource argument", + "accepts an action tool that names no resource, and refuses one that offers it without requiring it", + "keeps the argument names of identity and approval out of an action tool", "is a valid scope for an action request", "is refused for any other request, and an action request refuses any other scope", "has a long timeout of its own that is not part of the limits an add-on receives", - "accepts an action tool that names no resource, and refuses one that offers it without requiring it", - "keeps the argument names of identity and approval out of an action tool", "round-trip", "unknown field", "duplicate key", @@ -186,6 +209,69 @@ "hyphen", "empty verb", "too long", + "has the same IDs as the registry of the React UI", + "parses skillDefinitions from the manifest", + "accepts the add-on id and names that start with it", + "refuses a bad name, naming it", + "refuses a name declared twice", + "refuses more than 16 skills", + "refuses a plain name that does not start with the add-on id", + "accepts a skill that replaces a replaceable built-in skill by its name", + "refuses shadows that does not repeat the name, or names a skill that may not be replaced", + "keeps the replaceable skills and tools apart, so one pin map holds both", + "cuts a long name on a character boundary", + "Explanation markup", + "Readiness override", + "uppercase enums", + "Explanation control", + "Explanation oversized", + "benign explanation alias", + "isolation override", + "Alias empty", + "Label null", + "ID override", + "Page variant", + "Icon variant", + "provider Tools", + "provider StatusTool", + "provider mixed duplicate", + "operation Required", + "operation Optional", + "operation mixed duplicate", + "provider entry variant", + "provider entry mixed duplicate", + "operations entry variant", + "operations entry mixed duplicate", + "commands entry variant", + "commands entry mixed duplicate", + "preserves arbitrary valid locale keys at the localized map boundary", + "accepts known controls and refuses an unknown or repeated one", + "reads ui.controls from a manifest and refuses an unknown field in it", + "draws the network policy only for an add-on granted egress that runs bash", + "serves a second add-on with the same controls, by its grant and not by its name", + "draws the tool providers for an add-on that replaces any built-in tool", + "accepts a read tool without a resource", + "refuses one that works on a resource or is not a read: the page has no resource to name", + "is resourceless like any read without a resource", + "refuses an add-on that needs a newer SDK than the host provides, and says so", + "accepts a range that contains the host SDK", + "accepts a range that spans the host SDK", + "refuses an add-on built for SDK 1 only on a version 2 host", + "keeps the generic message for a range that is malformed", + "accepts independent service tool page and chat capabilities", + "requires the existence tool of an addon whose resources are created or deleted", + "rejects invalid namespace IDs and executable paths", + "rejects add-on IDs that the host serves as a route segment", + "rejects invalid versions ranges and capabilities", + "rejects unknown duplicate trailing and oversized manifest JSON", + "rejects incompatible host API protocol SDK data and platform fixtures", + "validates UI only when declared and rejects inconsistent contributions", + "accepts the apps surface fields and keeps headless manifests valid", + "rejects unknown and reserved launch modes", + "rejects remote data absolute and traversing icon paths", + "requires the main page to be one of the declared pages", + "bounds localized titles and descriptions and rejects markup", + "reserves the segments of the navigation routes", "bash is replaceable", "web_search and web_fetch are replaceable", "a file tool is not", @@ -201,29 +287,715 @@ "extra optional properties are allowed", "lists only the shadows that pass the check", "keeps the file tools off the replaceable list", - "parses skillDefinitions from the manifest", - "accepts the add-on id and names that start with it", - "refuses a bad name, naming it", - "refuses a name declared twice", - "refuses more than 16 skills", - "refuses a plain name that does not start with the add-on id", + "preserves all three declarations through manifest and capability echo", + "keeps old manifests free of extension keys", + "bounds commands and operation requests at their declared limits", + "accepts localized string labels, packaged icons and explicit non-navigation pages", + "root enum excludes objects", + "root required property excludes empty objects", + "root type excludes objects", + "optional constrained properties accept empty objects", + "keywords for other types do not constrain objects", + "non bash execution tool", + "empty optional alias", + "null optional alias", + "empty optional page", + "null optional label", + "empty optional icon", + "unknown command key", + "duplicate command key", + "invalid alias", + "missing id", + "duplicate id", + "undeclared page", + "no default destination", + "foreign icon", + "long label", + "bad locale", + "markup label", + "duplicate locale", + "status schema open", + "status wrong kind", + "empty provider", + "unknown execution tool", + "duplicate execution binding", + "same status binding", + "non UI status", + "status required input", + "status resource", + "empty requests", + "duplicate requests", + "wildcard request", + "unknown request key", + "audits actual host calls and strict package contract for face", + "audits actual host calls and strict package contract for fine-tuning", + "audits actual host calls and strict package contract for office", + "audits actual host calls and strict package contract for quantization", + "audits actual host calls and strict package contract for sandbox", + "audits actual host calls and strict package contract for slack", + "audits actual host calls and strict package contract for studio", + "audits actual host calls and strict package contract for tables", + "audits actual host calls and strict package contract for talk", + "audits actual host calls and strict package contract for telegram", + "audits actual host calls and strict package contract for voice", + "audits actual host calls and strict package contract for web", + "lists every release platform with its runner", + "lists every release platform with its runner", + "lists every release platform with its runner", + "lists every release platform with its runner", + "lists every release platform with its runner", + "lists every release platform with its runner", + "lists every release platform with its runner", + "lists every release platform with its runner", + "lists every release platform with its runner", + "lists every release platform with its runner", + "enables a platform exactly when its native check is recorded", + "enables a platform exactly when its native check is recorded", + "enables a platform exactly when its native check is recorded", + "enables a platform exactly when its native check is recorded", + "enables a platform exactly when its native check is recorded", + "enables a platform exactly when its native check is recorded", + "enables a platform exactly when its native check is recorded", + "enables a platform exactly when its native check is recorded", + "enables a platform exactly when its native check is recorded", + "enables a platform exactly when its native check is recorded", + "has a catalog template", + "has a catalog template", + "has a catalog template", + "has a catalog template", + "has a catalog template", + "has a catalog template", + "has a catalog template", + "has a catalog template", + "has a catalog template", + "has a catalog template", + "publishes every add-on to the one documented repository, with its id as the tag prefix", + "fails on a publishing event when an add-on has no enabled platform", + "publishes when every add-on has an enabled platform", + "gives an add-on without an enabled platform no legs on an event that publishes nothing", + "fails on a missing or malformed platform file on every event", + "joins every enabled platform leg into one verified index", + "never creates a partial index when an enabled platform has no leg", + "refuses duplicate legs, local-check legs, other versions and extra platforms", + "refuses a merged layout that lost a platform after the merge", + "renders digest-pinned entries with an explicit add-on signer identity", + "renders the development channel as mutable entries: the moving tag, no digest", + "renders the development entries of every enabled platform from the manifest, without a package", + "refuses --from-manifest for a pinned channel or together with a merged layout", + "renders an entry for every first-party add-on from its own template", + "reports the chat tools of the manifest on each entry and not the admin tools", + "caps the tool names at MaxEntryTools and keeps the full count", + "lists the built-in skills an add-on replaces", + "lists the built-in tools an add-on replaces after its tools", + "adds the tool fields to an existing index from the repository manifests, and changes nothing else", + "refuses missing inputs, backend repositories, bad channels and unknown placeholders", + "carries placeholders only: no fabricated URL, registry or namespace", + "reads a YAML list and refuses JSON, unknown fields and a second document", + "has no hard-coded add-on path", + "lists the add-ons in the validate matrix", + "gives promote an add-on matrix", + "gives publish an add-on matrix", + "takes the package legs from the plan", + "loops the plan over the same list", + "has consistent package files for face", + "has consistent package files for fine-tuning", + "has consistent package files for office", + "has consistent package files for quantization", + "has consistent package files for sandbox", + "has consistent package files for slack", + "has consistent package files for studio", + "has consistent package files for tables", + "has consistent package files for talk", + "has consistent package files for telegram", + "has consistent package files for voice", + "has consistent package files for web", + "refuses missing and backend repositories before any request", + "pushes platform digests then the index, and a retry is digest-identical", + "rejects overwriting an immutable version with different content", + "adopts published content only for the same version and commit", + "refuses reserved aliases and mismatched tags as immutable tags", + "publishes nothing from a partial or local-check layout", + "fails closed when no platform is enabled, the file is absent or empty", + "rejects enabled platforms without a native runner or check, duplicates and unknown fields", + "prints the enabled platforms with their native runners", + "ships a checked-in Tables matrix with at least one enabled platform", + "moves development forward and leaves it alone for an older commit", + "accepts a diverged branch head and refuses one commit with two contents", + "treats a force-pushed branch as the new truth for a push to the same ref", + "blocks the alias for anything but a push to the channel's own ref", + "refuses development when its commit is unreachable, even for a push", + "fails on any git error that is not a plain not-ancestor answer", + "tells an unreachable commit from a plain not-ancestor", + "moves latest only to a newer stable version", + "refuses reserved or foreign alias names and unpublished digests, leaving aliases unchanged", + "promotes a branch channel under its hashed name", + "accepts the checked-in workflow", + "rejects pull_request_target", + "rejects secrets in a pull request validation job", + "rejects id-token write outside the publishing job", + "lets only the release-index job write the contents of the repository", + "hands the guest image digest of the sandbox-image job to the package leg", + "rejects a registry login or publish in an untrusted job", + "rejects publish that does not wait for every platform leg", + "rejects publish that can run after a failed leg", + "rejects publish that does not need validate", + "rejects a package leg that does not need validate", + "rejects a validate job that cannot stop the release", + "rejects a package or promote job that runs after a failed validate", + "keeps validate unprivileged on every event", + "rejects promotion that does not wait for signing", + "rejects alias promotion before signing", + "rejects an alias command inside the publishing job", + "rejects a literal latest tag", + "rejects backend registries and backend workflows", + "rejects a signer identity that is a pattern or the backend workflow", + "rejects signing by tag or without referrers and the new bundle format", + "rejects missing serialization per channel", + "rejects publication that is shared between add-ons", + "rejects a workflow that names one add-on by path or skips an add-on in the matrix", + "rejects a plan that does not refuse an add-on without an enabled platform", + "rejects a plan that does not exit 1 for an add-on without an enabled platform on a publishing event", + "rejects a plan that treats any matrix error as no legs", + "rejects a repository rule that derives a repository for each add-on", + "rejects a publish or promote matrix that stops the other add-ons on a failure", + "rejects a publish job that downloads the layouts of other add-ons", + "rejects a promote job that does not check the digest format", + "rejects a plan that does not fail closed on missing configuration", + "rejects a checkout of the pull request head or a persisted token", + "rejects write permission at workflow level and a literal platform matrix", + "rejects a publishing job without an environment or a gate", + "does not trust the backend workflow identity in the documented verification", + "loads exactly the historical grants through the actual host loader", + "explicitly selects the shipped policy in the final Docker runtime stage", + "accepts the add-on namespace and refuses every other go-skynet repository", + "rejects a package without its entrypoint", + "rejects a declared UI whose layer or files are missing", + "rejects a package without a required runtime library", + "rejects a manifest whose platform differs from the image", + "rejects an image config or annotation of another platform", + "rejects a manifest version that differs from the release version", + "rejects another artifact type", + "rejects unexpected layers, roles and layer media types", + "rejects an unexpected file in the executable layer", + "rejects a blob whose bytes differ from its digest", + "rejects a receipt that does not match the image", + "rejects invalid extraction permissions", + "rejects a UI file that is executable or outside the declared directory", + "rejects traversal paths, links and duplicates", + "rejects a UI that bundles its own React runtime", + "rejects an icon that is not an image of its type", + "rejects an invalid platform layout marker or index", + "lays the skills of the add-on in their own layer and verifies them with the install rules", + "refuses to build when the skills folder and the manifest disagree, or a skill breaks a rule", + "builds a deterministic platform layout with executable, ui and manifest layers", + "restores the validated executable mode on extraction", + "refuses a platform that the matrix has not enabled unless it is a local check", + "refuses an invalid version, an existing layout and an empty executable", + "never publishes from a pull request, whatever the ref", + "maps master alone to development and publishes the commit tag", + "never publishes from a branch push, whatever the branch is named", + "keeps reserved names out of the branch namespace", + "separates refs that normalize to the same label", + "bounds empty, Unicode and very long branch names", + "lets a tag push prepare the immutable version and never move latest", + "promotes latest only for a published, stable, non-draft release", + "rejects unknown events and a malformed commit", + "prints the decision as key=value lines", + "keeps official addon identities out of generic CLI and config policy code", + "keeps provider and Home dispatch independent of official addon IDs", + "bounds model actions to one mapping per supported capability", + "declares only owning model actions for existing addon destinations", + "round trips declared model destinations without changing protocol or SDK", + "accepts SDK route segment and combined path boundaries", + "unknown field", + "duplicate field", + "case field", + "case page", + "case path", + "duplicate capability", + "missing page", + "undeclared page", + "unknown capability", + "native chat", + "native completion", + "aggregate LLM", + "any capability", + "noncanonical capability", + "absolute", + "URL", + "traversal", + "dot", + "encoded traversal", + "query", + "hash", + "backslash", + "empty segment", + "trailing slash", + "empty path", + "null path", + "long segment", + "too many segments", + "long combined route", + "rejects noncanonical modelActions entry keys and duplicate keys", + "wrong object", + "additional properties true", + "additional properties absent", + "required", + "enum", + "unknown schema keyword", + "malformed required", + "resource", + "admin", + "status uiOnly", + "status kind", + "action uiOnly", + "action kind", + "shadow", + "accepts empty arguments with optional property constraints", "refuses protected or reserved plain names, whatever the add-on id", - "accepts a skill that replaces a replaceable built-in skill by its name", - "refuses shadows that does not repeat the name, or names a skill that may not be replaced", "keeps embedded names protected or reserved without requiring a slot fallback", - "keeps the replaceable skills and tools apart, so one pin map holds both", - "accepts a read tool without a resource", - "refuses one that works on a resource or is not a read: the page has no resource to name", - "is resourceless like any read without a resource", - "has the same IDs as the registry of the React UI", - "accepts known controls and refuses an unknown or repeated one", - "reads ui.controls from a manifest and refuses an unknown field in it", - "draws the network policy only for an add-on granted egress that runs bash", - "serves a second add-on with the same controls, by its grant and not by its name", - "draws the tool providers for an add-on that replaces any built-in tool", "does not promise a core fallback for document provider names" ], - "report": ".cache/addons/logs/contracts.json" + "report": ".cache/addons/logs/contracts.json", + "reports": [ + { + "path": ".cache/addons/logs/contracts.json", + "cases": [ + "a plain name", + "a path keeps its last element", + "a Windows path", + "control characters and quotes", + "dots only", + "empty", + "a hidden name loses its leading dot", + "a long name keeps its extension", + "accepts a read tool that names no resource", + "still accepts a read tool that requires its resource", + "refuses a read tool that offers the resource without requiring it", + "refuses a write or schema tool without a resource", + "still accepts a write or schema tool that requires its resource", + "keeps a create tool free of the resource argument", + "accepts an action tool that names no resource, and refuses one that offers it without requiring it", + "keeps the argument names of identity and approval out of an action tool", + "is valid when the add-on says it acts", + "is refused when the add-on does not say it acts", + "leaves the other kinds alone", + "cuts a long name on a character boundary", + "refuses an add-on that needs a newer SDK than the host provides, and says so", + "accepts a range that contains the host SDK", + "accepts a range that spans the host SDK", + "refuses an add-on built for SDK 1 only on a version 2 host", + "keeps the generic message for a range that is malformed", + "accepts independent service tool page and chat capabilities", + "requires the existence tool of an addon whose resources are created or deleted", + "rejects invalid namespace IDs and executable paths", + "rejects add-on IDs that the host serves as a route segment", + "rejects invalid versions ranges and capabilities", + "rejects unknown duplicate trailing and oversized manifest JSON", + "rejects incompatible host API protocol SDK data and platform fixtures", + "validates UI only when declared and rejects inconsistent contributions", + "accepts the apps surface fields and keeps headless manifests valid", + "rejects unknown and reserved launch modes", + "rejects remote data absolute and traversing icon paths", + "requires the main page to be one of the declared pages", + "bounds localized titles and descriptions and rejects markup", + "reserves the segments of the navigation routes", + "bounds model actions to one mapping per supported capability", + "declares only owning model actions for existing addon destinations", + "round trips declared model destinations without changing protocol or SDK", + "accepts SDK route segment and combined path boundaries", + "unknown field", + "duplicate field", + "case field", + "case page", + "case path", + "duplicate capability", + "missing page", + "undeclared page", + "unknown capability", + "native chat", + "native completion", + "aggregate LLM", + "any capability", + "noncanonical capability", + "absolute", + "URL", + "traversal", + "dot", + "encoded traversal", + "query", + "hash", + "backslash", + "empty segment", + "trailing slash", + "empty path", + "null path", + "long segment", + "too many segments", + "long combined route", + "rejects noncanonical modelActions entry keys and duplicate keys", + "validates every enum pair and normalizes nonavailable isolation", + "missing isolation", + "missing readiness", + "bad readiness", + "bad isolation", + "duplicate", + "unknown field", + "outputs", + "trailing", + "null", + "null explanation", + "empty explanation", + "oversized explanation", + "markup", + "control", + "direction", + "oversized payload", + "bridges validation into the existing pure model boundary", + "is a valid scope for an action request", + "is refused for any other request, and an action request refuses any other scope", + "has a long timeout of its own that is not part of the limits an add-on receives", + "bash is replaceable", + "web_search and web_fetch are replaceable", + "a file tool is not", + "an unknown name is not", + "a page-only tool cannot shadow", + "two tools cannot shadow one name", + "a shadow of bash must be an action tool", + "a shadow of web_fetch must be a read tool", + "a shadow of bash must declare command", + "a shadow keeps the type of a required property", + "a shadow requires no property the built-in does not", + "a shadow names no resource", + "extra optional properties are allowed", + "lists only the shadows that pass the check", + "keeps the file tools off the replaceable list", + "accepts known controls and refuses an unknown or repeated one", + "reads ui.controls from a manifest and refuses an unknown field in it", + "draws the network policy only for an add-on granted egress that runs bash", + "serves a second add-on with the same controls, by its grant and not by its name", + "draws the tool providers for an add-on that replaces any built-in tool", + "Explanation markup", + "Readiness override", + "uppercase enums", + "Explanation control", + "Explanation oversized", + "benign explanation alias", + "isolation override", + "Alias empty", + "Label null", + "ID override", + "Page variant", + "Icon variant", + "provider Tools", + "provider StatusTool", + "provider mixed duplicate", + "operation Required", + "operation Optional", + "operation mixed duplicate", + "provider entry variant", + "provider entry mixed duplicate", + "operations entry variant", + "operations entry mixed duplicate", + "commands entry variant", + "commands entry mixed duplicate", + "preserves arbitrary valid locale keys at the localized map boundary", + "parses skillDefinitions from the manifest", + "accepts the add-on id and names that start with it", + "refuses a bad name, naming it", + "refuses a name declared twice", + "refuses more than 16 skills", + "refuses a plain name that does not start with the add-on id", + "refuses protected or reserved plain names, whatever the add-on id", + "accepts a skill that replaces a replaceable built-in skill by its name", + "refuses shadows that does not repeat the name, or names a skill that may not be replaced", + "keeps embedded names protected or reserved without requiring a slot fallback", + "keeps the replaceable skills and tools apart, so one pin map holds both", + "has the same IDs as the registry of the React UI", + "accepts a read tool without a resource", + "refuses one that works on a resource or is not a read: the page has no resource to name", + "is resourceless like any read without a resource", + "uses canonical version one wire fixtures", + "rejects incomplete expired or forged authority shapes", + "rejects unknown and oversized wire payloads", + "validates finite defaults overrides and cross field bounds", + "validates preview receipt states and output descriptions", + "round-trip", + "unknown field", + "duplicate key", + "trailing", + "not an object", + "empty", + "refuses an oversized body", + "finetune.get", + "quantize.start", + "finetune.a.b", + "empty", + "no dot", + "leading dot", + "uppercase", + "trailing space", + "slash", + "trailing newline", + "hyphen", + "empty verb", + "too long", + "does not promise a core fallback for document provider names", + "wrong object", + "additional properties true", + "additional properties absent", + "required", + "enum", + "unknown schema keyword", + "malformed required", + "resource", + "admin", + "status uiOnly", + "status kind", + "action uiOnly", + "action kind", + "shadow", + "accepts empty arguments with optional property constraints", + "preserves all three declarations through manifest and capability echo", + "keeps old manifests free of extension keys", + "bounds commands and operation requests at their declared limits", + "accepts localized string labels, packaged icons and explicit non-navigation pages", + "root enum excludes objects", + "root required property excludes empty objects", + "root type excludes objects", + "optional constrained properties accept empty objects", + "keywords for other types do not constrain objects", + "non bash execution tool", + "empty optional alias", + "null optional alias", + "empty optional page", + "null optional label", + "empty optional icon", + "unknown command key", + "duplicate command key", + "invalid alias", + "missing id", + "duplicate id", + "undeclared page", + "no default destination", + "foreign icon", + "long label", + "bad locale", + "markup label", + "duplicate locale", + "status schema open", + "status wrong kind", + "empty provider", + "unknown execution tool", + "duplicate execution binding", + "same status binding", + "non UI status", + "status required input", + "status resource", + "empty requests", + "duplicate requests", + "wildcard request", + "unknown request key", + "accepts a pull kind with a declared page", + "accepts drafts and a preview interval within the bounds", + "lets an add-on declare nothing but the connector", + "no kind", + "a bad kind id", + "a repeated kind", + "markup in the title", + "an unknown delivery", + "a page the UI does not declare", + "a preview interval too short", + "a preview interval too long" + ] + }, + { + "path": ".cache/addons/logs/contracts-packaging.json", + "cases": [ + "audits actual host calls and strict package contract for face", + "audits actual host calls and strict package contract for fine-tuning", + "audits actual host calls and strict package contract for office", + "audits actual host calls and strict package contract for quantization", + "audits actual host calls and strict package contract for sandbox", + "audits actual host calls and strict package contract for slack", + "audits actual host calls and strict package contract for studio", + "audits actual host calls and strict package contract for tables", + "audits actual host calls and strict package contract for talk", + "audits actual host calls and strict package contract for telegram", + "audits actual host calls and strict package contract for voice", + "audits actual host calls and strict package contract for web", + "lists every release platform with its runner", + "lists every release platform with its runner", + "lists every release platform with its runner", + "lists every release platform with its runner", + "lists every release platform with its runner", + "lists every release platform with its runner", + "lists every release platform with its runner", + "lists every release platform with its runner", + "lists every release platform with its runner", + "lists every release platform with its runner", + "enables a platform exactly when its native check is recorded", + "enables a platform exactly when its native check is recorded", + "enables a platform exactly when its native check is recorded", + "enables a platform exactly when its native check is recorded", + "enables a platform exactly when its native check is recorded", + "enables a platform exactly when its native check is recorded", + "enables a platform exactly when its native check is recorded", + "enables a platform exactly when its native check is recorded", + "enables a platform exactly when its native check is recorded", + "enables a platform exactly when its native check is recorded", + "has a catalog template", + "has a catalog template", + "has a catalog template", + "has a catalog template", + "has a catalog template", + "has a catalog template", + "has a catalog template", + "has a catalog template", + "has a catalog template", + "has a catalog template", + "publishes every add-on to the one documented repository, with its id as the tag prefix", + "fails on a publishing event when an add-on has no enabled platform", + "publishes when every add-on has an enabled platform", + "gives an add-on without an enabled platform no legs on an event that publishes nothing", + "fails on a missing or malformed platform file on every event", + "joins every enabled platform leg into one verified index", + "never creates a partial index when an enabled platform has no leg", + "refuses duplicate legs, local-check legs, other versions and extra platforms", + "refuses a merged layout that lost a platform after the merge", + "renders digest-pinned entries with an explicit add-on signer identity", + "renders the development channel as mutable entries: the moving tag, no digest", + "renders the development entries of every enabled platform from the manifest, without a package", + "refuses --from-manifest for a pinned channel or together with a merged layout", + "renders an entry for every first-party add-on from its own template", + "reports the chat tools of the manifest on each entry and not the admin tools", + "caps the tool names at MaxEntryTools and keeps the full count", + "lists the built-in skills an add-on replaces", + "lists the built-in tools an add-on replaces after its tools", + "adds the tool fields to an existing index from the repository manifests, and changes nothing else", + "refuses missing inputs, backend repositories, bad channels and unknown placeholders", + "carries placeholders only: no fabricated URL, registry or namespace", + "reads a YAML list and refuses JSON, unknown fields and a second document", + "has no hard-coded add-on path", + "lists the add-ons in the validate matrix", + "gives promote an add-on matrix", + "gives publish an add-on matrix", + "takes the package legs from the plan", + "loops the plan over the same list", + "has consistent package files for face", + "has consistent package files for fine-tuning", + "has consistent package files for office", + "has consistent package files for quantization", + "has consistent package files for sandbox", + "has consistent package files for slack", + "has consistent package files for studio", + "has consistent package files for tables", + "has consistent package files for talk", + "has consistent package files for telegram", + "has consistent package files for voice", + "has consistent package files for web", + "refuses missing and backend repositories before any request", + "pushes platform digests then the index, and a retry is digest-identical", + "rejects overwriting an immutable version with different content", + "adopts published content only for the same version and commit", + "refuses reserved aliases and mismatched tags as immutable tags", + "publishes nothing from a partial or local-check layout", + "fails closed when no platform is enabled, the file is absent or empty", + "rejects enabled platforms without a native runner or check, duplicates and unknown fields", + "prints the enabled platforms with their native runners", + "ships a checked-in Tables matrix with at least one enabled platform", + "moves development forward and leaves it alone for an older commit", + "accepts a diverged branch head and refuses one commit with two contents", + "treats a force-pushed branch as the new truth for a push to the same ref", + "blocks the alias for anything but a push to the channel's own ref", + "refuses development when its commit is unreachable, even for a push", + "fails on any git error that is not a plain not-ancestor answer", + "tells an unreachable commit from a plain not-ancestor", + "moves latest only to a newer stable version", + "refuses reserved or foreign alias names and unpublished digests, leaving aliases unchanged", + "promotes a branch channel under its hashed name", + "accepts the checked-in workflow", + "rejects pull_request_target", + "rejects secrets in a pull request validation job", + "rejects id-token write outside the publishing job", + "lets only the release-index job write the contents of the repository", + "hands the guest image digest of the sandbox-image job to the package leg", + "rejects a registry login or publish in an untrusted job", + "rejects publish that does not wait for every platform leg", + "rejects publish that can run after a failed leg", + "rejects publish that does not need validate", + "rejects a package leg that does not need validate", + "rejects a validate job that cannot stop the release", + "rejects a package or promote job that runs after a failed validate", + "keeps validate unprivileged on every event", + "rejects promotion that does not wait for signing", + "rejects alias promotion before signing", + "rejects an alias command inside the publishing job", + "rejects a literal latest tag", + "rejects backend registries and backend workflows", + "rejects a signer identity that is a pattern or the backend workflow", + "rejects signing by tag or without referrers and the new bundle format", + "rejects missing serialization per channel", + "rejects publication that is shared between add-ons", + "rejects a workflow that names one add-on by path or skips an add-on in the matrix", + "rejects a plan that does not refuse an add-on without an enabled platform", + "rejects a plan that does not exit 1 for an add-on without an enabled platform on a publishing event", + "rejects a plan that treats any matrix error as no legs", + "rejects a repository rule that derives a repository for each add-on", + "rejects a publish or promote matrix that stops the other add-ons on a failure", + "rejects a publish job that downloads the layouts of other add-ons", + "rejects a promote job that does not check the digest format", + "rejects a plan that does not fail closed on missing configuration", + "rejects a checkout of the pull request head or a persisted token", + "rejects write permission at workflow level and a literal platform matrix", + "rejects a publishing job without an environment or a gate", + "does not trust the backend workflow identity in the documented verification", + "loads exactly the historical grants through the actual host loader", + "explicitly selects the shipped policy in the final Docker runtime stage", + "accepts the add-on namespace and refuses every other go-skynet repository", + "rejects a package without its entrypoint", + "rejects a declared UI whose layer or files are missing", + "rejects a package without a required runtime library", + "rejects a manifest whose platform differs from the image", + "rejects an image config or annotation of another platform", + "rejects a manifest version that differs from the release version", + "rejects another artifact type", + "rejects unexpected layers, roles and layer media types", + "rejects an unexpected file in the executable layer", + "rejects a blob whose bytes differ from its digest", + "rejects a receipt that does not match the image", + "rejects invalid extraction permissions", + "rejects a UI file that is executable or outside the declared directory", + "rejects traversal paths, links and duplicates", + "rejects a UI that bundles its own React runtime", + "rejects an icon that is not an image of its type", + "rejects an invalid platform layout marker or index", + "lays the skills of the add-on in their own layer and verifies them with the install rules", + "refuses to build when the skills folder and the manifest disagree, or a skill breaks a rule", + "builds a deterministic platform layout with executable, ui and manifest layers", + "restores the validated executable mode on extraction", + "refuses a platform that the matrix has not enabled unless it is a local check", + "refuses an invalid version, an existing layout and an empty executable", + "never publishes from a pull request, whatever the ref", + "maps master alone to development and publishes the commit tag", + "never publishes from a branch push, whatever the branch is named", + "keeps reserved names out of the branch namespace", + "separates refs that normalize to the same label", + "bounds empty, Unicode and very long branch names", + "lets a tag push prepare the immutable version and never move latest", + "promotes latest only for a published, stable, non-draft release", + "rejects unknown events and a malformed commit", + "prints the decision as key=value lines" + ] + }, + { + "path": ".cache/addons/logs/contracts-architecture.json", + "cases": [ + "keeps official addon identities out of generic CLI and config policy code", + "keeps provider and Home dispatch independent of official addon IDs" + ] + } + ] }, "catalog": { "kind": "ginkgo", @@ -398,7 +1170,10 @@ "installs the linux/arm64 child of an index with four platforms", "refuses an index that does not list the host platform", "refuses a child whose digest differs from the one the index lists", - "still installs a single manifest and refuses a different digest" + "still installs a single manifest and refuses a different digest", + "persists model actions and typed declarations together separately and absent in verified immutable manifests and receipts", + "refuses unsupported typed candidates without changing the previous package bytes digest or receipt", + "discards only the creating installer's unpublished single use stage" ], "report": ".cache/addons/logs/packages.json" }, @@ -418,35 +1193,15 @@ ] ], "cases": [ - "recovers every durable journal boundary without replaying publication", - "removes abandoned staging without removing published packages on recovery", - "keeps immutable history on successful upgrade and reinstallation", - "excludes competing owners and rejects conflicting durable identities", - "never opens replica roots or elects an absent owner", - "binds duplicate operation IDs to actor and command and serializes conflicts", - "refuses commands and asset reads for an add-on ID the host serves as a route", - "retains generations identity data and grants across uninstall and restart", - "preserves old packages and active generation on manager upgrade failure", - "requires current authoritative administration even for idempotent retries", - "rejects symlinked package roots without touching external staging", - "executes enabled owner intent on startup and stops on disable and close without forwarder execution", + "persists and recovers model actions and declarations through install manual and automatic upgrades and real activation rollback", + "fails the install and says the package could not be verified, without installing anything", "retries runtime synchronization after canceled disable and generation upgrade", - "binds lifecycle lookup to current admin and original actor without writes or repair", - "cancels status lookup while a lifecycle writer holds the journal lock", - "denies enable and disable for a stale generation without recording anything", - "lists the identifiers of installed addons, disabled ones included", - "refuses to list installed identifiers while recovery is required", - "lists apps by visibility and serves an icon only for a visible app at its generation", - "reports the supervisor state of an app through a crash and a restart", - "reads a flat directory in batches and stops at the entry bound", - "adds up nested files and reports a complete walk as not truncated", - "never follows or counts a symbolic link", - "keeps a result for a short time so polling does not walk again", - "stops at the time bound and reports a truncated size", - "does not keep a result cut short by the caller", - "is what the installed view reports for the data directory", "resolves a package for the current enabled generation and digest only", + "binds lifecycle lookup to current admin and original actor without writes or repair", + "publishes identical bytes and digests with sorted archive entries for unchanged inputs and transforms", "answers from the current installed manifest only and follows a generation change", + "does not retain the caller's map or prefix slices", + "keeps an explicitly empty startup policy immutable", "reports a digest-only change of a moving channel, applies nothing and offers the pinned upgrade", "refuses a second operation ID for the same transition, in flight or done, and applies it once", "runs concurrent requests with one operation ID once and gives each the same answer", @@ -461,8 +1216,21 @@ "shows the pending update in the apps list from the cache only", "does not accept pins without an update checker or on another action", "keeps the unpinned typed upgrade of an earlier release working", - "carries the SDK refusal text to the caller of a failed install and keeps the generic error", - "installs an add-on whose SDK range contains the host SDK", + "traces completion, retries, detach, interruption and late delivery without replay", + "keeps terminals immutable for every event and gives each active state a terminal exit", + "denies enable and disable for a stale generation without recording anything", + "lists the identifiers of installed addons, disabled ones included", + "refuses to list installed identifiers while recovery is required", + "lists apps by visibility and serves an icon only for a visible app at its generation", + "distinguishes healthy empty connector discovery from loss of runtime ownership", + "executes enabled owner intent on startup and stops on disable and close without forwarder execution", + "writes the owner and storage only, with no address", + "loads a file written in the old format with an address", + "loads when only the address changed", + "refuses a changed owner ID", + "refuses a changed storage ID", + "is offered when the tag moved, pinned to the build the tag names, and upgrades to exactly that build", + "refuses the upgrade when the tag moved again after the check, and installs nothing", "upgrades an installed add-on through the same path as a manual upgrade", "does nothing when no newer version is listed", "refuses a candidate whose package no longer matches its listed digest and replaces nothing", @@ -471,34 +1239,45 @@ "does nothing without an update checker", "keeps the manual upgrade closed to a caller who is not an administrator", "copies them at install, upgrade and owner start, and removes them at uninstall", + "installs upgrades and uninstalls the actual Sandbox document skills through verified receipts", "never fails the operation when the copy fails", - "fails the install and says the package could not be verified, without installing anything", + "copies every capability field of the manifest, so a new field cannot be forgotten", + "reports the supervisor state of an app through a crash and a restart", + "starts the enabled add-ons side by side, so the start takes the slowest ones and not the sum", + "reads a flat directory in batches and stops at the entry bound", + "adds up nested files and reports a complete walk as not truncated", + "never follows or counts a symbolic link", + "keeps a result for a short time so polling does not walk again", + "stops at the time bound and reports a truncated size", + "does not keep a result cut short by the caller", + "is what the installed view reports for the data directory", + "finishes a real backup and commit after the submitting request is cancelled", + "polls and retries during a blocked backup, rejects conflicting IDs, and retains the receipt on restart", + "reconciles durable accepted work to interrupted without replay", + "Close cancels and joins an accepted owner worker before releasing storage", + "Close cancels a backup in flight and joins its cleanup before releasing ownership", + "serializes synchronous disable against an accepted upgrade without overwriting its journal", + "carries the SDK refusal text to the caller of a failed install and keeps the generic error", + "installs an add-on whose SDK range contains the host SDK", + "rejects symlinked package roots without touching external staging", + "recovers every durable journal boundary without replaying publication", + "removes abandoned staging without removing published packages on recovery", + "keeps immutable history on successful upgrade and reinstallation", + "stamps the first install, keeps the time over an upgrade and stamps it again after an uninstall", + "excludes competing owners and rejects conflicting durable identities", + "never opens replica roots or elects an absent owner", + "needs a state root for the owner only", + "refuses commands and asset reads for an add-on ID the host serves as a route", + "binds duplicate operation IDs to actor and command and serializes conflicts", + "retains generations identity data and grants across uninstall and restart", + "preserves old packages and active generation on manager upgrade failure", + "requires current authoritative administration even for idempotent retries", "names a release that the library does not list", "names two libraries that list the same add-on", "names a library that cannot be read", "reads the library on demand when the startup refresh has not filled the cache", "reads the library again when the startup refresh failed", - "writes the owner and storage only, with no address", - "loads a file written in the old format with an address", - "loads when only the address changed", - "refuses a changed owner ID", - "refuses a changed storage ID", - "copies every capability field of the manifest, so a new field cannot be forgotten", - "is offered when the tag moved, pinned to the build the tag names, and upgrades to exactly that build", - "refuses the upgrade when the tag moved again after the check, and installs nothing", - "stamps the first install, keeps the time over an upgrade and stamps it again after an uninstall", - "needs a state root for the owner only", - "starts the enabled add-ons side by side, so the start takes the slowest ones and not the sum", - "distinguishes healthy empty connector discovery from loss of runtime ownership", - "installs upgrades and uninstalls the actual Sandbox document skills through verified receipts", - "finishes a real backup and commit after the submitting request is cancelled", - "polls and retries during a blocked backup, rejects conflicting IDs, and retains the receipt on restart", - "reconciles durable accepted work to interrupted without replay", - "Close cancels and joins an accepted owner worker before releasing storage", - "traces completion, retries, detach, interruption and late delivery without replay", - "keeps terminals immutable for every event and gives each active state a terminal exit", - "Close cancels a backup in flight and joins its cleanup before releasing ownership", - "serializes synchronous disable against an accepted upgrade without overwriting its journal" + "cancels status lookup while a lifecycle writer holds the journal lock" ], "report": ".cache/addons/logs/lifecycle.json" }, @@ -515,6 +1294,17 @@ "auth", "./core/services/addons/runtime", "-ginkgo.json-report=../../../../.cache/addons/logs/runtime.json" + ], + [ + "go", + "test", + "-count=1", + "-p", + "2", + "-tags", + "auth", + "./addons/internal/protocol", + "-ginkgo.json-report=../../../.cache/addons/logs/runtime-protocol.json" ] ], "cases": [ @@ -566,9 +1356,162 @@ "kills an add-on that ignores the closing of its startup channel, within the grace period", "goes to the log line by line with the add-on ID and a cap per line", "records the chat with the envelope of a read and of an action while it runs", - "sends the add-on an envelope without a new field" + "sends the add-on an envelope without a new field", + "requires exact typed capability equality on authenticated registration health", + "keeps all typed declarations on automatic crash restart with a fresh incarnation", + "replays typed and legacy receipts across process restarts", + "refuses missing wire tool discriminators before any read", + "serializes exact identity admission with withdrawal and never dispatches the replacement", + "cannot observe a restarted same-generation process through the captured client", + "rejects output handles before they can reach storage or output-open", + "settles callback denial as failure even if the addon returns a valid positive payload", + "cleans up after cancellation and rejects repeat correlation", + "keeps an ordinary concurrent read authorized while an observation is pending", + "error", + "malformed/artifact payload", + "success", + "timeout", + "cancels an admitted observation when the runtime withdraws", + "encodes a payload", + "reads the tool of a request", + "empty tool", + "no tool", + "not an object", + "no arguments", + "decodes arguments into a closed struct", + "unknown field", + "trailing value", + "wrong type", + "bad request", + "too large", + "not found", + "conflict", + "feature", + "scope", + "keeps any other failure as it is", + "ends the context when the owner closes the startup channel, so Run can return", + "serves /act for a runtime that implements ActRuntime, under the act scope only", + "has no /act for a runtime that does not act", + "serves /connector for a runtime that implements ConnectorRuntime, behind the incarnation credential", + "answers a refusal of the add-on as a bad request", + "has no /connector for a runtime that carries no connector", + "refuses without the protocol flag and builds no runtime", + "echoes model actions and typed declarations together separately and absent over authenticated health", + "sends the reason of a bad request on, so the owner can tell the person what to fix", + "keeps the plain answer for a bad request without a reason", + "closes the runtime with a bounded context before it exits, once", + "exits when the owner closes the startup channel", + "still exits when the runtime does not return in time", + "exits at once for a runtime with nothing to close" ], - "report": ".cache/addons/logs/runtime.json" + "report": ".cache/addons/logs/runtime.json", + "reports": [ + { + "path": ".cache/addons/logs/runtime.json", + "cases": [ + "registers only ready generation bound service headless and UI capabilities", + "rejects forged registration nonloopback and stale incarnation handshakes", + "bounds readiness and handles exit during start and stop", + "withdraws immediately drains boundedly and rejects stale process identities", + "exhausts restart budget without retaining dead registrations", + "does not inherit secrets and exits when the private parent channel closes", + "rejects foreign supervisor handles and numeric PID substitutions", + "resets restart budget only after sustained authenticated health", + "authenticates every request and separates trusted authority from model arguments", + "bounds output streams cancellation deadlines and stop admission", + "tells a 409 conflict of the add-on from an outage", + "rejects nonloopback endpoints and bounds oversized streams and concurrency", + "never reports failed between a launch starting and the restarted process being published", + "reserves a draining slot against concurrent starts", + "waits for a restart launch worker before acknowledging stop", + "exposes a new incarnation client after restart without reviving the old client", + "kills the exact paused child of a killed owner before launching its replacement", + "fails closed on ambiguous launch identities and preserves unrelated reused PIDs", + "retains a canceled restart slot until cleanup completes and rejects duplicate supervisors", + "refuses a missing established ledger and fences failed cleanup publication", + "withdraws and initiates every live slot before a cleanup timeout", + "reports stopped, running, failed, starting and running again across a crash and a restart", + "keeps reporting failed once the restart budget is exhausted", + "creates one private data directory per addon and passes its path in the startup object", + "gives no data directory without a data root and refuses an unsafe root or addon ID", + "gives an add-on that the host setting names a host endpoint of its own", + "gives an add-on that the host setting does not name no host endpoint", + "closes the host endpoint when the incarnation ends", + "an HTTP 400 answer", + "an HTTP 422 answer", + "keeps a bounded, printable reason and nothing else of the body", + "leaves 503, 500 and 404 as outages", + "types a withdrawn client, a full slot table, a bad envelope and a missing schema as not sent", + "keeps a not-sent error an ErrRuntime for callers that do not tell them apart", + "types a refused connection as not sent", + "never types a possibly-sent request as not sent", + "types the add-on's own 'forbidden' answer as a denial of the person, not as an outage", + "keeps a 403 about the envelope itself an outage: it is a host fault, not a permission", + "reaches an add-on that declares actions, as the actor of the envelope, under the act scope", + "never sends a call that is not an act call or has another scope", + "never sends an act call to an add-on that did not declare actions", + "stops an act call when the caller gives up", + "posts the request with the incarnation credential and decodes the answer", + "is not sent to an add-on that declared no connector", + "gives it time to release what it owns before it kills the process", + "kills an add-on that ignores the closing of its startup channel, within the grace period", + "goes to the log line by line with the add-on ID and a cap per line", + "records the chat with the envelope of a read and of an action while it runs", + "sends the add-on an envelope without a new field", + "requires exact typed capability equality on authenticated registration health", + "keeps all typed declarations on automatic crash restart with a fresh incarnation", + "replays typed and legacy receipts across process restarts", + "refuses missing wire tool discriminators before any read", + "serializes exact identity admission with withdrawal and never dispatches the replacement", + "cannot observe a restarted same-generation process through the captured client", + "rejects output handles before they can reach storage or output-open", + "settles callback denial as failure even if the addon returns a valid positive payload", + "cleans up after cancellation and rejects repeat correlation", + "keeps an ordinary concurrent read authorized while an observation is pending", + "error", + "malformed/artifact payload", + "success", + "timeout", + "cancels an admitted observation when the runtime withdraws" + ] + }, + { + "path": ".cache/addons/logs/runtime-protocol.json", + "cases": [ + "encodes a payload", + "reads the tool of a request", + "empty tool", + "no tool", + "not an object", + "no arguments", + "decodes arguments into a closed struct", + "unknown field", + "trailing value", + "wrong type", + "bad request", + "too large", + "not found", + "conflict", + "feature", + "scope", + "keeps any other failure as it is", + "ends the context when the owner closes the startup channel, so Run can return", + "serves /act for a runtime that implements ActRuntime, under the act scope only", + "has no /act for a runtime that does not act", + "serves /connector for a runtime that implements ConnectorRuntime, behind the incarnation credential", + "answers a refusal of the add-on as a bad request", + "has no /connector for a runtime that carries no connector", + "refuses without the protocol flag and builds no runtime", + "echoes model actions and typed declarations together separately and absent over authenticated health", + "sends the reason of a bad request on, so the owner can tell the person what to fix", + "keeps the plain answer for a bad request without a reason", + "closes the runtime with a bounded context before it exits, once", + "exits when the owner closes the startup channel", + "still exits when the runtime does not return in time", + "exits at once for a runtime with nothing to close" + ] + } + ] }, "gateway": { "kind": "ginkgo", @@ -594,35 +1537,32 @@ "auth", "./core/http/endpoints/addons", "-ginkgo.json-report=../../../../.cache/addons/logs/gateway-http.json" + ], + [ + "go", + "test", + "-count=1", + "-p", + "2", + "-tags", + "auth", + "./core/application", + "-ginkgo.focus=execution binding owner resolver|workspace operation declarations", + "-ginkgo.json-report=../../.cache/addons/logs/gateway-owner-binding.json" + ], + [ + "go", + "test", + "-count=1", + "-p", + "2", + "-tags", + "auth", + "./core/services/studio/session", + "-ginkgo.json-report=../../../../.cache/addons/logs/gateway-session-scope.json" ] ], "cases": [ - "serves only declared installed generation assets and rejects traversal", - "fails closed without current identity or resource policy and reports the original ID of an unknown mutation outcome", - "reports an unclassified account check error as unavailable on every path and a wrapped denial as forbidden", - "propagates interrupted progress cancellation through the receiving frontend", - "validates finite relay budgets and prevents disabling polling bounds", - "builds a carrier only from complete pinned identities", - "types an unknown mutation outcome as unavailable without losing its operation", - "rejects untrusted peers wrong audience body expiry replay and revoked credentials", - "alternates pinned frontends and rejects untrusted certificates without local fallback", - "reports the original operation after lost mutation replies without retrying", - "rejects a stream longer than its receiving frontend budget", - "rejects a CA signed peer without a pin on the owner side", - "refuses an owner whose CA signed certificate is not the pinned owner", - "returns the artifact status through the owner from every frontend with one denial for the rest", - "types the owner's refusal and outage of a lookup the same from every frontend", - "writes an attachment header that cannot be broken out of", - "rotates the enforced audience on restart and rejects captured requests", - "never expires the response deadline after the relay returned", - "returns the owner's forbidden status for an invalid kind and for missing policy", - "still reports unavailable when the owner is unable to serve and not forbidden", - "reaches the same decision through both frontends and delivers bytes when allowed", - "keeps aborting when revocation happens after bytes were written", - "bounds progress, asset and file relays by their own limit", - "alternates real owner journal status and terminates coarse progress without submitting again", - "reports an account store outage as unavailable and a refused account as forbidden", - "rejects unknown lifecycle argument fields before touching the owner journal", "reports a runtime that cannot serve as unavailable through both frontends", "reports an output that ends before its first byte as unavailable", "fails closed as unavailable for an unclassified owner error", @@ -633,24 +1573,111 @@ "keeps a mutation unknown on the owner's own frontend and leaves reads and denials alone", "lets unavailability win when an error wraps both a denial and an unavailable cause", "does not leak attachment headers into an error when the first read fails", + "fails closed without current identity or resource policy and reports the original ID of an unknown mutation outcome", + "carries the chat binding signed and rejects a changed one", + "signs a request without a binding as before", + "gives the owner the chat the frontend bound the call to", + "says nothing was sent when the add-on is not running, instead of an unknown outcome", + "runs a declared action tool for the calling user, under the act scope, admitted as an add-on call", + "is not reachable through the typed dispatch of the direct API", + "refuses what is not a declared action tool", + "refuses an add-on that did not declare actions", + "is unavailable when the runtime does not serve action tools, and never runs for an unvalidated user", + "reports an unknown outcome, not a refusal, when the runtime fails after the call began", + "returns the artifact status through the owner from every frontend with one denial for the rest", + "types the owner's refusal and outage of a lookup the same from every frontend", + "writes an attachment header that cannot be broken out of", + "rejects unknown lifecycle argument fields before touching the owner journal", + "is not permitted when the add-on denies the person, on the owner and on a forwarding frontend", + "is an unreachable resource when the policy denies it, on the owner and on a forwarding frontend", + "types the denial of a read like the denial of a write, on the owner and on a forwarding frontend", + "crosses the wire of a resource call typed, so a forwarding frontend answers like the owner", + "keeps any other failure to resolve a resource a plain denial, so nothing more is told", + "reports an account store outage as unavailable and a refused account as forbidden", + "propagates interrupted progress cancellation through the receiving frontend", + "round trips installed typed home metadata over mTLS with owner actor visibility and immutable asset URLs", + "returns the owner's forbidden status for an invalid kind and for missing policy", + "still reports unavailable when the owner is unable to serve and not forbidden", + "reaches the same decision through both frontends and delivers bytes when allowed", + "keeps aborting when revocation happens after bytes were written", + "validates finite relay budgets and prevents disabling polling bounds", "treats every action except the known reads as a mutation", - "keeps resolved resources under admission for JSON and attachment bytes", - "closes interrupted output and releases admission on disconnect", - "rechecks revoked credentials during attachment copying", - "adopts read outputs as private artifacts inside admission and replaces a forged resource identity", + "reads typed status and preserves named tool and host operation IDs through a supervised process", + "rejects a captured supervised client after same generation replacement before carrier dispatch", + "rejects a late supervised carrier observation after replacement before owner consumption", + "serves only declared installed generation assets and rejects traversal", + "builds a carrier only from complete pinned identities", + "types an unknown mutation outcome as unavailable without losing its operation", + "passes through owner-resolved bindings with distinct provider and status names", + "rejects a CA signed peer without a pin on the owner side", + "refuses an owner whose CA signed certificate is not the pinned owner", + "crosses the owner's wire as the same typed error, whatever the frontend", + "does not trust a code the host does not know, and cleans the detail the add-on wrote", + "is a stale view for a caller that only knows ErrStale, and a denial otherwise", + "is a mutation on the carrier: a lost answer is an unknown outcome, never a plain outage", + "never expires the response deadline after the relay returned", + "runs on the owner for the actor the signature binds, and the typed direct dispatch still refuses it", + "keeps the answer of a call that outlasts the write timeout of the owner server", + "is an unknown outcome when the answer is lost", "refuses at ingest a file result larger than the relay file bound so nothing unservable is staged", - "denies private relays without host resolution and admission", - "serves declared assets of the current enabled generation by package digest only", + "preserves bounded owner admission refusals over mTLS without frontend grants", + "rejects untrusted peers wrong audience body expiry replay and revoked credentials", + "writes the set with private modes and no CA key", + "loads both roles into carriers and serves a frontend call from the owner", + "gives the owner no signing key and the frontend its own", + "accepts two frontends made from the same set", + "refuses a frontend from another generation", + "reads only the files of the role", + "refuses a key file with group or other access", + "refuses an unknown role", + "refuses to overwrite a file and leaves it unchanged", + "needs an owner host", "allows only an exact declared tool name and fails closed otherwise", "never contacts the add-on for an undeclared tool and still serves a declared one", "denies a repeated key at any depth however it is spelled", "denies arguments that are not exactly one object", "separates a denial from an unavailable declaration source", "answers 503 and never contacts the add-on when the source is unavailable or the arguments repeat a key", + "runs the backend-releases check for a pinned frontend without an actor", + "refuses a system call from a peer that is not pinned", + "never lets a system call change anything or ride the user-facing route", + "denies private relays without host resolution and admission", + "resolves only the authenticated claim over actual mTLS", + "fails closed on unsupported owners and mismatched binding replies", + "serves declared assets of the current enabled generation by package digest only", + "bounds progress, asset and file relays by their own limit", + "reads the exact uiOnly status tool locally and over authenticated mTLS", + "refuses forged identities tools current selections and incarnations without dispatch", + "rejects stale carrier replies and frontend authority fields without dispatch", + "refuses forged observations through the real carrier and ignores ordinary dispatch attempts", + "does not relay a captured old binding to a same-generation replacement", + "settles parent cancellation and owner permission loss as unknown", + "does not grant owner selection authority through wire capability support", + "rejects duplicate noncanonical and nested authority keys in observation DTOs", + "withdraws a positive late reply when exact-client admission becomes stale", + "adopts read outputs as private artifacts inside admission and replaces a forged resource identity", + "rotates the enforced audience on restart and rejects captured requests", + "keeps resolved resources under admission for JSON and attachment bytes", + "closes interrupted output and releases admission on disconnect", + "rechecks revoked credentials during attachment copying", + "alternates real owner journal status and terminates coarse progress without submitting again", + "keeps its address stable across starts", + "does not share state between different roots", + "fails clearly when the port is in use", + "rejects an invalid port file", + "rejects a port file with a loose mode", + "rejects a port file that links outside the root", "is the typed invalid request on the owner for a read, never an outage", "is the typed invalid request on the owner for a proposal call", "is the typed invalid request on the owner for a json relay", "is the same typed answer from a forwarding frontend", + "runs a resource-less read tool for the calling user only", + "denies a tool that declares a resource when none is given, and never reaches the add-on", + "denies a resource-less read when the admission cannot admit an add-on, or the tool is undeclared", + "alternates pinned frontends and rejects untrusted certificates without local fallback", + "reports the original operation after lost mutation replies without retrying", + "rejects a stream longer than its receiving frontend budget", + "reports an unclassified account check error as unavailable on every path and a wrapped denial as forbidden", "stores a proposal from an API key and lets only the proposer's browser session confirm it", "cancels a proposal from the same chain and never lets typed dispatch or relay confirm", "denies sharing and deletion proposals and an unauthenticated caller", @@ -720,47 +1747,7 @@ "reports a lost upgrade as an unknown outcome with its original operation, and a lost status read or check as plainly unavailable", "keeps the update actions out of the tools and names every field of the status in the Swagger spec", "answers an add-on's invalid-request refusal as a typed 400 from the owner and from a forwarding frontend", - "crosses the owner's wire as the same typed error, whatever the frontend", - "does not trust a code the host does not know, and cleans the detail the add-on wrote", - "is a stale view for a caller that only knows ErrStale, and a denial otherwise", - "is a mutation on the carrier: a lost answer is an unknown outcome, never a plain outage", - "runs on the owner for the actor the signature binds, and the typed direct dispatch still refuses it", - "keeps the answer of a call that outlasts the write timeout of the owner server", - "is an unknown outcome when the answer is lost", - "runs the backend-releases check for a pinned frontend without an actor", - "refuses a system call from a peer that is not pinned", - "never lets a system call change anything or ride the user-facing route", - "writes the set with private modes and no CA key", - "loads both roles into carriers and serves a frontend call from the owner", - "gives the owner no signing key and the frontend its own", - "accepts two frontends made from the same set", - "refuses a frontend from another generation", - "reads only the files of the role", - "refuses a key file with group or other access", - "refuses an unknown role", - "refuses to overwrite a file and leaves it unchanged", - "needs an owner host", - "says nothing was sent when the add-on is not running, instead of an unknown outcome", - "runs a declared action tool for the calling user, under the act scope, admitted as an add-on call", - "is not reachable through the typed dispatch of the direct API", - "refuses what is not a declared action tool", - "refuses an add-on that did not declare actions", - "is unavailable when the runtime does not serve action tools, and never runs for an unvalidated user", - "reports an unknown outcome, not a refusal, when the runtime fails after the call began", - "runs a resource-less read tool for the calling user only", - "denies a tool that declares a resource when none is given, and never reaches the add-on", - "denies a resource-less read when the admission cannot admit an add-on, or the tool is undeclared", - "keeps its address stable across starts", - "does not share state between different roots", - "fails clearly when the port is in use", - "rejects an invalid port file", - "rejects a port file with a loose mode", - "rejects a port file that links outside the root", - "is not permitted when the add-on denies the person, on the owner and on a forwarding frontend", - "is an unreachable resource when the policy denies it, on the owner and on a forwarding frontend", - "types the denial of a read like the denial of a write, on the owner and on a forwarding frontend", - "crosses the wire of a resource call typed, so a forwarding frontend answers like the owner", - "keeps any other failure to resolve a resource a plain denial, so nothing more is told", + "returns the same text through a forwarding frontend", "Sec-Fetch-Site cross-site is refused even with a Referer on the same host", "Sec-Fetch-Site same-origin passes", "Sec-Fetch-Site same-site is refused", @@ -803,15 +1790,165 @@ "returns the SDK refusal text, the unverified-package text and the missing-release text to the administrator", "logs the cause of a failed install and types it for the client", "logs the reason behind a generic denial and leaves the response unchanged", - "returns the same text through a forwarding frontend", "names every typed cause of a failed install in the 403 of the dispatch route", - "carries the chat binding signed and rejects a changed one", - "signs a request without a binding as before", - "gives the owner the chat the frontend bound the call to", + "keeps one scope through prompt/status/egress and isolates priming and subsequent turns", + "closes pending observations at the real turn boundary (stop=false)", + "closes pending observations at the real turn boundary (stop=true)", + "gives a person's own call the whole home and no chat", + "refuses a person's access for a fenced actor or an actor of a chat", + "builds a view only of folders inside the access, at no more than their mode", + "a sibling", + "the home", + "a name that starts like the folder", + "a parent spelled inside", + "another home", + "a relative path", + "refuses an empty request and a missing store", + "names the work folder in the prompt and makes it only when it is used", + "names the work folder in the prompt and makes it only when it is used", + "gives two chats with the same title two folders before either is made", + "gives two chats with the same title two folders before either is made", + "renames a folder name that has no folder yet", + "renames a folder name that has no folder yet", + "names no work folder when the product does not ask", + "marks the working files of the chat's file list", + "puts the kind of each file on the card of a call", + "closes an idle chat and opens it again", + "does not close a chat in use by the janitor", + "gives the same type for the common text files on every host", + "file tools time out", + "the output cap stops the process", + "backend missing", + "backend missing, first failure", + "cooling down", + "cooling down for minutes", + "load failed", + "model not found", + "provider error", + "upstream key", + "upstream path", + "upstream host", + "timeout", + "cancelled", + "refused", + "quota", + "context", + "unknown", + "does not tell the user to check the backend of a provider that failed", + "does not read a deadline as a context that is too small", + "ignores a model name that is not plain", + "removes addresses, paths, secrets and stacks", + "drops the internal marks", + "cuts a long text", + "keeps a well formed app", + "refuses a bad reference", + "drops an oversize input or result but keeps the reference", + "drops invalid JSON", + "CapText cuts on a rune boundary", + "Summarize picks the first useful argument", + "tells uploads, model and tool writes apart", + "follows a rename of the chat folder", + "are not found for another user", + "give an empty prompt without files and a capped one with many", + "gets the actor and the input source", + "offers no sandbox tools without a sandbox", + "keeps the turn events when full", + "drops the oldest text", + "does not drop answer text in a reasoning flood", + "opens a chat and finishes a turn", + "gives the model the user's text and the guest paths, as before", + "gives the model only the list when there is no text", + "puts the text read from the files between the words and the list", + "leaves a message without files alone", + "splits what ComposeMessage wrote back into the words, the note and the files", + "splits a message that has only files", + "splits a voice note without words", + "splits waiting messages joined into one, each with its files", + "keeps text that only looks like the list", + "shows the user's words of an old message that holds the list", + "leaves the other lines alone", + "gives the model the list of a message stored with its files apart", + "tells the user when a turn has no harness after a restart failure", + "refuses to open a chat as the workspace administrator", + "keeps the order of the conversation in the transcript summary", + "maps every home spelling to the primary mount", + "maps /workspace/chat to the chat folder", + "keeps the home spelling of the chat folder that attachment lists use", + "refuses parents, NUL and control characters wherever they point", + "takes encoded and look-alike separators as literal names", + "refuses a /workspace path that is no mount, case-sensitively", + "maps the skill layers read only", + "never resolves outside its mounts", + "is empty for a view without mounts", + "names a file of the chat folder where the commands see it", + "keeps the home spelling for a file outside the chat folder and in an unscoped chat", + "keeps the stored spelling, which SplitMessage reads back", + "needs a chat ID and keeps the owner without the fence", + "takes a new fence at the next open even when the view is the same", + "makes the folder of a chat that is not open, once, as the user", + "builds a scoped access with the view builder of every chat", + "gives a command runner the turn's view and the skill layers the actor can read", + "gives the same access to another chat of the user", + "gives a refused model request a reason the user can act on", + "context too small with numbers", + "context too small without numbers", + "the model calls a tool that is not offered", + "model keeps failing", + "keeps the fence on its actor and drops it for the owner", + "refuses an actor without a valid principal", + "reaches the client", + "names its files by the place and leaves out files outside its folders", + "mounts the folder at home and the chat folder at chat", + "keeps today's view for an unscoped chat", + "refuses a primary folder outside the fence as a missing home", + "mounts shared folders read only for a scoped view", + "renames a shared folder called chat", + "tells a scoped chat only its folders and no page hint", + "names the whole home for a scoped chat whose folder is the home", + "keeps today's prompt for an unscoped chat", + "validates current selection and authenticated actor", + "rejects aliases, duplicate fields and null claims", + "accepts canonical bindings and rejects aliases and duplicates", "projects private policy safely", "forwards authenticated owner decisions", "validates strict request payloads", "checks CAS and administrator authority", + "uses owner CAS pins for execution and typed status and fails closed on policy outage", + "resolves local-owner SQLite using current chat and agent reads", + "resolves named SQLite chats and rechecks current accounts and credentials", + "selects the oldest real provider then pins a second distinct manifest and runtime", + "binds the actual renamed running provider and rejects forged bindings and foreign actors", + "observes the actual renamed provider through the Studio typed adapter and withdraws stale facts", + "validates current pins and chat switches without historical ABA guarantees", + "denies foreign chat access and invalid named credentials", + "uses current agent grants and denies unattended actions", + "rejects stale runtime incarnations without owner callback nonces", + "uses the ordinary chat store interface", + "rejects noncanonical and mismatched selected tool identities", + "wrong object", + "additional properties true", + "additional properties absent", + "required", + "enum", + "unknown schema keyword", + "malformed required", + "resource", + "admin", + "status uiOnly", + "status kind", + "action uiOnly", + "action kind", + "shadow", + "nil", + "empty", + "materialize", + "read", + "prefix only", + "future suffix", + "mixed grants", + "unrelated operations", + "bare workspace", + "similar prefix", "accepts before a slow backup, detaches the request and polls through either frontend" ], "report": ".cache/addons/logs/gateway.json", @@ -819,70 +1956,26 @@ { "path": ".cache/addons/logs/gateway.json", "cases": [ - "serves only declared installed generation assets and rejects traversal", - "fails closed without current identity or resource policy and reports the original ID of an unknown mutation outcome", - "reports an unclassified account check error as unavailable on every path and a wrapped denial as forbidden", - "propagates interrupted progress cancellation through the receiving frontend", - "validates finite relay budgets and prevents disabling polling bounds", - "builds a carrier only from complete pinned identities", - "types an unknown mutation outcome as unavailable without losing its operation", - "rejects untrusted peers wrong audience body expiry replay and revoked credentials", - "alternates pinned frontends and rejects untrusted certificates without local fallback", - "reports the original operation after lost mutation replies without retrying", - "rejects a stream longer than its receiving frontend budget", "rejects a CA signed peer without a pin on the owner side", "refuses an owner whose CA signed certificate is not the pinned owner", - "returns the artifact status through the owner from every frontend with one denial for the rest", - "types the owner's refusal and outage of a lookup the same from every frontend", - "writes an attachment header that cannot be broken out of", - "rotates the enforced audience on restart and rejects captured requests", - "never expires the response deadline after the relay returned", + "rejects unknown lifecycle argument fields before touching the owner journal", + "adopts read outputs as private artifacts inside admission and replaces a forged resource identity", + "reports an account store outage as unavailable and a refused account as forbidden", + "alternates real owner journal status and terminates coarse progress without submitting again", "returns the owner's forbidden status for an invalid kind and for missing policy", "still reports unavailable when the owner is unable to serve and not forbidden", "reaches the same decision through both frontends and delivers bytes when allowed", "keeps aborting when revocation happens after bytes were written", - "bounds progress, asset and file relays by their own limit", - "alternates real owner journal status and terminates coarse progress without submitting again", - "reports an account store outage as unavailable and a refused account as forbidden", - "rejects unknown lifecycle argument fields before touching the owner journal", - "reports a runtime that cannot serve as unavailable through both frontends", - "reports an output that ends before its first byte as unavailable", - "fails closed as unavailable for an unclassified owner error", - "keeps authorization and policy denials forbidden", - "keeps a mutation unknown when the runtime fails after the owner applied it", - "keeps a mutation unknown when the owner reports recovery required for its operation", - "reports a mutation as unknown when the owner cannot serve it at all", - "keeps a mutation unknown on the owner's own frontend and leaves reads and denials alone", - "lets unavailability win when an error wraps both a denial and an unavailable cause", - "does not leak attachment headers into an error when the first read fails", - "treats every action except the known reads as a mutation", - "keeps resolved resources under admission for JSON and attachment bytes", - "closes interrupted output and releases admission on disconnect", - "rechecks revoked credentials during attachment copying", - "adopts read outputs as private artifacts inside admission and replaces a forged resource identity", + "fails closed without current identity or resource policy and reports the original ID of an unknown mutation outcome", "refuses at ingest a file result larger than the relay file bound so nothing unservable is staged", - "denies private relays without host resolution and admission", - "serves declared assets of the current enabled generation by package digest only", - "allows only an exact declared tool name and fails closed otherwise", - "never contacts the add-on for an undeclared tool and still serves a declared one", - "denies a repeated key at any depth however it is spelled", - "denies arguments that are not exactly one object", - "separates a denial from an unavailable declaration source", - "answers 503 and never contacts the add-on when the source is unavailable or the arguments repeat a key", - "is the typed invalid request on the owner for a read, never an outage", - "is the typed invalid request on the owner for a proposal call", - "is the typed invalid request on the owner for a json relay", - "is the same typed answer from a forwarding frontend", - "crosses the owner's wire as the same typed error, whatever the frontend", - "does not trust a code the host does not know, and cleans the detail the add-on wrote", - "is a stale view for a caller that only knows ErrStale, and a denial otherwise", - "is a mutation on the carrier: a lost answer is an unknown outcome, never a plain outage", - "runs on the owner for the actor the signature binds, and the typed direct dispatch still refuses it", - "keeps the answer of a call that outlasts the write timeout of the owner server", - "is an unknown outcome when the answer is lost", - "runs the backend-releases check for a pinned frontend without an actor", - "refuses a system call from a peer that is not pinned", - "never lets a system call change anything or ride the user-facing route", + "projects private policy safely", + "forwards authenticated owner decisions", + "validates strict request payloads", + "checks CAS and administrator authority", + "validates current selection and authenticated actor", + "round trips installed typed home metadata over mTLS with owner actor visibility and immutable asset URLs", + "rotates the enforced audience on restart and rejects captured requests", + "passes through owner-resolved bindings with distinct provider and status names", "writes the set with private modes and no CA key", "loads both roles into carriers and serves a frontend call from the owner", "gives the owner no signing key and the frontend its own", @@ -893,6 +1986,104 @@ "refuses an unknown role", "refuses to overwrite a file and leaves it unchanged", "needs an owner host", + "builds a carrier only from complete pinned identities", + "types an unknown mutation outcome as unavailable without losing its operation", + "rejects untrusted peers wrong audience body expiry replay and revoked credentials", + "is not permitted when the add-on denies the person, on the owner and on a forwarding frontend", + "is an unreachable resource when the policy denies it, on the owner and on a forwarding frontend", + "types the denial of a read like the denial of a write, on the owner and on a forwarding frontend", + "crosses the wire of a resource call typed, so a forwarding frontend answers like the owner", + "keeps any other failure to resolve a resource a plain denial, so nothing more is told", + "preserves bounded owner admission refusals over mTLS without frontend grants", + "accepts canonical bindings and rejects aliases and duplicates", + "keeps resolved resources under admission for JSON and attachment bytes", + "closes interrupted output and releases admission on disconnect", + "rechecks revoked credentials during attachment copying", + "denies private relays without host resolution and admission", + "carries the chat binding signed and rejects a changed one", + "signs a request without a binding as before", + "gives the owner the chat the frontend bound the call to", + "bounds progress, asset and file relays by their own limit", + "returns the artifact status through the owner from every frontend with one denial for the rest", + "types the owner's refusal and outage of a lookup the same from every frontend", + "writes an attachment header that cannot be broken out of", + "rejects aliases, duplicate fields and null claims", + "runs the backend-releases check for a pinned frontend without an actor", + "refuses a system call from a peer that is not pinned", + "never lets a system call change anything or ride the user-facing route", + "wrong object", + "additional properties true", + "additional properties absent", + "required", + "enum", + "unknown schema keyword", + "malformed required", + "resource", + "admin", + "status uiOnly", + "status kind", + "action uiOnly", + "action kind", + "shadow", + "reads the exact uiOnly status tool locally and over authenticated mTLS", + "refuses forged identities tools current selections and incarnations without dispatch", + "rejects stale carrier replies and frontend authority fields without dispatch", + "refuses forged observations through the real carrier and ignores ordinary dispatch attempts", + "does not relay a captured old binding to a same-generation replacement", + "settles parent cancellation and owner permission loss as unknown", + "does not grant owner selection authority through wire capability support", + "rejects duplicate noncanonical and nested authority keys in observation DTOs", + "withdraws a positive late reply when exact-client admission becomes stale", + "serves only declared installed generation assets and rejects traversal", + "accepts before a slow backup, detaches the request and polls through either frontend", + "reads typed status and preserves named tool and host operation IDs through a supervised process", + "rejects a captured supervised client after same generation replacement before carrier dispatch", + "rejects a late supervised carrier observation after replacement before owner consumption", + "resolves only the authenticated claim over actual mTLS", + "fails closed on unsupported owners and mismatched binding replies", + "runs a resource-less read tool for the calling user only", + "denies a tool that declares a resource when none is given, and never reaches the add-on", + "denies a resource-less read when the admission cannot admit an add-on, or the tool is undeclared", + "never expires the response deadline after the relay returned", + "propagates interrupted progress cancellation through the receiving frontend", + "reports a runtime that cannot serve as unavailable through both frontends", + "reports an output that ends before its first byte as unavailable", + "fails closed as unavailable for an unclassified owner error", + "keeps authorization and policy denials forbidden", + "keeps a mutation unknown when the runtime fails after the owner applied it", + "keeps a mutation unknown when the owner reports recovery required for its operation", + "reports a mutation as unknown when the owner cannot serve it at all", + "keeps a mutation unknown on the owner's own frontend and leaves reads and denials alone", + "lets unavailability win when an error wraps both a denial and an unavailable cause", + "does not leak attachment headers into an error when the first read fails", + "keeps its address stable across starts", + "does not share state between different roots", + "fails clearly when the port is in use", + "rejects an invalid port file", + "rejects a port file with a loose mode", + "rejects a port file that links outside the root", + "allows only an exact declared tool name and fails closed otherwise", + "never contacts the add-on for an undeclared tool and still serves a declared one", + "denies a repeated key at any depth however it is spelled", + "denies arguments that are not exactly one object", + "separates a denial from an unavailable declaration source", + "answers 503 and never contacts the add-on when the source is unavailable or the arguments repeat a key", + "crosses the owner's wire as the same typed error, whatever the frontend", + "does not trust a code the host does not know, and cleans the detail the add-on wrote", + "is a stale view for a caller that only knows ErrStale, and a denial otherwise", + "is a mutation on the carrier: a lost answer is an unknown outcome, never a plain outage", + "alternates pinned frontends and rejects untrusted certificates without local fallback", + "reports the original operation after lost mutation replies without retrying", + "rejects a stream longer than its receiving frontend budget", + "treats every action except the known reads as a mutation", + "is the typed invalid request on the owner for a read, never an outage", + "is the typed invalid request on the owner for a proposal call", + "is the typed invalid request on the owner for a json relay", + "is the same typed answer from a forwarding frontend", + "runs on the owner for the actor the signature binds, and the typed direct dispatch still refuses it", + "keeps the answer of a call that outlasts the write timeout of the owner server", + "is an unknown outcome when the answer is lost", + "reports an unclassified account check error as unavailable on every path and a wrapped denial as forbidden", "says nothing was sent when the add-on is not running, instead of an unknown outcome", "runs a declared action tool for the calling user, under the act scope, admitted as an add-on call", "is not reachable through the typed dispatch of the direct API", @@ -900,28 +2091,8 @@ "refuses an add-on that did not declare actions", "is unavailable when the runtime does not serve action tools, and never runs for an unvalidated user", "reports an unknown outcome, not a refusal, when the runtime fails after the call began", - "runs a resource-less read tool for the calling user only", - "denies a tool that declares a resource when none is given, and never reaches the add-on", - "denies a resource-less read when the admission cannot admit an add-on, or the tool is undeclared", - "keeps its address stable across starts", - "does not share state between different roots", - "fails clearly when the port is in use", - "rejects an invalid port file", - "rejects a port file with a loose mode", - "rejects a port file that links outside the root", - "is not permitted when the add-on denies the person, on the owner and on a forwarding frontend", - "is an unreachable resource when the policy denies it, on the owner and on a forwarding frontend", - "types the denial of a read like the denial of a write, on the owner and on a forwarding frontend", - "crosses the wire of a resource call typed, so a forwarding frontend answers like the owner", - "keeps any other failure to resolve a resource a plain denial, so nothing more is told", - "carries the chat binding signed and rejects a changed one", - "signs a request without a binding as before", - "gives the owner the chat the frontend bound the call to", - "projects private policy safely", - "forwards authenticated owner decisions", - "validates strict request payloads", - "checks CAS and administrator authority", - "accepts before a slow backup, detaches the request and polls through either frontend" + "validates finite relay budgets and prevents disabling polling bounds", + "serves declared assets of the current enabled generation by package digest only" ] }, { @@ -1041,6 +2212,154 @@ "logs the reason behind a generic denial and leaves the response unchanged", "names every typed cause of a failed install in the 403 of the dispatch route" ] + }, + { + "path": ".cache/addons/logs/gateway-owner-binding.json", + "cases": [ + "uses owner CAS pins for execution and typed status and fails closed on policy outage", + "resolves local-owner SQLite using current chat and agent reads", + "resolves named SQLite chats and rechecks current accounts and credentials", + "selects the oldest real provider then pins a second distinct manifest and runtime", + "binds the actual renamed running provider and rejects forged bindings and foreign actors", + "observes the actual renamed provider through the Studio typed adapter and withdraws stale facts", + "validates current pins and chat switches without historical ABA guarantees", + "denies foreign chat access and invalid named credentials", + "uses current agent grants and denies unattended actions", + "rejects stale runtime incarnations without owner callback nonces", + "uses the ordinary chat store interface", + "rejects noncanonical and mismatched selected tool identities", + "nil", + "empty", + "materialize", + "read", + "prefix only", + "future suffix", + "mixed grants", + "unrelated operations", + "bare workspace", + "similar prefix" + ], + "focus": "execution binding owner resolver|workspace operation declarations" + }, + { + "path": ".cache/addons/logs/gateway-session-scope.json", + "cases": [ + "keeps one scope through prompt/status/egress and isolates priming and subsequent turns", + "closes pending observations at the real turn boundary (stop=false)", + "closes pending observations at the real turn boundary (stop=true)", + "gives a person's own call the whole home and no chat", + "refuses a person's access for a fenced actor or an actor of a chat", + "builds a view only of folders inside the access, at no more than their mode", + "a sibling", + "the home", + "a name that starts like the folder", + "a parent spelled inside", + "another home", + "a relative path", + "refuses an empty request and a missing store", + "names the work folder in the prompt and makes it only when it is used", + "names the work folder in the prompt and makes it only when it is used", + "gives two chats with the same title two folders before either is made", + "gives two chats with the same title two folders before either is made", + "renames a folder name that has no folder yet", + "renames a folder name that has no folder yet", + "names no work folder when the product does not ask", + "marks the working files of the chat's file list", + "puts the kind of each file on the card of a call", + "closes an idle chat and opens it again", + "does not close a chat in use by the janitor", + "gives the same type for the common text files on every host", + "file tools time out", + "the output cap stops the process", + "backend missing", + "backend missing, first failure", + "cooling down", + "cooling down for minutes", + "load failed", + "model not found", + "provider error", + "upstream key", + "upstream path", + "upstream host", + "timeout", + "cancelled", + "refused", + "quota", + "context", + "unknown", + "does not tell the user to check the backend of a provider that failed", + "does not read a deadline as a context that is too small", + "ignores a model name that is not plain", + "removes addresses, paths, secrets and stacks", + "drops the internal marks", + "cuts a long text", + "keeps a well formed app", + "refuses a bad reference", + "drops an oversize input or result but keeps the reference", + "drops invalid JSON", + "CapText cuts on a rune boundary", + "Summarize picks the first useful argument", + "tells uploads, model and tool writes apart", + "follows a rename of the chat folder", + "are not found for another user", + "give an empty prompt without files and a capped one with many", + "gets the actor and the input source", + "offers no sandbox tools without a sandbox", + "keeps the turn events when full", + "drops the oldest text", + "does not drop answer text in a reasoning flood", + "opens a chat and finishes a turn", + "gives the model the user's text and the guest paths, as before", + "gives the model only the list when there is no text", + "puts the text read from the files between the words and the list", + "leaves a message without files alone", + "splits what ComposeMessage wrote back into the words, the note and the files", + "splits a message that has only files", + "splits a voice note without words", + "splits waiting messages joined into one, each with its files", + "keeps text that only looks like the list", + "shows the user's words of an old message that holds the list", + "leaves the other lines alone", + "gives the model the list of a message stored with its files apart", + "tells the user when a turn has no harness after a restart failure", + "refuses to open a chat as the workspace administrator", + "keeps the order of the conversation in the transcript summary", + "maps every home spelling to the primary mount", + "maps /workspace/chat to the chat folder", + "keeps the home spelling of the chat folder that attachment lists use", + "refuses parents, NUL and control characters wherever they point", + "takes encoded and look-alike separators as literal names", + "refuses a /workspace path that is no mount, case-sensitively", + "maps the skill layers read only", + "never resolves outside its mounts", + "is empty for a view without mounts", + "names a file of the chat folder where the commands see it", + "keeps the home spelling for a file outside the chat folder and in an unscoped chat", + "keeps the stored spelling, which SplitMessage reads back", + "needs a chat ID and keeps the owner without the fence", + "takes a new fence at the next open even when the view is the same", + "makes the folder of a chat that is not open, once, as the user", + "builds a scoped access with the view builder of every chat", + "gives a command runner the turn's view and the skill layers the actor can read", + "gives the same access to another chat of the user", + "gives a refused model request a reason the user can act on", + "context too small with numbers", + "context too small without numbers", + "the model calls a tool that is not offered", + "model keeps failing", + "keeps the fence on its actor and drops it for the owner", + "refuses an actor without a valid principal", + "reaches the client", + "names its files by the place and leaves out files outside its folders", + "mounts the folder at home and the chat folder at chat", + "keeps today's view for an unscoped chat", + "refuses a primary folder outside the fence as a missing home", + "mounts shared folders read only for a scoped view", + "renames a shared folder called chat", + "tells a scoped chat only its folders and no page hint", + "names the whole home for a scoped chat whose folder is the home", + "keeps today's prompt for an unscoped chat" + ] } ] }, @@ -1345,6 +2664,29 @@ "migrates the ownership of a stopped store with a backup and an explicit mapping and refuses a populated downgrade", "refuses a proposal whose preview the add-on refused, storing nothing", "settles a confirmation the add-on refused as rejected, never as an unknown outcome", + "R34: returns an admission that was refused before the wire to pending, and a repeat applies", + "R35, R52: a hold that cancels the dispatch before the wire ends the proposal as cancelled, not unknown", + "R38: records a conflict of the add-on as rejected, not unknown", + "R39: still records an unknown outcome when the request may have been sent", + "reports a not-sent confirmation through the gateway as an outage that sent nothing, never as unknown", + "is a refusal that changed nothing, not an outage, and leaves the ID free", + "R4, S4: refuses an editor who claims the write action for a schema tool, and stores nothing", + "R4: refuses an editor who names the schema action for a schema tool", + "derives the action from the tool, so the owner needs the matching claim and gets the schema action", + "still lets an editor propose a data write for a write tool", + "R3: refuses a read tool, a create tool and a tool without a definition", + "runs the declaration check as well as the kind check", + "refuses a named tool when the source cannot report kinds", + "R3: a creation names a create tool, never a schema or data tool", + "retry-after-not-sent: R34 then R37", + "addon-conflict-is-rejected: R38", + "timeout-unknown-then-receipt-committed: R39 then R40", + "runs the work for a current account", + "refuses a stale account and does not run the work", + "refuses an identity that does not fit the mode", + "refuses a held add-on as unavailable, and serves it again after the release", + "refuses an empty add-on ID and a nil work function", + "is reached through the gateway provider", "stores a proposal from an API key and lets only the proposer's browser session confirm it", "cancels a proposal from the same chain and never lets typed dispatch or relay confirm", "denies sharing and deletion proposals and an unauthenticated caller", @@ -1414,29 +2756,7 @@ "reports a lost upgrade as an unknown outcome with its original operation, and a lost status read or check as plainly unavailable", "keeps the update actions out of the tools and names every field of the status in the Swagger spec", "answers an add-on's invalid-request refusal as a typed 400 from the owner and from a forwarding frontend", - "R34: returns an admission that was refused before the wire to pending, and a repeat applies", - "R35, R52: a hold that cancels the dispatch before the wire ends the proposal as cancelled, not unknown", - "R38: records a conflict of the add-on as rejected, not unknown", - "R39: still records an unknown outcome when the request may have been sent", - "reports a not-sent confirmation through the gateway as an outage that sent nothing, never as unknown", - "is a refusal that changed nothing, not an outage, and leaves the ID free", - "R4, S4: refuses an editor who claims the write action for a schema tool, and stores nothing", - "R4: refuses an editor who names the schema action for a schema tool", - "derives the action from the tool, so the owner needs the matching claim and gets the schema action", - "still lets an editor propose a data write for a write tool", - "R3: refuses a read tool, a create tool and a tool without a definition", - "runs the declaration check as well as the kind check", - "refuses a named tool when the source cannot report kinds", - "R3: a creation names a create tool, never a schema or data tool", - "retry-after-not-sent: R34 then R37", - "addon-conflict-is-rejected: R38", - "timeout-unknown-then-receipt-committed: R39 then R40", - "runs the work for a current account", - "refuses a stale account and does not run the work", - "refuses an identity that does not fit the mode", - "refuses a held add-on as unavailable, and serves it again after the release", - "refuses an empty add-on ID and a nil work function", - "is reached through the gateway provider", + "returns the same text through a forwarding frontend", "Sec-Fetch-Site cross-site is refused even with a Referer on the same host", "Sec-Fetch-Site same-origin passes", "Sec-Fetch-Site same-site is refused", @@ -1479,7 +2799,6 @@ "returns the SDK refusal text, the unverified-package text and the missing-release text to the administrator", "logs the cause of a failed install and types it for the client", "logs the reason behind a generic denial and leaves the response unchanged", - "returns the same text through a forwarding frontend", "names every typed cause of a failed install in the 403 of the dispatch route" ], "report": ".cache/addons/logs/proposals.json", @@ -1823,32 +3142,6 @@ "reports an unavailable declaration source as unavailable and never opens the output", "shortens the lifetime and keeps the bounds consistent, and zero keeps the defaults", "fails the artifact with a fixed reason that says it was refused, not that the add-on is down", - "serves only declared installed generation assets and rejects traversal", - "fails closed without current identity or resource policy and reports the original ID of an unknown mutation outcome", - "reports an unclassified account check error as unavailable on every path and a wrapped denial as forbidden", - "propagates interrupted progress cancellation through the receiving frontend", - "validates finite relay budgets and prevents disabling polling bounds", - "builds a carrier only from complete pinned identities", - "types an unknown mutation outcome as unavailable without losing its operation", - "rejects untrusted peers wrong audience body expiry replay and revoked credentials", - "alternates pinned frontends and rejects untrusted certificates without local fallback", - "reports the original operation after lost mutation replies without retrying", - "rejects a stream longer than its receiving frontend budget", - "rejects a CA signed peer without a pin on the owner side", - "refuses an owner whose CA signed certificate is not the pinned owner", - "returns the artifact status through the owner from every frontend with one denial for the rest", - "types the owner's refusal and outage of a lookup the same from every frontend", - "writes an attachment header that cannot be broken out of", - "rotates the enforced audience on restart and rejects captured requests", - "never expires the response deadline after the relay returned", - "returns the owner's forbidden status for an invalid kind and for missing policy", - "still reports unavailable when the owner is unable to serve and not forbidden", - "reaches the same decision through both frontends and delivers bytes when allowed", - "keeps aborting when revocation happens after bytes were written", - "bounds progress, asset and file relays by their own limit", - "alternates real owner journal status and terminates coarse progress without submitting again", - "reports an account store outage as unavailable and a refused account as forbidden", - "rejects unknown lifecycle argument fields before touching the owner journal", "reports a runtime that cannot serve as unavailable through both frontends", "reports an output that ends before its first byte as unavailable", "fails closed as unavailable for an unclassified owner error", @@ -1859,24 +3152,111 @@ "keeps a mutation unknown on the owner's own frontend and leaves reads and denials alone", "lets unavailability win when an error wraps both a denial and an unavailable cause", "does not leak attachment headers into an error when the first read fails", + "fails closed without current identity or resource policy and reports the original ID of an unknown mutation outcome", + "carries the chat binding signed and rejects a changed one", + "signs a request without a binding as before", + "gives the owner the chat the frontend bound the call to", + "says nothing was sent when the add-on is not running, instead of an unknown outcome", + "runs a declared action tool for the calling user, under the act scope, admitted as an add-on call", + "is not reachable through the typed dispatch of the direct API", + "refuses what is not a declared action tool", + "refuses an add-on that did not declare actions", + "is unavailable when the runtime does not serve action tools, and never runs for an unvalidated user", + "reports an unknown outcome, not a refusal, when the runtime fails after the call began", + "returns the artifact status through the owner from every frontend with one denial for the rest", + "types the owner's refusal and outage of a lookup the same from every frontend", + "writes an attachment header that cannot be broken out of", + "rejects unknown lifecycle argument fields before touching the owner journal", + "is not permitted when the add-on denies the person, on the owner and on a forwarding frontend", + "is an unreachable resource when the policy denies it, on the owner and on a forwarding frontend", + "types the denial of a read like the denial of a write, on the owner and on a forwarding frontend", + "crosses the wire of a resource call typed, so a forwarding frontend answers like the owner", + "keeps any other failure to resolve a resource a plain denial, so nothing more is told", + "reports an account store outage as unavailable and a refused account as forbidden", + "propagates interrupted progress cancellation through the receiving frontend", + "round trips installed typed home metadata over mTLS with owner actor visibility and immutable asset URLs", + "returns the owner's forbidden status for an invalid kind and for missing policy", + "still reports unavailable when the owner is unable to serve and not forbidden", + "reaches the same decision through both frontends and delivers bytes when allowed", + "keeps aborting when revocation happens after bytes were written", + "validates finite relay budgets and prevents disabling polling bounds", "treats every action except the known reads as a mutation", - "keeps resolved resources under admission for JSON and attachment bytes", - "closes interrupted output and releases admission on disconnect", - "rechecks revoked credentials during attachment copying", - "adopts read outputs as private artifacts inside admission and replaces a forged resource identity", + "reads typed status and preserves named tool and host operation IDs through a supervised process", + "rejects a captured supervised client after same generation replacement before carrier dispatch", + "rejects a late supervised carrier observation after replacement before owner consumption", + "serves only declared installed generation assets and rejects traversal", + "builds a carrier only from complete pinned identities", + "types an unknown mutation outcome as unavailable without losing its operation", + "passes through owner-resolved bindings with distinct provider and status names", + "rejects a CA signed peer without a pin on the owner side", + "refuses an owner whose CA signed certificate is not the pinned owner", + "crosses the owner's wire as the same typed error, whatever the frontend", + "does not trust a code the host does not know, and cleans the detail the add-on wrote", + "is a stale view for a caller that only knows ErrStale, and a denial otherwise", + "is a mutation on the carrier: a lost answer is an unknown outcome, never a plain outage", + "never expires the response deadline after the relay returned", + "runs on the owner for the actor the signature binds, and the typed direct dispatch still refuses it", + "keeps the answer of a call that outlasts the write timeout of the owner server", + "is an unknown outcome when the answer is lost", "refuses at ingest a file result larger than the relay file bound so nothing unservable is staged", - "denies private relays without host resolution and admission", - "serves declared assets of the current enabled generation by package digest only", + "preserves bounded owner admission refusals over mTLS without frontend grants", + "rejects untrusted peers wrong audience body expiry replay and revoked credentials", + "writes the set with private modes and no CA key", + "loads both roles into carriers and serves a frontend call from the owner", + "gives the owner no signing key and the frontend its own", + "accepts two frontends made from the same set", + "refuses a frontend from another generation", + "reads only the files of the role", + "refuses a key file with group or other access", + "refuses an unknown role", + "refuses to overwrite a file and leaves it unchanged", + "needs an owner host", "allows only an exact declared tool name and fails closed otherwise", "never contacts the add-on for an undeclared tool and still serves a declared one", "denies a repeated key at any depth however it is spelled", "denies arguments that are not exactly one object", "separates a denial from an unavailable declaration source", "answers 503 and never contacts the add-on when the source is unavailable or the arguments repeat a key", + "runs the backend-releases check for a pinned frontend without an actor", + "refuses a system call from a peer that is not pinned", + "never lets a system call change anything or ride the user-facing route", + "denies private relays without host resolution and admission", + "resolves only the authenticated claim over actual mTLS", + "fails closed on unsupported owners and mismatched binding replies", + "serves declared assets of the current enabled generation by package digest only", + "bounds progress, asset and file relays by their own limit", + "reads the exact uiOnly status tool locally and over authenticated mTLS", + "refuses forged identities tools current selections and incarnations without dispatch", + "rejects stale carrier replies and frontend authority fields without dispatch", + "refuses forged observations through the real carrier and ignores ordinary dispatch attempts", + "does not relay a captured old binding to a same-generation replacement", + "settles parent cancellation and owner permission loss as unknown", + "does not grant owner selection authority through wire capability support", + "rejects duplicate noncanonical and nested authority keys in observation DTOs", + "withdraws a positive late reply when exact-client admission becomes stale", + "adopts read outputs as private artifacts inside admission and replaces a forged resource identity", + "rotates the enforced audience on restart and rejects captured requests", + "keeps resolved resources under admission for JSON and attachment bytes", + "closes interrupted output and releases admission on disconnect", + "rechecks revoked credentials during attachment copying", + "alternates real owner journal status and terminates coarse progress without submitting again", + "keeps its address stable across starts", + "does not share state between different roots", + "fails clearly when the port is in use", + "rejects an invalid port file", + "rejects a port file with a loose mode", + "rejects a port file that links outside the root", "is the typed invalid request on the owner for a read, never an outage", "is the typed invalid request on the owner for a proposal call", "is the typed invalid request on the owner for a json relay", "is the same typed answer from a forwarding frontend", + "runs a resource-less read tool for the calling user only", + "denies a tool that declares a resource when none is given, and never reaches the add-on", + "denies a resource-less read when the admission cannot admit an add-on, or the tool is undeclared", + "alternates pinned frontends and rejects untrusted certificates without local fallback", + "reports the original operation after lost mutation replies without retrying", + "rejects a stream longer than its receiving frontend budget", + "reports an unclassified account check error as unavailable on every path and a wrapped denial as forbidden", "stores a proposal from an API key and lets only the proposer's browser session confirm it", "cancels a proposal from the same chain and never lets typed dispatch or relay confirm", "denies sharing and deletion proposals and an unauthenticated caller", @@ -1946,47 +3326,7 @@ "reports a lost upgrade as an unknown outcome with its original operation, and a lost status read or check as plainly unavailable", "keeps the update actions out of the tools and names every field of the status in the Swagger spec", "answers an add-on's invalid-request refusal as a typed 400 from the owner and from a forwarding frontend", - "crosses the owner's wire as the same typed error, whatever the frontend", - "does not trust a code the host does not know, and cleans the detail the add-on wrote", - "is a stale view for a caller that only knows ErrStale, and a denial otherwise", - "is a mutation on the carrier: a lost answer is an unknown outcome, never a plain outage", - "runs on the owner for the actor the signature binds, and the typed direct dispatch still refuses it", - "keeps the answer of a call that outlasts the write timeout of the owner server", - "is an unknown outcome when the answer is lost", - "runs the backend-releases check for a pinned frontend without an actor", - "refuses a system call from a peer that is not pinned", - "never lets a system call change anything or ride the user-facing route", - "writes the set with private modes and no CA key", - "loads both roles into carriers and serves a frontend call from the owner", - "gives the owner no signing key and the frontend its own", - "accepts two frontends made from the same set", - "refuses a frontend from another generation", - "reads only the files of the role", - "refuses a key file with group or other access", - "refuses an unknown role", - "refuses to overwrite a file and leaves it unchanged", - "needs an owner host", - "says nothing was sent when the add-on is not running, instead of an unknown outcome", - "runs a declared action tool for the calling user, under the act scope, admitted as an add-on call", - "is not reachable through the typed dispatch of the direct API", - "refuses what is not a declared action tool", - "refuses an add-on that did not declare actions", - "is unavailable when the runtime does not serve action tools, and never runs for an unvalidated user", - "reports an unknown outcome, not a refusal, when the runtime fails after the call began", - "runs a resource-less read tool for the calling user only", - "denies a tool that declares a resource when none is given, and never reaches the add-on", - "denies a resource-less read when the admission cannot admit an add-on, or the tool is undeclared", - "keeps its address stable across starts", - "does not share state between different roots", - "fails clearly when the port is in use", - "rejects an invalid port file", - "rejects a port file with a loose mode", - "rejects a port file that links outside the root", - "is not permitted when the add-on denies the person, on the owner and on a forwarding frontend", - "is an unreachable resource when the policy denies it, on the owner and on a forwarding frontend", - "types the denial of a read like the denial of a write, on the owner and on a forwarding frontend", - "crosses the wire of a resource call typed, so a forwarding frontend answers like the owner", - "keeps any other failure to resolve a resource a plain denial, so nothing more is told", + "returns the same text through a forwarding frontend", "Sec-Fetch-Site cross-site is refused even with a Referer on the same host", "Sec-Fetch-Site same-origin passes", "Sec-Fetch-Site same-site is refused", @@ -2029,15 +3369,28 @@ "returns the SDK refusal text, the unverified-package text and the missing-release text to the administrator", "logs the cause of a failed install and types it for the client", "logs the reason behind a generic denial and leaves the response unchanged", - "returns the same text through a forwarding frontend", "names every typed cause of a failed install in the 403 of the dispatch route", - "carries the chat binding signed and rejects a changed one", - "signs a request without a binding as before", - "gives the owner the chat the frontend bound the call to", "projects private policy safely", "forwards authenticated owner decisions", "validates strict request payloads", "checks CAS and administrator authority", + "validates current selection and authenticated actor", + "accepts canonical bindings and rejects aliases and duplicates", + "rejects aliases, duplicate fields and null claims", + "wrong object", + "additional properties true", + "additional properties absent", + "required", + "enum", + "unknown schema keyword", + "malformed required", + "resource", + "admin", + "status uiOnly", + "status kind", + "action uiOnly", + "action kind", + "shadow", "accepts before a slow backup, detaches the request and polls through either frontend" ], "report": ".cache/addons/logs/artifacts.json", @@ -2087,70 +3440,26 @@ { "path": ".cache/addons/logs/artifacts-gateway.json", "cases": [ - "serves only declared installed generation assets and rejects traversal", - "fails closed without current identity or resource policy and reports the original ID of an unknown mutation outcome", - "reports an unclassified account check error as unavailable on every path and a wrapped denial as forbidden", - "propagates interrupted progress cancellation through the receiving frontend", - "validates finite relay budgets and prevents disabling polling bounds", - "builds a carrier only from complete pinned identities", - "types an unknown mutation outcome as unavailable without losing its operation", - "rejects untrusted peers wrong audience body expiry replay and revoked credentials", - "alternates pinned frontends and rejects untrusted certificates without local fallback", - "reports the original operation after lost mutation replies without retrying", - "rejects a stream longer than its receiving frontend budget", "rejects a CA signed peer without a pin on the owner side", "refuses an owner whose CA signed certificate is not the pinned owner", - "returns the artifact status through the owner from every frontend with one denial for the rest", - "types the owner's refusal and outage of a lookup the same from every frontend", - "writes an attachment header that cannot be broken out of", - "rotates the enforced audience on restart and rejects captured requests", - "never expires the response deadline after the relay returned", + "rejects unknown lifecycle argument fields before touching the owner journal", + "adopts read outputs as private artifacts inside admission and replaces a forged resource identity", + "reports an account store outage as unavailable and a refused account as forbidden", + "alternates real owner journal status and terminates coarse progress without submitting again", "returns the owner's forbidden status for an invalid kind and for missing policy", "still reports unavailable when the owner is unable to serve and not forbidden", "reaches the same decision through both frontends and delivers bytes when allowed", "keeps aborting when revocation happens after bytes were written", - "bounds progress, asset and file relays by their own limit", - "alternates real owner journal status and terminates coarse progress without submitting again", - "reports an account store outage as unavailable and a refused account as forbidden", - "rejects unknown lifecycle argument fields before touching the owner journal", - "reports a runtime that cannot serve as unavailable through both frontends", - "reports an output that ends before its first byte as unavailable", - "fails closed as unavailable for an unclassified owner error", - "keeps authorization and policy denials forbidden", - "keeps a mutation unknown when the runtime fails after the owner applied it", - "keeps a mutation unknown when the owner reports recovery required for its operation", - "reports a mutation as unknown when the owner cannot serve it at all", - "keeps a mutation unknown on the owner's own frontend and leaves reads and denials alone", - "lets unavailability win when an error wraps both a denial and an unavailable cause", - "does not leak attachment headers into an error when the first read fails", - "treats every action except the known reads as a mutation", - "keeps resolved resources under admission for JSON and attachment bytes", - "closes interrupted output and releases admission on disconnect", - "rechecks revoked credentials during attachment copying", - "adopts read outputs as private artifacts inside admission and replaces a forged resource identity", + "fails closed without current identity or resource policy and reports the original ID of an unknown mutation outcome", "refuses at ingest a file result larger than the relay file bound so nothing unservable is staged", - "denies private relays without host resolution and admission", - "serves declared assets of the current enabled generation by package digest only", - "allows only an exact declared tool name and fails closed otherwise", - "never contacts the add-on for an undeclared tool and still serves a declared one", - "denies a repeated key at any depth however it is spelled", - "denies arguments that are not exactly one object", - "separates a denial from an unavailable declaration source", - "answers 503 and never contacts the add-on when the source is unavailable or the arguments repeat a key", - "is the typed invalid request on the owner for a read, never an outage", - "is the typed invalid request on the owner for a proposal call", - "is the typed invalid request on the owner for a json relay", - "is the same typed answer from a forwarding frontend", - "crosses the owner's wire as the same typed error, whatever the frontend", - "does not trust a code the host does not know, and cleans the detail the add-on wrote", - "is a stale view for a caller that only knows ErrStale, and a denial otherwise", - "is a mutation on the carrier: a lost answer is an unknown outcome, never a plain outage", - "runs on the owner for the actor the signature binds, and the typed direct dispatch still refuses it", - "keeps the answer of a call that outlasts the write timeout of the owner server", - "is an unknown outcome when the answer is lost", - "runs the backend-releases check for a pinned frontend without an actor", - "refuses a system call from a peer that is not pinned", - "never lets a system call change anything or ride the user-facing route", + "projects private policy safely", + "forwards authenticated owner decisions", + "validates strict request payloads", + "checks CAS and administrator authority", + "validates current selection and authenticated actor", + "round trips installed typed home metadata over mTLS with owner actor visibility and immutable asset URLs", + "rotates the enforced audience on restart and rejects captured requests", + "passes through owner-resolved bindings with distinct provider and status names", "writes the set with private modes and no CA key", "loads both roles into carriers and serves a frontend call from the owner", "gives the owner no signing key and the frontend its own", @@ -2161,6 +3470,104 @@ "refuses an unknown role", "refuses to overwrite a file and leaves it unchanged", "needs an owner host", + "builds a carrier only from complete pinned identities", + "types an unknown mutation outcome as unavailable without losing its operation", + "rejects untrusted peers wrong audience body expiry replay and revoked credentials", + "is not permitted when the add-on denies the person, on the owner and on a forwarding frontend", + "is an unreachable resource when the policy denies it, on the owner and on a forwarding frontend", + "types the denial of a read like the denial of a write, on the owner and on a forwarding frontend", + "crosses the wire of a resource call typed, so a forwarding frontend answers like the owner", + "keeps any other failure to resolve a resource a plain denial, so nothing more is told", + "preserves bounded owner admission refusals over mTLS without frontend grants", + "accepts canonical bindings and rejects aliases and duplicates", + "keeps resolved resources under admission for JSON and attachment bytes", + "closes interrupted output and releases admission on disconnect", + "rechecks revoked credentials during attachment copying", + "denies private relays without host resolution and admission", + "carries the chat binding signed and rejects a changed one", + "signs a request without a binding as before", + "gives the owner the chat the frontend bound the call to", + "bounds progress, asset and file relays by their own limit", + "returns the artifact status through the owner from every frontend with one denial for the rest", + "types the owner's refusal and outage of a lookup the same from every frontend", + "writes an attachment header that cannot be broken out of", + "rejects aliases, duplicate fields and null claims", + "runs the backend-releases check for a pinned frontend without an actor", + "refuses a system call from a peer that is not pinned", + "never lets a system call change anything or ride the user-facing route", + "wrong object", + "additional properties true", + "additional properties absent", + "required", + "enum", + "unknown schema keyword", + "malformed required", + "resource", + "admin", + "status uiOnly", + "status kind", + "action uiOnly", + "action kind", + "shadow", + "reads the exact uiOnly status tool locally and over authenticated mTLS", + "refuses forged identities tools current selections and incarnations without dispatch", + "rejects stale carrier replies and frontend authority fields without dispatch", + "refuses forged observations through the real carrier and ignores ordinary dispatch attempts", + "does not relay a captured old binding to a same-generation replacement", + "settles parent cancellation and owner permission loss as unknown", + "does not grant owner selection authority through wire capability support", + "rejects duplicate noncanonical and nested authority keys in observation DTOs", + "withdraws a positive late reply when exact-client admission becomes stale", + "serves only declared installed generation assets and rejects traversal", + "accepts before a slow backup, detaches the request and polls through either frontend", + "reads typed status and preserves named tool and host operation IDs through a supervised process", + "rejects a captured supervised client after same generation replacement before carrier dispatch", + "rejects a late supervised carrier observation after replacement before owner consumption", + "resolves only the authenticated claim over actual mTLS", + "fails closed on unsupported owners and mismatched binding replies", + "runs a resource-less read tool for the calling user only", + "denies a tool that declares a resource when none is given, and never reaches the add-on", + "denies a resource-less read when the admission cannot admit an add-on, or the tool is undeclared", + "never expires the response deadline after the relay returned", + "propagates interrupted progress cancellation through the receiving frontend", + "reports a runtime that cannot serve as unavailable through both frontends", + "reports an output that ends before its first byte as unavailable", + "fails closed as unavailable for an unclassified owner error", + "keeps authorization and policy denials forbidden", + "keeps a mutation unknown when the runtime fails after the owner applied it", + "keeps a mutation unknown when the owner reports recovery required for its operation", + "reports a mutation as unknown when the owner cannot serve it at all", + "keeps a mutation unknown on the owner's own frontend and leaves reads and denials alone", + "lets unavailability win when an error wraps both a denial and an unavailable cause", + "does not leak attachment headers into an error when the first read fails", + "keeps its address stable across starts", + "does not share state between different roots", + "fails clearly when the port is in use", + "rejects an invalid port file", + "rejects a port file with a loose mode", + "rejects a port file that links outside the root", + "allows only an exact declared tool name and fails closed otherwise", + "never contacts the add-on for an undeclared tool and still serves a declared one", + "denies a repeated key at any depth however it is spelled", + "denies arguments that are not exactly one object", + "separates a denial from an unavailable declaration source", + "answers 503 and never contacts the add-on when the source is unavailable or the arguments repeat a key", + "crosses the owner's wire as the same typed error, whatever the frontend", + "does not trust a code the host does not know, and cleans the detail the add-on wrote", + "is a stale view for a caller that only knows ErrStale, and a denial otherwise", + "is a mutation on the carrier: a lost answer is an unknown outcome, never a plain outage", + "alternates pinned frontends and rejects untrusted certificates without local fallback", + "reports the original operation after lost mutation replies without retrying", + "rejects a stream longer than its receiving frontend budget", + "treats every action except the known reads as a mutation", + "is the typed invalid request on the owner for a read, never an outage", + "is the typed invalid request on the owner for a proposal call", + "is the typed invalid request on the owner for a json relay", + "is the same typed answer from a forwarding frontend", + "runs on the owner for the actor the signature binds, and the typed direct dispatch still refuses it", + "keeps the answer of a call that outlasts the write timeout of the owner server", + "is an unknown outcome when the answer is lost", + "reports an unclassified account check error as unavailable on every path and a wrapped denial as forbidden", "says nothing was sent when the add-on is not running, instead of an unknown outcome", "runs a declared action tool for the calling user, under the act scope, admitted as an add-on call", "is not reachable through the typed dispatch of the direct API", @@ -2168,28 +3575,8 @@ "refuses an add-on that did not declare actions", "is unavailable when the runtime does not serve action tools, and never runs for an unvalidated user", "reports an unknown outcome, not a refusal, when the runtime fails after the call began", - "runs a resource-less read tool for the calling user only", - "denies a tool that declares a resource when none is given, and never reaches the add-on", - "denies a resource-less read when the admission cannot admit an add-on, or the tool is undeclared", - "keeps its address stable across starts", - "does not share state between different roots", - "fails clearly when the port is in use", - "rejects an invalid port file", - "rejects a port file with a loose mode", - "rejects a port file that links outside the root", - "is not permitted when the add-on denies the person, on the owner and on a forwarding frontend", - "is an unreachable resource when the policy denies it, on the owner and on a forwarding frontend", - "types the denial of a read like the denial of a write, on the owner and on a forwarding frontend", - "crosses the wire of a resource call typed, so a forwarding frontend answers like the owner", - "keeps any other failure to resolve a resource a plain denial, so nothing more is told", - "carries the chat binding signed and rejects a changed one", - "signs a request without a binding as before", - "gives the owner the chat the frontend bound the call to", - "projects private policy safely", - "forwards authenticated owner decisions", - "validates strict request payloads", - "checks CAS and administrator authority", - "accepts before a slow backup, detaches the request and polls through either frontend" + "validates finite relay budgets and prevents disabling polling bounds", + "serves declared assets of the current enabled generation by package digest only" ] }, { @@ -2362,10 +3749,17 @@ ], "cases": [ "lists installed enabled addons for users and adds installed disabled ones only for administrators", + "carries the install time to the list and the management view, and none for an old record", "derives display fields, the main page and the icon URL from the verified manifest only", + "describes the immutable UI assets of an enabled addon by the package digest and never otherwise", "bounds the tool summary and coerces unknown process states to failed", "shows controls and the management link only to administrators and never leaks owner details", - "describes the immutable UI assets of an enabled addon by the package digest and never otherwise", + "lists the host controls an add-on is entitled to, whatever its ID", + "projects two non-Studio addon pages without identity dispatch", + "projects home commands with resolved declared pages display metadata immutable icons and generation", + "keeps disabled home apps installed without UI and never invents a destination", + "projects typed model actions without sharing manifest or view slices", + "keeps unavailable installed records and omits unavailable or absent model metadata", "denies enable and disable for a stale generation without recording anything", "lists the identifiers of installed addons, disabled ones included", "refuses to list installed identifiers while recovery is required", @@ -2409,6 +3803,39 @@ "shows the pending update in the apps list from the cache only", "does not accept pins without an update checker or on another action", "keeps the unpinned typed upgrade of an earlier release working", + "carries the SDK refusal text to the caller of a failed install and keeps the generic error", + "installs an add-on whose SDK range contains the host SDK", + "copies them at install, upgrade and owner start, and removes them at uninstall", + "never fails the operation when the copy fails", + "fails the install and says the package could not be verified, without installing anything", + "names a release that the library does not list", + "names two libraries that list the same add-on", + "names a library that cannot be read", + "reads the library on demand when the startup refresh has not filled the cache", + "reads the library again when the startup refresh failed", + "writes the owner and storage only, with no address", + "loads a file written in the old format with an address", + "loads when only the address changed", + "refuses a changed owner ID", + "refuses a changed storage ID", + "copies every capability field of the manifest, so a new field cannot be forgotten", + "is offered when the tag moved, pinned to the build the tag names, and upgrades to exactly that build", + "refuses the upgrade when the tag moved again after the check, and installs nothing", + "stamps the first install, keeps the time over an upgrade and stamps it again after an uninstall", + "needs a state root for the owner only", + "upgrades an installed add-on through the same path as a manual upgrade", + "does nothing when no newer version is listed", + "refuses a candidate whose package no longer matches its listed digest and replaces nothing", + "refuses a candidate that fails the signature policy of its library and replaces nothing", + "does not upgrade to a release the host cannot run", + "does nothing without an update checker", + "keeps the manual upgrade closed to a caller who is not an administrator", + "starts the enabled add-ons side by side, so the start takes the slowest ones and not the sum", + "distinguishes healthy empty connector discovery from loss of runtime ownership", + "persists and recovers model actions and declarations through install manual and automatic upgrades and real activation rollback", + "does not retain the caller's map or prefix slices", + "keeps an explicitly empty startup policy immutable", + "publishes identical bytes and digests with sorted archive entries for unchanged inputs and transforms", "registers only ready generation bound service headless and UI capabilities", "rejects forged registration nonloopback and stale incarnation handshakes", "bounds readiness and handles exit during start and stop", @@ -2441,6 +3868,38 @@ "an HTTP 422 answer", "keeps a bounded, printable reason and nothing else of the body", "leaves 503, 500 and 404 as outages", + "types a withdrawn client, a full slot table, a bad envelope and a missing schema as not sent", + "keeps a not-sent error an ErrRuntime for callers that do not tell them apart", + "types a refused connection as not sent", + "never types a possibly-sent request as not sent", + "types the add-on's own 'forbidden' answer as a denial of the person, not as an outage", + "keeps a 403 about the envelope itself an outage: it is a host fault, not a permission", + "reaches an add-on that declares actions, as the actor of the envelope, under the act scope", + "never sends a call that is not an act call or has another scope", + "never sends an act call to an add-on that did not declare actions", + "stops an act call when the caller gives up", + "posts the request with the incarnation credential and decodes the answer", + "is not sent to an add-on that declared no connector", + "gives it time to release what it owns before it kills the process", + "kills an add-on that ignores the closing of its startup channel, within the grace period", + "goes to the log line by line with the add-on ID and a cap per line", + "records the chat with the envelope of a read and of an action while it runs", + "sends the add-on an envelope without a new field", + "requires exact typed capability equality on authenticated registration health", + "keeps all typed declarations on automatic crash restart with a fresh incarnation", + "replays typed and legacy receipts across process restarts", + "refuses missing wire tool discriminators before any read", + "serializes exact identity admission with withdrawal and never dispatches the replacement", + "cannot observe a restarted same-generation process through the captured client", + "rejects output handles before they can reach storage or output-open", + "settles callback denial as failure even if the addon returns a valid positive payload", + "cleans up after cancellation and rejects repeat correlation", + "keeps an ordinary concurrent read authorized while an observation is pending", + "error", + "malformed/artifact payload", + "success", + "timeout", + "cancels an admitted observation when the runtime withdraws", "rejects anonymous and forged forwarding authority through real authentication", "lets callers through only when the deployment selected local-owner mode", "keeps authentication, the feature gate and administration when accounts exist", @@ -2510,52 +3969,7 @@ "reports a lost upgrade as an unknown outcome with its original operation, and a lost status read or check as plainly unavailable", "keeps the update actions out of the tools and names every field of the status in the Swagger spec", "answers an add-on's invalid-request refusal as a typed 400 from the owner and from a forwarding frontend", - "types a withdrawn client, a full slot table, a bad envelope and a missing schema as not sent", - "keeps a not-sent error an ErrRuntime for callers that do not tell them apart", - "types a refused connection as not sent", - "never types a possibly-sent request as not sent", - "carries the SDK refusal text to the caller of a failed install and keeps the generic error", - "installs an add-on whose SDK range contains the host SDK", "returns the same text through a forwarding frontend", - "carries the install time to the list and the management view, and none for an old record", - "lists the host controls an add-on is entitled to, whatever its ID", - "copies them at install, upgrade and owner start, and removes them at uninstall", - "never fails the operation when the copy fails", - "fails the install and says the package could not be verified, without installing anything", - "names a release that the library does not list", - "names two libraries that list the same add-on", - "names a library that cannot be read", - "reads the library on demand when the startup refresh has not filled the cache", - "reads the library again when the startup refresh failed", - "writes the owner and storage only, with no address", - "loads a file written in the old format with an address", - "loads when only the address changed", - "refuses a changed owner ID", - "refuses a changed storage ID", - "copies every capability field of the manifest, so a new field cannot be forgotten", - "is offered when the tag moved, pinned to the build the tag names, and upgrades to exactly that build", - "refuses the upgrade when the tag moved again after the check, and installs nothing", - "stamps the first install, keeps the time over an upgrade and stamps it again after an uninstall", - "needs a state root for the owner only", - "upgrades an installed add-on through the same path as a manual upgrade", - "does nothing when no newer version is listed", - "refuses a candidate whose package no longer matches its listed digest and replaces nothing", - "refuses a candidate that fails the signature policy of its library and replaces nothing", - "does not upgrade to a release the host cannot run", - "does nothing without an update checker", - "keeps the manual upgrade closed to a caller who is not an administrator", - "starts the enabled add-ons side by side, so the start takes the slowest ones and not the sum", - "types the add-on's own 'forbidden' answer as a denial of the person, not as an outage", - "keeps a 403 about the envelope itself an outage: it is a host fault, not a permission", - "reaches an add-on that declares actions, as the actor of the envelope, under the act scope", - "never sends a call that is not an act call or has another scope", - "never sends an act call to an add-on that did not declare actions", - "stops an act call when the caller gives up", - "posts the request with the incarnation credential and decodes the answer", - "is not sent to an add-on that declared no connector", - "gives it time to release what it owns before it kills the process", - "kills an add-on that ignores the closing of its startup channel, within the grace period", - "goes to the log line by line with the add-on ID and a cap per line", "Sec-Fetch-Site cross-site is refused even with a Referer on the same host", "Sec-Fetch-Site same-origin passes", "Sec-Fetch-Site same-site is refused", @@ -2598,19 +4012,7 @@ "returns the SDK refusal text, the unverified-package text and the missing-release text to the administrator", "logs the cause of a failed install and types it for the client", "logs the reason behind a generic denial and leaves the response unchanged", - "names every typed cause of a failed install in the 403 of the dispatch route", - "records the chat with the envelope of a read and of an action while it runs", - "sends the add-on an envelope without a new field", - "distinguishes healthy empty connector discovery from loss of runtime ownership", - "installs upgrades and uninstalls the actual Sandbox document skills through verified receipts", - "finishes a real backup and commit after the submitting request is cancelled", - "polls and retries during a blocked backup, rejects conflicting IDs, and retains the receipt on restart", - "reconciles durable accepted work to interrupted without replay", - "Close cancels and joins an accepted owner worker before releasing storage", - "traces completion, retries, detach, interruption and late delivery without replay", - "keeps terminals immutable for every event and gives each active state a terminal exit", - "Close cancels a backup in flight and joins its cleanup before releasing ownership", - "serializes synchronous disable against an accepted upgrade without overwriting its journal" + "names every typed cause of a failed install in the 403 of the dispatch route" ], "report": ".cache/addons/logs/apps.json", "reports": [ @@ -2618,12 +4020,17 @@ "path": ".cache/addons/logs/apps.json", "cases": [ "lists installed enabled addons for users and adds installed disabled ones only for administrators", + "carries the install time to the list and the management view, and none for an old record", "derives display fields, the main page and the icon URL from the verified manifest only", + "describes the immutable UI assets of an enabled addon by the package digest and never otherwise", "bounds the tool summary and coerces unknown process states to failed", "shows controls and the management link only to administrators and never leaks owner details", - "describes the immutable UI assets of an enabled addon by the package digest and never otherwise", - "carries the install time to the list and the management view, and none for an old record", - "lists the host controls an add-on is entitled to, whatever its ID" + "lists the host controls an add-on is entitled to, whatever its ID", + "projects two non-Studio addon pages without identity dispatch", + "projects home commands with resolved declared pages display metadata immutable icons and generation", + "keeps disabled home apps installed without UI and never invents a destination", + "projects typed model actions without sharing manifest or view slices", + "keeps unavailable installed records and omits unavailable or absent model metadata" ] }, { @@ -2709,7 +4116,11 @@ "traces completion, retries, detach, interruption and late delivery without replay", "keeps terminals immutable for every event and gives each active state a terminal exit", "Close cancels a backup in flight and joins its cleanup before releasing ownership", - "serializes synchronous disable against an accepted upgrade without overwriting its journal" + "serializes synchronous disable against an accepted upgrade without overwriting its journal", + "persists and recovers model actions and declarations through install manual and automatic upgrades and real activation rollback", + "does not retain the caller's map or prefix slices", + "keeps an explicitly empty startup policy immutable", + "publishes identical bytes and digests with sorted archive entries for unchanged inputs and transforms" ] }, { @@ -2763,7 +4174,22 @@ "kills an add-on that ignores the closing of its startup channel, within the grace period", "goes to the log line by line with the add-on ID and a cap per line", "records the chat with the envelope of a read and of an action while it runs", - "sends the add-on an envelope without a new field" + "sends the add-on an envelope without a new field", + "requires exact typed capability equality on authenticated registration health", + "keeps all typed declarations on automatic crash restart with a fresh incarnation", + "replays typed and legacy receipts across process restarts", + "refuses missing wire tool discriminators before any read", + "serializes exact identity admission with withdrawal and never dispatches the replacement", + "cannot observe a restarted same-generation process through the captured client", + "rejects output handles before they can reach storage or output-open", + "settles callback denial as failure even if the addon returns a valid positive payload", + "cleans up after cancellation and rejects repeat correlation", + "keeps an ordinary concurrent read authorized while an observation is pending", + "error", + "malformed/artifact payload", + "success", + "timeout", + "cancels an admitted observation when the runtime withdraws" ] }, { @@ -3356,6 +4782,27 @@ "leaves a database whose step fails exactly as it was and resumes on the next run", "refuses data in a newer format, a downgrade and a version it has no path to, and writes nothing", "leaves no pending line for a change the add-on refused, so the same ID applies the corrected request", + "records an inverse for an insert and removes exactly those rows on undo", + "restores the previous values of an update, for the columns it set only", + "brings deleted rows back with their row IDs and every value, nulls included", + "is idempotent by operation ID: the same ID returns the same receipt and undoes once", + "refuses the same operation ID for another change", + "refuses a stale undo with what changed and overwrites nothing", + "refuses an undo whose expected revision moved between the read and the apply", + "unwinds in order: undoing the latest change makes the earlier one undoable again", + "keeps no change set that is too large and says so at apply time", + "expires a change set after the retention and drops what it held", + "keeps the total of change sets of one database bounded by dropping the oldest", + "answers not found for a change that never existed or belongs to a read", + "removes a created table again and refuses once it was touched", + "renames a table or a column back", + "removes an added column again, with its unique index", + "restores a dropped column with its data, its type, required and unique flags", + "restores a dropped table with its columns, rows and row IDs", + "says a dropped column or table was too large to keep, at apply time", + "renames the database back only while it still has the new name", + "undoes a creation only while the database is untouched, and marks that the resource is deleted", + "keeps the change sets in the database file, so deleting the database removes them", "starts the real executable through the authenticated handshake and refuses unauthenticated callers", "creates a database under the host-assigned ID and lists it only for people who may read it", "drops the reservation when the addon refuses the name, and leaves no database", @@ -3379,27 +4826,6 @@ "stores every change as a pending proposal and applies it only on the proposer's own session, exactly once", "keeps the roles of the host for tool calls and rejects forged fields before the owner sees them", "lists at most the bound of pending proposals and refuses more than the user's quota", - "records an inverse for an insert and removes exactly those rows on undo", - "restores the previous values of an update, for the columns it set only", - "brings deleted rows back with their row IDs and every value, nulls included", - "is idempotent by operation ID: the same ID returns the same receipt and undoes once", - "refuses the same operation ID for another change", - "refuses a stale undo with what changed and overwrites nothing", - "refuses an undo whose expected revision moved between the read and the apply", - "unwinds in order: undoing the latest change makes the earlier one undoable again", - "keeps no change set that is too large and says so at apply time", - "expires a change set after the retention and drops what it held", - "keeps the total of change sets of one database bounded by dropping the oldest", - "answers not found for a change that never existed or belongs to a read", - "removes a created table again and refuses once it was touched", - "renames a table or a column back", - "removes an added column again, with its unique index", - "restores a dropped column with its data, its type, required and unique flags", - "restores a dropped table with its columns, rows and row IDs", - "says a dropped column or table was too large to keep, at apply time", - "renames the database back only while it still has the new name", - "undoes a creation only while the database is untouched, and marks that the resource is deleted", - "keeps the change sets in the database file, so deleting the database removes them", "tells the model and the chat that a data change can be undone, and undoes it from the person's own tab", "never lets a key, a tool call, another session or a stranger undo, so a model cannot undo its own work", "refuses a stale undo with what changed, and unwinds from the newest change to the oldest", @@ -3636,6 +5062,27 @@ "leaves a database whose step fails exactly as it was and resumes on the next run", "refuses data in a newer format, a downgrade and a version it has no path to, and writes nothing", "leaves no pending line for a change the add-on refused, so the same ID applies the corrected request", + "records an inverse for an insert and removes exactly those rows on undo", + "restores the previous values of an update, for the columns it set only", + "brings deleted rows back with their row IDs and every value, nulls included", + "is idempotent by operation ID: the same ID returns the same receipt and undoes once", + "refuses the same operation ID for another change", + "refuses a stale undo with what changed and overwrites nothing", + "refuses an undo whose expected revision moved between the read and the apply", + "unwinds in order: undoing the latest change makes the earlier one undoable again", + "keeps no change set that is too large and says so at apply time", + "expires a change set after the retention and drops what it held", + "keeps the total of change sets of one database bounded by dropping the oldest", + "answers not found for a change that never existed or belongs to a read", + "removes a created table again and refuses once it was touched", + "renames a table or a column back", + "removes an added column again, with its unique index", + "restores a dropped column with its data, its type, required and unique flags", + "restores a dropped table with its columns, rows and row IDs", + "says a dropped column or table was too large to keep, at apply time", + "renames the database back only while it still has the new name", + "undoes a creation only while the database is untouched, and marks that the resource is deleted", + "keeps the change sets in the database file, so deleting the database removes them", "starts the real executable through the authenticated handshake and refuses unauthenticated callers", "creates a database under the host-assigned ID and lists it only for people who may read it", "drops the reservation when the addon refuses the name, and leaves no database", @@ -3659,27 +5106,6 @@ "stores every change as a pending proposal and applies it only on the proposer's own session, exactly once", "keeps the roles of the host for tool calls and rejects forged fields before the owner sees them", "lists at most the bound of pending proposals and refuses more than the user's quota", - "records an inverse for an insert and removes exactly those rows on undo", - "restores the previous values of an update, for the columns it set only", - "brings deleted rows back with their row IDs and every value, nulls included", - "is idempotent by operation ID: the same ID returns the same receipt and undoes once", - "refuses the same operation ID for another change", - "refuses a stale undo with what changed and overwrites nothing", - "refuses an undo whose expected revision moved between the read and the apply", - "unwinds in order: undoing the latest change makes the earlier one undoable again", - "keeps no change set that is too large and says so at apply time", - "expires a change set after the retention and drops what it held", - "keeps the total of change sets of one database bounded by dropping the oldest", - "answers not found for a change that never existed or belongs to a read", - "removes a created table again and refuses once it was touched", - "renames a table or a column back", - "removes an added column again, with its unique index", - "restores a dropped column with its data, its type, required and unique flags", - "restores a dropped table with its columns, rows and row IDs", - "says a dropped column or table was too large to keep, at apply time", - "renames the database back only while it still has the new name", - "undoes a creation only while the database is untouched, and marks that the resource is deleted", - "keeps the change sets in the database file, so deleting the database removes them", "tells the model and the chat that a data change can be undone, and undoes it from the person's own tab", "never lets a key, a tool call, another session or a stranger undo, so a model cannot undo its own work", "refuses a stale undo with what changed, and unwinds from the newest change to the oldest", @@ -3916,6 +5342,27 @@ "leaves a database whose step fails exactly as it was and resumes on the next run", "refuses data in a newer format, a downgrade and a version it has no path to, and writes nothing", "leaves no pending line for a change the add-on refused, so the same ID applies the corrected request", + "records an inverse for an insert and removes exactly those rows on undo", + "restores the previous values of an update, for the columns it set only", + "brings deleted rows back with their row IDs and every value, nulls included", + "is idempotent by operation ID: the same ID returns the same receipt and undoes once", + "refuses the same operation ID for another change", + "refuses a stale undo with what changed and overwrites nothing", + "refuses an undo whose expected revision moved between the read and the apply", + "unwinds in order: undoing the latest change makes the earlier one undoable again", + "keeps no change set that is too large and says so at apply time", + "expires a change set after the retention and drops what it held", + "keeps the total of change sets of one database bounded by dropping the oldest", + "answers not found for a change that never existed or belongs to a read", + "removes a created table again and refuses once it was touched", + "renames a table or a column back", + "removes an added column again, with its unique index", + "restores a dropped column with its data, its type, required and unique flags", + "restores a dropped table with its columns, rows and row IDs", + "says a dropped column or table was too large to keep, at apply time", + "renames the database back only while it still has the new name", + "undoes a creation only while the database is untouched, and marks that the resource is deleted", + "keeps the change sets in the database file, so deleting the database removes them", "starts the real executable through the authenticated handshake and refuses unauthenticated callers", "creates a database under the host-assigned ID and lists it only for people who may read it", "drops the reservation when the addon refuses the name, and leaves no database", @@ -3939,27 +5386,6 @@ "stores every change as a pending proposal and applies it only on the proposer's own session, exactly once", "keeps the roles of the host for tool calls and rejects forged fields before the owner sees them", "lists at most the bound of pending proposals and refuses more than the user's quota", - "records an inverse for an insert and removes exactly those rows on undo", - "restores the previous values of an update, for the columns it set only", - "brings deleted rows back with their row IDs and every value, nulls included", - "is idempotent by operation ID: the same ID returns the same receipt and undoes once", - "refuses the same operation ID for another change", - "refuses a stale undo with what changed and overwrites nothing", - "refuses an undo whose expected revision moved between the read and the apply", - "unwinds in order: undoing the latest change makes the earlier one undoable again", - "keeps no change set that is too large and says so at apply time", - "expires a change set after the retention and drops what it held", - "keeps the total of change sets of one database bounded by dropping the oldest", - "answers not found for a change that never existed or belongs to a read", - "removes a created table again and refuses once it was touched", - "renames a table or a column back", - "removes an added column again, with its unique index", - "restores a dropped column with its data, its type, required and unique flags", - "restores a dropped table with its columns, rows and row IDs", - "says a dropped column or table was too large to keep, at apply time", - "renames the database back only while it still has the new name", - "undoes a creation only while the database is untouched, and marks that the resource is deleted", - "keeps the change sets in the database file, so deleting the database removes them", "tells the model and the chat that a data change can be undone, and undoes it from the person's own tab", "never lets a key, a tool call, another session or a stranger undo, so a model cannot undo its own work", "refuses a stale undo with what changed, and unwinds from the newest change to the oldest", @@ -4080,7 +5506,11 @@ "go: does not let a listing-capable provider invent a named-user principal", "go: revalidates listing failures through an already-bound consumer without namespace fallback", "go: preserves the model read byte bound", - "go: offers the host read even when Tables has no executable definitions" + "go: offers the host read even when Tables has no executable definitions", + "go: keeps declared uiOnly execution status tools out of generic adapters", + "go: round trips valid components and enforces the callable length boundary", + "go: rejects malformed qualified identities while reserving their namespace", + "go: forwards bindings only through an explicit binding backend" ] }, "tables-admin": { @@ -4745,5 +6175,154 @@ "shows the conflict message the store gives next to the source that clashes", "turning it off hides the default galleries and shows the defaults as off, and on brings them back" ] + }, + "host-admission": { + "kind": "ginkgo", + "commands": [ + [ + "go", + "test", + "-count=1", + "-p", + "2", + "-tags", + "auth", + "./core/services/addons/hostops", + "-ginkgo.json-report=../../../../.cache/addons/logs/host-admission.json" + ] + ], + "cases": [ + "materializes / as the folder and adds the chat folder", + "refuses a mount outside the folder", + "refuses export and import outside the folder", + "works inside the folder with the agent's mode", + "imports into the folder of a writable agent, for the chat", + "lists no change outside the folder", + "reports a commit outside the view as a conflict", + "binds a read call that has no resource ID", + "stays user-wide for a call with no binding", + "keeps an unscoped chat on the whole home", + "fails closed when the chat of the call cannot be resolved", + "resolves an action without a binding by its chat", + "refuses what is malformed", + "refuses an unregistered operation under an allowed prefix", + "refuses everything for a broker without a feature check", + "needs no feature check for the local owner", + "does not let another add-on use the operation of a live request", + "drops the envelope when the request ends", + "stores the content and returns a ref, not a path", + "is idempotent and fine-tuning only", + "refuses an empty or oversized content", + "refuses a name with a directory", + "refuses an extension that is not a text dataset", + "refuses content that is not UTF-8", + "resolves only a reference the host issued to that user", + "bounds the issued references to 256", + "refuses a wrong credential", + "refuses a call without a live envelope", + "refuses an expired authority", + "does not let a read scope run an apply operation", + "checks the feature of the actor", + "runs the operation as the actor of the envelope", + "refuses an operation outside the allowed prefix", + "refuses an oversized body", + "runs an idempotent operation once per operation ID", + "materializes the folders of the user of the live request", + "refuses every operation when the host maps the actor to no user", + "never reaches the files of another user", + "refuses a directory outside the data directory of the add-on", + "builds the tree in the folder it opened when a folder above it is swapped for a link", + "commits the upper layer as the user of the request", + "commits for the chat of the request and names the paths it did not save in the add-on's terms", + "does not commit an upper layer that is a link or not a regular file", + "lets a read envelope materialize but not commit", + "takes the user from the request, never from the arguments", + "tells an administrator apart from anybody else", + "lists the changes after a revision", + "exports a file of the user and never one of another user", + "imports a file as the user of the request and refuses a link", + "names the change of an imported file after the write, not as an upload by the person", + "keeps both files when the file changed after the add-on read it", + "commits once for a repeated operation", + "gives an add-on outside the allowlist no workspace operation", + "export registers a model and returns only its name", + "export uses the highest step checkpoint", + "export of a job with no checkpoint is a conflict naming it", + "export refuses a bad name", + "export refuses an export format that is not listed", + "export of another user's job is not found", + "export runs once per operation ID", + "export refuses a returned path that is not a model name", + "import registers the quantized model and returns only its name", + "import of a job that is not completed is a conflict", + "model operations are apply, idempotent and admin only", + "export is refused for a feature-holding non-admin", + "export is allowed for an admin", + "import is refused for a feature-holding non-admin", + "import is allowed for an admin", + "lets the admin check decide in every mode", + "matches the supported vocabulary exactly to every real builtin registration", + "detects missing extra duplicate and case-changed operations with the parity oracle", + "uses registered exact names and literal nonempty grant prefixes without self grants", + "permits missing optional requests and bounds diagnostics to declaration names", + "keeps the binding with the envelope and gives it to the operation", + "refuses an operation ID that two requests with different bindings share", + "carries a binding in a context and ignores an incomplete one", + "runs with the incarnation credential alone, with no live request and no actor", + "refuses an absent or foreign credential", + "takes the add-on ID from the broker, not from the request", + "refuses an argument that names a user, an actor or an owner", + "refuses an op outside the prefix the host setting gives", + "does not turn a normal operation into an inbound one", + "refuses to register an inbound operation that names a feature", + "maps a refusal of the handler to 403", + "takes the chat from the envelope's resource and the user from its actor", + "refuses arguments that name a chat, a user or the policy, and unknown ones", + "refuses a call with no live operation, a lower scope, no chat or no chats feature", + "takes at most 500 audit lines and only done or block verdicts", + "start passes the same user key as the route", + "start refuses a local path as model or dataset", + "start refuses a backend that is not a fine-tuning backend", + "start names the missing fine-tuning engine, the default one included", + "start refuses a field that is not in the tool schema", + "get of another user's job is not found", + "list returns only the caller's jobs", + "a job result never carries a filesystem path", + "a second user's list does not show the first user's jobs", + "start refuses a local path as model", + "start refuses a backend that is not a quantization backend", + "start names the missing quantization engine, the default one included", + "list returns only the caller's jobs without a path", + "registers no operation for nil services", + "finetune.checkpoints needs its feature", + "finetune.get needs its feature", + "finetune.list needs its feature", + "finetune.start needs its feature", + "quantize.get needs its feature", + "quantize.list needs its feature", + "quantize.start needs its feature", + "must be a gallery or Hugging Face ID", + "refusal is a 400 naming the field", + "accepts Hugging Face and gallery IDs", + "accepts a dataset ref as dataset source", + "returns the next event within the wait", + "returns the snapshot when the wait ends with no event", + "caps the wait", + "reports done for a final status", + "does not find the progress of another user's job", + "stops once per operation", + "keeps the checkpoint only when asked", + "does not find the stop of another user's job", + "ends the wait when the context is cancelled", + "denies every correlated class before handlers and retains late denial", + "cannot overwrite an observation with a forged identity or duplicate operation", + "rejects mismatched authenticated incarnation and forged IDs without touching handlers", + "removes retired state on endpoint close", + "fails closed at the retained correlation bound without restricting ordinary operations", + "keeps authenticated pending inbound callbacks denied while another endpoint works", + "refuses an authorized decoded callback paused across endpoint Stop", + "stops without waiting for a handler that ignores cancellation" + ], + "report": ".cache/addons/logs/host-admission.json" } } diff --git a/scripts/addons/verify.sh b/scripts/addons/verify.sh index 5400055dd..696dfec29 100644 --- a/scripts/addons/verify.sh +++ b/scripts/addons/verify.sh @@ -60,22 +60,8 @@ def failure_summary(command, output, status): if tail: lines.append('last output: ' + ' | '.join(l[:200] for l in tail)) lines.append('child status %d; failed: %s; log: %s' % (status, ', '.join(names[:10]) or 'unknown', log)) return '\n'.join(lines) -def ginkgo_report(path, expected): - try: - reports = json.loads(path.read_text()) - assert isinstance(reports, list) and reports - cases = [] - for report in reports: - assert report['SuiteSucceeded'] is True - specs = report['SpecReports'] - assert specs - for spec in specs: - assert spec['State'] == 'passed' - if spec['LeafNodeType'] == 'It': cases.append(spec['LeafNodeText']) - assert cases and sorted(cases) == sorted(expected) - except (OSError, ValueError, KeyError, TypeError, AssertionError): - sys.exit('Ginkgo report missing, empty, skipped, failed or inventory mismatch: '+str(path)) - return cases +sys.path.insert(0, str(root / 'scripts/addons')) +from ginkgo_report import ginkgo_report seen = [] if entry['kind'] == 'ginkgo': @@ -93,7 +79,7 @@ for command in entry['commands']: seen.append(item['case']) if entry['kind'] == 'ginkgo': seen = [] - for report in reports: seen.extend(ginkgo_report(root/report['path'], report['cases'])) + for report in reports: seen.extend(ginkgo_report(root/report['path'], report['cases'], report.get('focus'))) if sorted(seen) != sorted(entry['cases']): sys.exit('required case inventory mismatch: '+repr(seen)) # Run real readiness only after the shell test inventory has passed. diff --git a/tests/addons/fixture/cmd/main.go b/tests/addons/fixture/cmd/main.go index e9af15c33..ba9386de1 100644 --- a/tests/addons/fixture/cmd/main.go +++ b/tests/addons/fixture/cmd/main.go @@ -35,7 +35,8 @@ func main() { // per committed operation, and ".applies" with one line per // apply request that reached the process, duplicates included. Empty // keeps the receipts in memory only. - Effects string `json:"effects"` + Effects string `json:"effects"` + ObservationLog string `json:"observationLog"` } b, _ := os.ReadFile("fixture.json") _ = json.Unmarshal(b, &config) @@ -155,6 +156,21 @@ func main() { if !read(&v) || !valid(v, r.URL.Path[1:]) { return } + if config.Mode == "execution-status" && r.URL.Path == "/read" { + // Record the authenticated envelope in a private test artifact, not + // in the status payload (whose schema is deliberately closed). + b, _ := json.Marshal(struct { + Incarnation string `json:"incarnation"` + Envelope contract.Envelope `json:"envelope"` + }{startup.Registration.Incarnation, v}) + noteLine(config.ObservationLog, string(b)) + if string(v.Request.Arguments) != `{"tool":"health"}` && string(v.Request.Arguments) != `{"tool":"inspect"}` { + http.Error(w, "wrong status tool", 400) + return + } + _ = json.NewEncoder(w).Encode(contract.Result{Payload: json.RawMessage(`{"readiness":"available","isolation":"isolated"}`)}) + return + } if v.Request.Action == "delay" { select { case <-r.Context().Done():