Compare commits

..
Author SHA1 Message Date
LukeGus cc725c7889 feat: add the operations layer for declared plugin actions 2026-11-03 22:28:54 -06:00
LukeGus b87fe3c6a5 chore: rename vault to ssh certificate signer in the readme 2026-11-03 21:31:19 -06:00
LukeGus b9df36b09a feat: rework default plugins and keep unchosen plugins off in the first run picker 2026-11-03 21:29:39 -06:00
LukeGus 31df4b12e3 fix: drop the empty security step when no sign in plugin offers setup 2026-11-03 21:27:29 -06:00
LukeGus 80612c304b fix: match password field height to other inputs 2026-11-03 21:26:49 -06:00
LukeGus a2aafc11ec chore: add wireguard to the readme plugin table 2026-11-03 20:55:27 -06:00
LukeGus 3ab3022451 fix: stop alerts with no icon squeezing text to one word per line 2026-11-03 20:48:27 -06:00
LukeGus 037b0329bd chore: use the new wake-on-lan description in the readme 2026-11-03 20:38:02 -06:00
LukeGus 78a1e90d93 chore: update wake-on-lan readme description 2026-11-03 20:37:33 -06:00
LukeGus f5895f15f2 chore: update translations and plugin pins 2026-11-03 15:09:54 +00:00
LukeGus 4fb91850a0 fix: keep the default look out of the first run plugin picker 2026-11-02 16:48:15 -06:00
LukeGus 6b06059411 chore: bump Homebrew cask to 26.11.0 2026-11-02 00:20:35 +00:00
Luke Gustafson 1d4eac3e20 v26.11.0 (#1756)
* fix: only show connection lost when the server check fails too

* fix: load the macOS tray icon from the right unpacked folder (#1728)

* fix: hide update and review buttons on disabled plugins (#1745)

* fix: save the database without blocking the server, bump to 26.11.0

* fix: try the key before the password for hosts with both (Plugin-SSH-Terminal#50)

* fix: add macOS Window menu item to reopen main window

* feat: upload Mac App Store screenshots on submit

* chore: drop internal note from changelog

* chore: update translations and plugin pins

* chore: lint, format, and bump version to 26.11.0
2026-11-01 17:16:04 -06:00
LukeGus c759a147c6 chore: bump Homebrew cask to 26.10.6 2026-10-30 00:24:05 +00:00
LukeGus cba845a7b8 feat: link full desktop builds in release body and readme 2026-10-29 18:31:59 -05:00
f90621a54e v26.10.6 (#1739)
* feat: let the login page send the token back to the mobile app

* feat: add host-scripts entry to the plugin sdk

* feat: let plugins say how they show in the mobile app

* feat: frame host for running one plugin view outside the shell

* fix: build the SDK host modules in production mode

* chore: note the SDK host build fix in the changelog

* feat: list plugin engine ranges for the mobile app

* feat: host color and icon columns that sync

* chore: update translations and plugin pins

* fix: test

* fix: show source address when rejecting proxy auth

* fix: deleting your own account removes all of its data

* fix(deps): update body-parser to 2.4.0 (#1736)

* fix: body-parser 보안 패치 반영

* chore: 자동 검사를 막던 기존 JSON 서식 정리

---------

Co-authored-by: renovys <180682734+renovys@users.noreply.github.com>

* fix(sdk): decode command output as GBK when it is not valid UTF-8 (#1735)

* fix: honor shared credentials on shared hosts (#1730)

* fix: honor shared credentials on shared hosts and in personal SSH auth

* fix: snapshot shared SSH auth from a credential shared with the host owner

* fix: size DataView list columns by the table's own width (#1713)

* fix: size DataView list columns by the table's own width

The list grid was built from every column, so columns hidden below md/lg
left empty tracks and the visible cells landed under the wrong headers.
Each width now gets a template of only its visible columns (CSS variables
+ container queries on the table, so a narrow panel counts, not the window),
the header uses the same gap as the rows, and rows use a minimum height so
wrapped cells no longer overlap.

* test: wait for the title bar offset effect before asserting

The bar shows once getTitleBarVisible resolves, but --termix-titlebar-height
is set in a later passive effect, so reading it right after findByText raced
(3-5 of 40 runs under load, on main too).

* chore: rerun CI against dev-26.10.6

* fix: unlock passphrase keys once per connect burst (#1737)

* feat: give ssh transports the host origin and sync id (#1717)

* fix: wake other clients after a sync push (Plugin-Sync#1)

* feat: look up local row ids by sync id for the sync plugin (Plugin-Sync#4)

* feat: rail items can show a status dot (Plugin-Sync#5)

* fix: add the desktop shared copy banner text (#1709)

* feat: sidebar preference for alternating rows (#1712)

* fix: striped rows text

* feat: shell can open a saved host's editor for plugins (Plugin-SSH-Terminal#36)

* fix: show the logo in the macos menu bar (#1728)

* fix: number shortcuts skip the home tab (#1724)

* fix: show plugins that could not be downloaded as failed (#1714)

* fix: pass the host id as text when opening its editor

* fix: retry a plugin bundle that was not ready yet (#1708)

* fix: let commonjs plugins fall back to core packages on the desktop

* fix: let non docker installs switch nginx to https (#1711)

* feat: remove a folder share from its hosts too (#1719)

* chore: changelog for merged pull requests

* fix: accept the release .sig when installing a plugin from a file

* feat: folder logins for shared hosts, read only plugin sync, full desktop builds

* fix: test

* fix: stop dev runner restart loops and narrow plugin watchers

* chore: update translations and plugin pins

* chore: lint, format, and bump version to 26.10.6

---------

Co-authored-by: renovys <renovy@gmail.com>
Co-authored-by: renovys <180682734+renovys@users.noreply.github.com>
Co-authored-by: Sebastian Cao <cycsmail@gmail.com>
Co-authored-by: Jonathan Rehuel Lewerissa <jonathan.rehuel16@gmail.com>
Co-authored-by: Rhiz3K <33246262+Rhiz3K@users.noreply.github.com>
2026-10-29 17:59:55 -05:00
133 changed files with 23045 additions and 119 deletions

No files matched your search

+3 -1
View File
@@ -1316,7 +1316,9 @@ jobs:
--app_identifier "com.karmaa.termix" \ --app_identifier "com.karmaa.termix" \
--skip_metadata false \ --skip_metadata false \
--metadata_path "${{ steps.asc_notes.outputs.metadata_path }}" \ --metadata_path "${{ steps.asc_notes.outputs.metadata_path }}" \
--skip_screenshots true \ --skip_screenshots false \
--screenshots_path packaging/appstore/screenshots \
--overwrite_screenshots true \
--skip_app_version_update false \ --skip_app_version_update false \
--submit_for_review true \ --submit_for_review true \
--automatic_release true \ --automatic_release true \
+32
View File
@@ -1,5 +1,37 @@
# Changelog # Changelog
## 26.11.1
### Added
- Termix actions: plugins can declare what they do once, and it works from the API with permission checks, dry runs, run history and approvals
- API keys can be limited to reading or running actions
### Changed
- Host status, import and export, and credential tools now start on for new installs
- Sync now starts on in a new desktop app, so it can link to a server
- The first run plugin picker leaves plugins you did not choose off instead of removing them
### Fixed
- The first run plugin picker no longer sets the default layout and themes to Remove. They always stay on
- Alerts with no icon no longer squeeze their text to one word per line
- Password fields with the show button are the same height as the other fields next to them
- The Secure your account setup step no longer shows up empty when no passkey or authenticator plugin is on
## 26.11.0
### Fixed
- Saving the database no longer holds up the whole server while it encrypts and writes. A big database is also saved less often by background writes, which fixes slow requests and dropped connections
- The macOS menu bar icon shows the logo instead of an empty shape on multi-architecture builds
- Disabled plugins no longer show update or review buttons, and no longer count as pending updates
- One slow request, like a plugin waiting on an SSH host, no longer shows Server connection lost. The app now checks the server first
- Canceled requests no longer show Server connection lost
- The macOS Window menu has an item to reopen the main window after it is closed
- Hosts with both an SSH key and a password can log in to servers set to AuthenticationMethods publickey,password
## 26.10.6 ## 26.10.6
### Added ### Added
+2 -2
View File
@@ -1,6 +1,6 @@
cask "termix" do cask "termix" do
version "26.10.5" version "26.11.0"
sha256 "808c467c5c34402193c7950ce61df0fa0445782f276348cf31ef235a6427d21c" sha256 "1b873e1315d769f8e416c1690032142efb152d1265c2f078b33f72cec3e6e03e"
url "https://github.com/Termix-SSH/Termix/releases/download/v#{version}/termix_macos_universal_dmg.dmg" url "https://github.com/Termix-SSH/Termix/releases/download/v#{version}/termix_macos_universal_dmg.dmg"
name "Termix" name "Termix"
+7 -5
View File
@@ -71,8 +71,8 @@ Plugins add everything else. The official ones ship with Termix, and admins can
| [Databases](https://github.com/Termix-SSH/Plugin-Databases) | Browse and edit MySQL, MariaDB, PostgreSQL and SQLite databases on your hosts from one tab. | | [Databases](https://github.com/Termix-SSH/Plugin-Databases) | Browse and edit MySQL, MariaDB, PostgreSQL and SQLite databases on your hosts from one tab. |
| [Docker](https://github.com/Termix-SSH/Plugin-Docker) | Manage Docker and Podman containers on your hosts over SSH, with logs, stats and a console. | | [Docker](https://github.com/Termix-SSH/Plugin-Docker) | Manage Docker and Podman containers on your hosts over SSH, with logs, stats and a console. |
| [File Manager](https://github.com/Termix-SSH/Plugin-File-Manager) | Browse, edit and move files over SFTP, including straight from one server to another. | | [File Manager](https://github.com/Termix-SSH/Plugin-File-Manager) | Browse, edit and move files over SFTP, including straight from one server to another. |
| [Firewall](https://github.com/Termix-SSH/Plugin-Firewall) | Manage UFW, firewalld, nftables and iptables rules on your hosts over SSH, with lockout protection. |
| [Fleets](https://github.com/Termix-SSH/Plugin-Fleets) | Group hosts into fleets and run commands, package actions and file transfers on all of them at once. | | [Fleets](https://github.com/Termix-SSH/Plugin-Fleets) | Group hosts into fleets and run commands, package actions and file transfers on all of them at once. |
| [HashiCorp Vault](https://github.com/Termix-SSH/Plugin-Vault) | Connect to hosts with short-lived SSH certificates signed by HashiCorp Vault. |
| [Homepage](https://github.com/Termix-SSH/Plugin-Homepage) | A drag and drop widget page for your servers and services, plus service links for the dashboard. | | [Homepage](https://github.com/Termix-SSH/Plugin-Homepage) | A drag and drop widget page for your servers and services, plus service links for the dashboard. |
| [Host Metrics](https://github.com/Termix-SSH/Plugin-Host-Metrics) | Live CPU, memory, disk, network and GPU stats, plus tools to manage your hosts. | | [Host Metrics](https://github.com/Termix-SSH/Plugin-Host-Metrics) | Live CPU, memory, disk, network and GPU stats, plus tools to manage your hosts. |
| [Host Status](https://github.com/Termix-SSH/Plugin-Host-Status) | Shows if each host is online with a dot in the host list, checked by connecting to its port. | | [Host Status](https://github.com/Termix-SSH/Plugin-Host-Status) | Shows if each host is online with a dot in the host list, checked by connecting to its port. |
@@ -96,6 +96,7 @@ Plugins add everything else. The official ones ship with Termix, and admins can
| [Simple Layout](https://github.com/Termix-SSH/Plugin-Simple-Layout) | A simple Termix layout for small setups with a top bar, a Home screen and only the basics. | | [Simple Layout](https://github.com/Termix-SSH/Plugin-Simple-Layout) | A simple Termix layout for small setups with a top bar, a Home screen and only the basics. |
| [Single sign-on](https://github.com/Termix-SSH/Plugin-SSO) | Sign in with any OpenID Connect provider, GitHub or Google. | | [Single sign-on](https://github.com/Termix-SSH/Plugin-SSO) | Sign in with any OpenID Connect provider, GitHub or Google. |
| [Snippets](https://github.com/Termix-SSH/Plugin-Snippets) | Save the commands you run often and run them in a terminal or on many hosts in one click. | | [Snippets](https://github.com/Termix-SSH/Plugin-Snippets) | Save the commands you run often and run them in a terminal or on many hosts in one click. |
| [SSH Certificate Signer](https://github.com/Termix-SSH/Plugin-Vault) | Connect to hosts with short-lived SSH certificates signed by HashiCorp Vault or OpenBao. |
| [SSH Terminal](https://github.com/Termix-SSH/Plugin-SSH-Terminal) | SSH terminal sessions with tabs, split screen, history, macros and a local terminal in the desktop app. | | [SSH Terminal](https://github.com/Termix-SSH/Plugin-SSH-Terminal) | SSH terminal sessions with tabs, split screen, history, macros and a local terminal in the desktop app. |
| [Step CA](https://github.com/Termix-SSH/Plugin-Step-CA) | Connect to hosts with short-lived SSH certificates from a smallstep step-ca server. | | [Step CA](https://github.com/Termix-SSH/Plugin-Step-CA) | Connect to hosts with short-lived SSH certificates from a smallstep step-ca server. |
| [Sync](https://github.com/Termix-SSH/Plugin-Sync) | Sync the desktop app with one or more servers, and manage other Termix servers as remote sites from a master. | | [Sync](https://github.com/Termix-SSH/Plugin-Sync) | Sync the desktop app with one or more servers, and manage other Termix servers as remote sites from a master. |
@@ -105,9 +106,10 @@ Plugins add everything else. The official ones ship with Termix, and admins can
| [TOTP](https://github.com/Termix-SSH/Plugin-TOTP) | Two-factor sign in with a code from an authenticator app, with backup codes. | | [TOTP](https://github.com/Termix-SSH/Plugin-TOTP) | Two-factor sign in with a code from an authenticator app, with backup codes. |
| [Tunnels](https://github.com/Termix-SSH/Plugin-Tunnels) | SSH port forwarding that reconnects on its own, plus client tunnels from the desktop app. | | [Tunnels](https://github.com/Termix-SSH/Plugin-Tunnels) | SSH port forwarding that reconnects on its own, plus client tunnels from the desktop app. |
| [Usage Statistics](https://github.com/Termix-SSH/Plugin-Telemetry) | Sends a small anonymous daily report so we can see how Termix is used. | | [Usage Statistics](https://github.com/Termix-SSH/Plugin-Telemetry) | Sends a small anonymous daily report so we can see how Termix is used. |
| [Wake-on-LAN](https://github.com/Termix-SSH/Plugin-Wake-On-LAN) | Wake sleeping machines by sending a Wake-on-LAN packet to their MAC address. | | [Wake-on-LAN](https://github.com/Termix-SSH/Plugin-Wake-On-LAN) | Wake, shut down and track your machines from one power dashboard. |
| [Warpgate](https://github.com/Termix-SSH/Plugin-Warpgate) | Connect to hosts through a Warpgate SSH bastion. | | [Warpgate](https://github.com/Termix-SSH/Plugin-Warpgate) | Connect to hosts through a Warpgate SSH bastion. |
| [Web Endpoint](https://github.com/Termix-SSH/Plugin-Web-Endpoint) | Open a host's web UI inside Termix, directly or over an SSH tunnel. | | [Web Endpoint](https://github.com/Termix-SSH/Plugin-Web-Endpoint) | Open a host's web UI inside Termix, directly or over an SSH tunnel. |
| [WireGuard](https://github.com/Termix-SSH/Plugin-WireGuard) | Install WireGuard on your hosts and manage interfaces, clients and configs from one tab. |
| [Workspaces](https://github.com/Termix-SSH/Plugin-Workspaces) | Save your open tabs and split layouts and reopen them in one click. | | [Workspaces](https://github.com/Termix-SSH/Plugin-Workspaces) | Save your open tabs and split layouts and reopen them in one click. |
Want to build your own? Start from the [plugin template](https://github.com/Termix-SSH/Termix-Plugin-Template). Plugins you can install are listed in the [registry](https://github.com/Termix-SSH/Termix-Registry). Turn on the community registry in Plugins, Registries to install plugins made by other people, or add your own registry. See [submitting a plugin](https://docs.termix.site/develop/community-registry) and [hosting your own registry](https://docs.termix.site/develop/registries#host-your-own-registry). Want to build your own? Start from the [plugin template](https://github.com/Termix-SSH/Termix-Plugin-Template). Plugins you can install are listed in the [registry](https://github.com/Termix-SSH/Termix-Registry). Turn on the community registry in Plugins, Registries to install plugins made by other people, or add your own registry. See [submitting a plugin](https://docs.termix.site/develop/community-registry) and [hosting your own registry](https://docs.termix.site/develop/registries#host-your-own-registry).
@@ -127,15 +129,15 @@ Want to build your own? Start from the [plugin template](https://github.com/Term
</tr> </tr>
<tr> <tr>
<td align="center"><b>Windows</b> <sub>x64/ia32</sub></td> <td align="center"><b>Windows</b> <sub>x64/ia32</sub></td>
<td>Portable | EXE and MSI Installer | Chocolatey</td> <td>Portable | EXE and MSI Installer | Full EXE (all plugins) | Chocolatey</td>
</tr> </tr>
<tr> <tr>
<td align="center"><b>Linux</b> <sub>x64/arm64/armv7l</sub></td> <td align="center"><b>Linux</b> <sub>x64/arm64/armv7l</sub></td>
<td>Portable | AUR | AppImage | Deb | Flatpak</td> <td>Portable | AUR | AppImage | Deb | Full AppImage and Deb (all plugins) | Flatpak</td>
</tr> </tr>
<tr> <tr>
<td align="center"><b>macOS</b> <sub>Universal/x64/arm64, v12.0+</sub></td> <td align="center"><b>macOS</b> <sub>Universal/x64/arm64, v12.0+</sub></td>
<td>Apple App Store | DMG | Homebrew</td> <td>Apple App Store | DMG | Full DMG (all plugins) | Homebrew</td>
</tr> </tr>
<tr> <tr>
<td align="center"><b>iOS/iPadOS</b> <sub>v15.1+</sub></td> <td align="center"><b>iOS/iPadOS</b> <sub>v15.1+</sub></td>
+62 -46
View File
@@ -10,15 +10,15 @@
{ {
"id": "ai", "id": "ai",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.3.0/ai-1.3.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.3.1/ai-1.3.1.tmxplug",
"sha256": "87ad17324cec6036dc224135fee342250c37413dccb2963f5f6b554d4ab8f5a6", "sha256": "dbee8bc101b8584e9e356bc3539787bb534ffc25809cd92c4f5d45d52cad4ce2",
"tier": "full" "tier": "full"
}, },
{ {
"id": "alerts", "id": "alerts",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.1.0/alerts-1.1.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.2.0/alerts-1.2.0.tmxplug",
"sha256": "a7ce9e20ce3c15f77c15479b4901563adf3c793e24a3aacd7226950491f0434c", "sha256": "488947482eed0306893c2983f701a0e14c55975ca2a0c4bb0799a40fce9db054",
"tier": "standard", "tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
@@ -42,8 +42,8 @@
{ {
"id": "classic-layout", "id": "classic-layout",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Classic-Layout/releases/download/v1.0.2/classic-layout-1.0.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Classic-Layout/releases/download/v1.1.1/classic-layout-1.1.1.tmxplug",
"sha256": "cf54f9b2e81c33b7e58cd3a21e203805f08ba37b3bc78412a08eb21b9fef8f14", "sha256": "2b0cee0c57b21d8017ed833aa923e934b57b908fa68ff395951b4d52e5113e47",
"tier": "minimal", "tier": "minimal",
"defaultLook": true "defaultLook": true
}, },
@@ -67,20 +67,24 @@
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Credential-Tools/releases/download/v1.0.0/credential-tools-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Credential-Tools/releases/download/v1.0.0/credential-tools-1.0.0.tmxplug",
"sha256": "413eff710b5c832a2575a890bbab1c90d28ea55f10cd7a7b94b851d51dd77dce", "sha256": "413eff710b5c832a2575a890bbab1c90d28ea55f10cd7a7b94b851d51dd77dce",
"tier": "standard" "tier": "standard",
"onboarding": {
"recommended": true,
"consent": false
}
}, },
{ {
"id": "databases", "id": "databases",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Databases/releases/download/v1.1.0/databases-1.1.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Databases/releases/download/v1.1.1/databases-1.1.1.tmxplug",
"sha256": "4d9504cde705688a6f1f7706139d4e4c49b2276eccc67538fdee8251f29920b8", "sha256": "316e26c682f5b07b0809e87f8ea94e2b819a9c46fc0e22611b24069c804312d0",
"tier": "full" "tier": "full"
}, },
{ {
"id": "docker", "id": "docker",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.2.1/docker-1.2.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.2.3/docker-1.2.3.tmxplug",
"sha256": "b2ca797465d88cfb549a04c3f0161708669def22a5ca73cb107879847c2db8ff", "sha256": "c997d5d1a751703facef544e901fb52e93fb79d5dc1c59496d4cb624210bbe14",
"tier": "standard", "tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
@@ -90,8 +94,8 @@
{ {
"id": "file-manager", "id": "file-manager",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.1.1/file-manager-1.1.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.1.3/file-manager-1.1.3.tmxplug",
"sha256": "ebba0b7d14facb3354f22a00e059e9f5a69e233458cb24985bf4ed5e5f8d2abd", "sha256": "b17315011c06db2223f04dac4606da505249dd8f2fec8aa24f1322c4dfd4d6ca",
"tier": "standard", "tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
@@ -101,8 +105,8 @@
{ {
"id": "fleets", "id": "fleets",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.2/fleets-1.0.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.1.0/fleets-1.1.0.tmxplug",
"sha256": "50dab38e1c6c95fdb4d0544ce50cdf84dae84789e64aa34ea9d04fdac3a38c59", "sha256": "5e0218c6145524ae8776b8fcbae043749c893d9b04fb67a9b3fe262ff76c0573",
"tier": "full" "tier": "full"
}, },
{ {
@@ -115,8 +119,8 @@
{ {
"id": "host-metrics", "id": "host-metrics",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.2.0/host-metrics-1.2.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.3.1/host-metrics-1.3.1.tmxplug",
"sha256": "8522cd23b558c3209821cee6d177daeb80f439b8ff652c577c5e4955315cc7a3", "sha256": "3c14a72b3efa2a8b0010229acba64a6a4098e8c2e35d5a01cbc3cb731df39fb5",
"tier": "standard", "tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
@@ -128,7 +132,11 @@
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Host-Status/releases/download/v1.0.0/host-status-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Host-Status/releases/download/v1.0.0/host-status-1.0.0.tmxplug",
"sha256": "1ba62577dc7373a2a2787bab7efdb89b544a30a8003b1de91ce51f372fc4f170", "sha256": "1ba62577dc7373a2a2787bab7efdb89b544a30a8003b1de91ce51f372fc4f170",
"tier": "standard" "tier": "standard",
"onboarding": {
"recommended": true,
"consent": false
}
}, },
{ {
"id": "hosts-config", "id": "hosts-config",
@@ -140,9 +148,13 @@
{ {
"id": "import-export", "id": "import-export",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Import-Export/releases/download/v1.0.0/import-export-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Import-Export/releases/download/v1.0.1/import-export-1.0.1.tmxplug",
"sha256": "6670b6c147e81eed0ddfd249f9a7c9564f1ab50e45f2771161d2e30c47f6fd58", "sha256": "d8f2f865e1e6b226f4fe38501bf69b752f47a643bed1d9d127181083e27da2f0",
"tier": "standard" "tier": "standard",
"onboarding": {
"recommended": true,
"consent": false
}
}, },
{ {
"id": "kubernetes", "id": "kubernetes",
@@ -182,15 +194,15 @@
{ {
"id": "proxmox", "id": "proxmox",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.1.0/proxmox-1.1.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.2.1/proxmox-1.2.1.tmxplug",
"sha256": "8937d239ad40033d69fba59085fe8896ee25c61951c21f66ff98531fd4c63cbf", "sha256": "d5a6e9cd50756785ede6aec351e2de08927816f71e54f3162c52331e0a804d74",
"tier": "full" "tier": "full"
}, },
{ {
"id": "remote-desktop", "id": "remote-desktop",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.2.1/remote-desktop-1.2.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.3.1/remote-desktop-1.3.1.tmxplug",
"sha256": "ad244f31016f0ea5df5c303bf6f48af35c6c8e52b12098d381c8296626b73de7", "sha256": "41483f8d64995206e0199cca7d37033e5b1f1194725da3525e1b8afa1b4e09a9",
"tier": "standard", "tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
@@ -200,8 +212,8 @@
{ {
"id": "reverse-proxy", "id": "reverse-proxy",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Reverse-Proxy/releases/download/v1.1.0/reverse-proxy-1.1.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Reverse-Proxy/releases/download/v1.1.1/reverse-proxy-1.1.1.tmxplug",
"sha256": "c8a9851da7ca6291c1f47cf6ab235da5057455a86b4ed0dc1c1a3026afb30aa7", "sha256": "f5e0089ca5eae1254f21ce18b222e70eb2beeae3bd874c2abc4faa1a68d3e47a",
"tier": "full" "tier": "full"
}, },
{ {
@@ -214,8 +226,8 @@
{ {
"id": "secret-sources", "id": "secret-sources",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.2/secret-sources-1.0.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.1.1/secret-sources-1.1.1.tmxplug",
"sha256": "82e2b1e95b0837dd3e5fee277be4eea2d1ce68567b0e204941c48150f415d134", "sha256": "2211ce2dac6355db49590532dfc339dc44df9cf14008157b420c7d4533d669ee",
"tier": "full" "tier": "full"
}, },
{ {
@@ -232,15 +244,15 @@
{ {
"id": "service-manager", "id": "service-manager",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Service-Manager/releases/download/v1.1.0/service-manager-1.1.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Service-Manager/releases/download/v1.1.1/service-manager-1.1.1.tmxplug",
"sha256": "82b1c70abd97ed535f217535c667fe6c48e5e01e2bf8037a89b58b80d35206bf", "sha256": "5731a04336d341a330416cba7b7999f0d8e6215752eabbf978ed58c4543c5036",
"tier": "full" "tier": "full"
}, },
{ {
"id": "session-recording", "id": "session-recording",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.1.0/session-recording-1.1.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.1.1/session-recording-1.1.1.tmxplug",
"sha256": "31cd78d67e061242f8776849a2fae5f84ab48c7c765d487b32c5e1693c54b46a", "sha256": "8e77087f98227ac37a8610dcc5380ad65162a58aebd9174a6c966d5a16b16c9e",
"tier": "full" "tier": "full"
}, },
{ {
@@ -260,8 +272,8 @@
{ {
"id": "snippets", "id": "snippets",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.1.0/snippets-1.1.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.2.0/snippets-1.2.0.tmxplug",
"sha256": "e2d7d1ebe5bdf2986ffb4961ac6f6d32b392b02c30a64a333a7eeb2ce6a46bb3", "sha256": "a72c0e1f38e49cf86ed8993b6913a6fbbdad7814ae132d5186c097ee3ea09bc3",
"tier": "standard", "tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
@@ -271,8 +283,8 @@
{ {
"id": "ssh-terminal", "id": "ssh-terminal",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.1.1/ssh-terminal-1.1.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.3.0/ssh-terminal-1.3.0.tmxplug",
"sha256": "5cfb381a2de10b7a6fab108d79645bb1e6e7316fb5e4150c4529a3e76a2193d8", "sha256": "ad39449f1e60724a9f84451e1190982e5d3ecd17e1751ec09a3b21c228e6dd5e",
"tier": "standard", "tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
@@ -282,8 +294,8 @@
{ {
"id": "sso", "id": "sso",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.4/sso-1.0.4.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.5/sso-1.0.5.tmxplug",
"sha256": "2aad33177085fbc92fe06cd007facffbfc8f42f9c9fa688ad5f37d68f62093d9", "sha256": "c1353d17fd25c1f503528c2a64a31ef14a638d9845c1d6cd70bb540e0321d41c",
"tier": "standard", "tier": "standard",
"onboarding": { "onboarding": {
"recommended": false, "recommended": false,
@@ -301,9 +313,13 @@
{ {
"id": "sync", "id": "sync",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Sync/releases/download/v1.0.1/sync-1.0.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Sync/releases/download/v1.1.0/sync-1.1.0.tmxplug",
"sha256": "66a301fd04fb8d2a3ece18dfb230069cf78e1138ee78941dc511dfbd8ee4f91e", "sha256": "9023d547f0e0bcef582c6ba7b7a8ab2ef68d8d0b973dde17d9e8c84d92b6429e",
"tier": "standard" "tier": "standard",
"onboarding": {
"recommended": "desktop",
"consent": false
}
}, },
{ {
"id": "tailscale", "id": "tailscale",
@@ -351,8 +367,8 @@
{ {
"id": "tunnels", "id": "tunnels",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.2.1/tunnels-1.2.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.2.3/tunnels-1.2.3.tmxplug",
"sha256": "2871a8d31854e1a31416c4afe5839099a4c489e34d9d31573659f73b2515f159", "sha256": "5a23953ee19b2951bb895e033d2099b8f6d5abc0121253ef3da25328134984ad",
"tier": "standard", "tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
@@ -390,8 +406,8 @@
{ {
"id": "webauthn", "id": "webauthn",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.1.0/webauthn-1.1.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.2.0/webauthn-1.2.0.tmxplug",
"sha256": "83a61a530db0273f9fef2080d6aa027a82ef8374fb4e0f158a8559a2770299de", "sha256": "4e7e06d05a17b6e474fdb0d45c8fc320f2ea89375bda440f824636cc393d4615",
"tier": "standard", "tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
+9
View File
@@ -276,6 +276,15 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
} }
location ~ ^/operations(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location ~ ^/plugins(/.*)?$ { location ~ ^/plugins(/.*)?$ {
proxy_pass http://127.0.0.1:30001; proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1; proxy_http_version 1.1;
+9
View File
@@ -245,6 +245,15 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
} }
location ~ ^/operations(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location ~ ^/plugins(/.*)?$ { location ~ ^/plugins(/.*)?$ {
proxy_pass http://127.0.0.1:30001; proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1; proxy_http_version 1.1;
+70
View File
@@ -0,0 +1,70 @@
CREATE TABLE `operation_approvals` (
`id` varchar(255) NOT NULL,
`run_id` varchar(255) NOT NULL,
`requested_by` text,
`reason` text,
`allow_self` boolean NOT NULL DEFAULT false,
`status` varchar(255) NOT NULL,
`decided_by` text,
`comment` text,
`created_at` text NOT NULL,
`expires_at` varchar(255) NOT NULL,
`decided_at` text,
CONSTRAINT `operation_approvals_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `operation_events` (
`id` varchar(255) NOT NULL,
`event_id` text NOT NULL,
`plugin_id` text NOT NULL,
`host_id` int,
`severity` text,
`payload` text,
`run_id` text,
`created_at` varchar(255) NOT NULL,
CONSTRAINT `operation_events_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `operation_runs` (
`id` varchar(255) NOT NULL,
`action_id` text NOT NULL,
`action_version` int NOT NULL DEFAULT 1,
`plugin_id` text NOT NULL,
`plugin_version` text NOT NULL,
`user_id` varchar(255),
`username` text NOT NULL,
`origin` text NOT NULL,
`host_id` int,
`host_name` text,
`batch_id` varchar(255),
`dry_run` boolean NOT NULL DEFAULT false,
`risk` text NOT NULL,
`status` varchar(255) NOT NULL,
`summary` text,
`changed` boolean,
`input` text,
`sealed_input` text,
`output` text,
`undo_input` text,
`error` text,
`error_code` text,
`undo_of` text,
`idempotency_key` text,
`created_at` varchar(255) NOT NULL,
`started_at` text,
`finished_at` text,
CONSTRAINT `operation_runs_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
ALTER TABLE `api_keys` ADD `scopes` text;--> statement-breakpoint
ALTER TABLE `operation_approvals` ADD CONSTRAINT `operation_approvals_run_id_operation_runs_id_fk` FOREIGN KEY (`run_id`) REFERENCES `operation_runs`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `operation_runs` ADD CONSTRAINT `operation_runs_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `idx_operation_approvals_status` ON `operation_approvals` (`status`,`expires_at`);--> statement-breakpoint
CREATE INDEX `idx_operation_approvals_run` ON `operation_approvals` (`run_id`);--> statement-breakpoint
CREATE INDEX `idx_operation_events_created` ON `operation_events` (`created_at`);--> statement-breakpoint
CREATE INDEX `idx_operation_events_host_created` ON `operation_events` (`host_id`,`created_at`);--> statement-breakpoint
CREATE INDEX `idx_operation_runs_created` ON `operation_runs` (`created_at`);--> statement-breakpoint
CREATE INDEX `idx_operation_runs_user_created` ON `operation_runs` (`user_id`,`created_at`);--> statement-breakpoint
CREATE INDEX `idx_operation_runs_host_created` ON `operation_runs` (`host_id`,`created_at`);--> statement-breakpoint
CREATE INDEX `idx_operation_runs_status` ON `operation_runs` (`status`);--> statement-breakpoint
CREATE INDEX `idx_operation_runs_batch` ON `operation_runs` (`batch_id`);
File diff suppressed because it is too large. Load diff
+7
View File
@@ -547,6 +547,13 @@
"when": 1793304929772, "when": 1793304929772,
"tag": "0077_shared_folder_auth_overrides", "tag": "0077_shared_folder_auth_overrides",
"breakpoints": true "breakpoints": true
},
{
"idx": 78,
"version": "5",
"when": 1793765383557,
"tag": "0078_sweet_king_cobra",
"breakpoints": true
} }
] ]
} }
@@ -0,0 +1,67 @@
CREATE TABLE "operation_approvals" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"run_id" varchar(255) NOT NULL,
"requested_by" text,
"reason" text,
"allow_self" boolean DEFAULT false NOT NULL,
"status" varchar(255) NOT NULL,
"decided_by" text,
"comment" text,
"created_at" text NOT NULL,
"expires_at" varchar(255) NOT NULL,
"decided_at" text
);
--> statement-breakpoint
CREATE TABLE "operation_events" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"event_id" text NOT NULL,
"plugin_id" text NOT NULL,
"host_id" integer,
"severity" text,
"payload" text,
"run_id" text,
"created_at" varchar(255) NOT NULL
);
--> statement-breakpoint
CREATE TABLE "operation_runs" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"action_id" text NOT NULL,
"action_version" integer DEFAULT 1 NOT NULL,
"plugin_id" text NOT NULL,
"plugin_version" text NOT NULL,
"user_id" varchar(255),
"username" text NOT NULL,
"origin" text NOT NULL,
"host_id" integer,
"host_name" text,
"batch_id" varchar(255),
"dry_run" boolean DEFAULT false NOT NULL,
"risk" text NOT NULL,
"status" varchar(255) NOT NULL,
"summary" text,
"changed" boolean,
"input" text,
"sealed_input" text,
"output" text,
"undo_input" text,
"error" text,
"error_code" text,
"undo_of" text,
"idempotency_key" text,
"created_at" varchar(255) NOT NULL,
"started_at" text,
"finished_at" text
);
--> statement-breakpoint
ALTER TABLE "api_keys" ADD COLUMN "scopes" text;--> statement-breakpoint
ALTER TABLE "operation_approvals" ADD CONSTRAINT "operation_approvals_run_id_operation_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."operation_runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "operation_runs" ADD CONSTRAINT "operation_runs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_operation_approvals_status" ON "operation_approvals" USING btree ("status","expires_at");--> statement-breakpoint
CREATE INDEX "idx_operation_approvals_run" ON "operation_approvals" USING btree ("run_id");--> statement-breakpoint
CREATE INDEX "idx_operation_events_created" ON "operation_events" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX "idx_operation_events_host_created" ON "operation_events" USING btree ("host_id","created_at");--> statement-breakpoint
CREATE INDEX "idx_operation_runs_created" ON "operation_runs" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX "idx_operation_runs_user_created" ON "operation_runs" USING btree ("user_id","created_at");--> statement-breakpoint
CREATE INDEX "idx_operation_runs_host_created" ON "operation_runs" USING btree ("host_id","created_at");--> statement-breakpoint
CREATE INDEX "idx_operation_runs_status" ON "operation_runs" USING btree ("status");--> statement-breakpoint
CREATE INDEX "idx_operation_runs_batch" ON "operation_runs" USING btree ("batch_id");
File diff suppressed because it is too large. Load diff
+7
View File
@@ -547,6 +547,13 @@
"when": 1793304928399, "when": 1793304928399,
"tag": "0077_shared_folder_auth_overrides", "tag": "0077_shared_folder_auth_overrides",
"breakpoints": true "breakpoints": true
},
{
"idx": 78,
"version": "7",
"when": 1793765382955,
"tag": "0078_clean_living_mummy",
"breakpoints": true
} }
] ]
} }
@@ -0,0 +1,67 @@
CREATE TABLE `operation_approvals` (
`id` text PRIMARY KEY NOT NULL,
`run_id` text NOT NULL,
`requested_by` text,
`reason` text,
`allow_self` integer DEFAULT false NOT NULL,
`status` text NOT NULL,
`decided_by` text,
`comment` text,
`created_at` text NOT NULL,
`expires_at` text NOT NULL,
`decided_at` text,
FOREIGN KEY (`run_id`) REFERENCES `operation_runs`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `idx_operation_approvals_status` ON `operation_approvals` (`status`,`expires_at`);--> statement-breakpoint
CREATE INDEX `idx_operation_approvals_run` ON `operation_approvals` (`run_id`);--> statement-breakpoint
CREATE TABLE `operation_events` (
`id` text PRIMARY KEY NOT NULL,
`event_id` text NOT NULL,
`plugin_id` text NOT NULL,
`host_id` integer,
`severity` text,
`payload` text,
`run_id` text,
`created_at` text NOT NULL
);
--> statement-breakpoint
CREATE INDEX `idx_operation_events_created` ON `operation_events` (`created_at`);--> statement-breakpoint
CREATE INDEX `idx_operation_events_host_created` ON `operation_events` (`host_id`,`created_at`);--> statement-breakpoint
CREATE TABLE `operation_runs` (
`id` text PRIMARY KEY NOT NULL,
`action_id` text NOT NULL,
`action_version` integer DEFAULT 1 NOT NULL,
`plugin_id` text NOT NULL,
`plugin_version` text NOT NULL,
`user_id` text,
`username` text NOT NULL,
`origin` text NOT NULL,
`host_id` integer,
`host_name` text,
`batch_id` text,
`dry_run` integer DEFAULT false NOT NULL,
`risk` text NOT NULL,
`status` text NOT NULL,
`summary` text,
`changed` integer,
`input` text,
`sealed_input` text,
`output` text,
`undo_input` text,
`error` text,
`error_code` text,
`undo_of` text,
`idempotency_key` text,
`created_at` text NOT NULL,
`started_at` text,
`finished_at` text,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE INDEX `idx_operation_runs_created` ON `operation_runs` (`created_at`);--> statement-breakpoint
CREATE INDEX `idx_operation_runs_user_created` ON `operation_runs` (`user_id`,`created_at`);--> statement-breakpoint
CREATE INDEX `idx_operation_runs_host_created` ON `operation_runs` (`host_id`,`created_at`);--> statement-breakpoint
CREATE INDEX `idx_operation_runs_status` ON `operation_runs` (`status`);--> statement-breakpoint
CREATE INDEX `idx_operation_runs_batch` ON `operation_runs` (`batch_id`);--> statement-breakpoint
ALTER TABLE `api_keys` ADD `scopes` text;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -512,6 +512,13 @@
"when": 1793304926942, "when": 1793304926942,
"tag": "0072_shared_folder_auth_overrides", "tag": "0072_shared_folder_auth_overrides",
"breakpoints": true "breakpoints": true
},
{
"idx": 73,
"version": "6",
"when": 1793765382283,
"tag": "0073_dazzling_amazoness",
"breakpoints": true
} }
] ]
} }
+14 -7
View File
@@ -1235,13 +1235,7 @@ function createTray() {
// use the unpacked path so the OS sees a real file. // use the unpacked path so the OS sees a real file.
const publicRoot = isDev const publicRoot = isDev
? path.join(appRoot, "public") ? path.join(appRoot, "public")
: path.join( : path.join(getUnpackedAppRoot(appRoot), "public");
appRoot.replace(
/app(-[a-z0-9]+)?\.asar(?!\.unpacked)/,
"app.asar.unpacked",
),
"public",
);
let trayIcon; let trayIcon;
if (process.platform === "darwin") { if (process.platform === "darwin") {
@@ -2027,6 +2021,19 @@ function createMenu() {
{ type: "separator" }, { type: "separator" },
{ role: "front" }, { role: "front" },
{ type: "separator" }, { type: "separator" },
{
label: app.name,
accelerator: "CmdOrCtrl+0",
click: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
} else {
createWindow();
}
},
},
{ role: "window" }, { role: "window" },
], ],
}, },
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "termix", "name": "termix",
"version": "26.10.6", "version": "26.11.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "termix", "name": "termix",
"version": "26.10.6", "version": "26.11.0",
"hasInstallScript": true, "hasInstallScript": true,
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
+1 -1
View File
@@ -4,7 +4,7 @@
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
], ],
"version": "26.10.6", "version": "26.11.0",
"description": "Self-hosted, plugin-based server management.", "description": "Self-hosted, plugin-based server management.",
"author": "Karmaa", "author": "Karmaa",
"main": "electron/main.cjs", "main": "electron/main.cjs",
+11
View File
@@ -1,5 +1,16 @@
# Changelog # Changelog
## 1.0.23
Plugin API 1.14.0.
### Added
- `contributes.operations` declares a plugin's actions, events, resources and signals, so Automations, Fleets, the assistant, the CLI, the phone app and the command palette can find them with no wiring
- `ctx.operations` registers handlers, runs other plugins' declared actions, emits events and provides approval rules, with the new `operations:provide`, `operations:invoke` and `operations:approve` capabilities
- `@termix-ssh/plugin-sdk/operations` has the types, input checks, redaction and JSON Schema for declared actions
- `contributes.hostSections` declares cards for the host page
## 1.0.22 ## 1.0.22
Plugin API 1.13.0. Plugin API 1.13.0.
+69 -1
View File
@@ -1,6 +1,6 @@
# Termix plugin guide # Termix plugin guide
SDK 1.0.22 | Plugin API 1.13.0 SDK 1.0.23 | Plugin API 1.14.0
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed. `@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
@@ -414,6 +414,70 @@ ctx.schedule.every(60_000, () => refresh(), { jitterMs: 5_000 });
- Use a service: declare it in `requires`, then `ctx.services.get<T>("snippets.access")`. It returns - Use a service: declare it in `requires`, then `ctx.services.get<T>("snippets.access")`. It returns
nothing while no provider runs, so always handle that. nothing while no provider runs, so always handle that.
### Operations
Declare what your plugin can do in `contributes.operations`, and it shows up in Automations, Fleets, the
assistant, the CLI, the phone app and the command palette with no other wiring. It also gets per host results,
dry runs, run history, approvals and drift checks.
```json
"capabilities": ["operations:provide"],
"engine": { "termix": ">=26.11.1-0", "api": "^1.13" },
"contributes": {
"operations": {
"actions": [{
"id": "demo.restart",
"titleKey": "ops.restart.title",
"scope": "host",
"inputs": [{ "key": "name", "type": "string", "labelKey": "ops.restart.name", "required": true }],
"risk": "change",
"permission": "use",
"hostAccess": "edit",
"dryRun": "describe"
}]
}
}
```
```ts
ctx.operations.register("demo.restart", {
describe: (input) => `Would restart ${input.name}`,
run: async (input, { hostId, signal, log }) => {
return { summary: `Restarted ${input.name}`, changed: true };
},
});
```
- Actions: `scope` is `host`, `resource` or `server`. `risk` is `read`, `change` or `destructive`. Host actions declare
`hostAccess`. Use `edit` for changes unless people with `connect` already do this today. A destructive action
must use `edit` or `manage` and needs a confirm.
- Inputs use the settings field types, plus `required`, `pattern`, `optionsFrom` (a read action fills the choices)
and type `resource`. Keys you did not declare are refused before your handler runs. `secret` values reach the handler
but are never stored or shown. Mark private outputs `sensitive`.
- `dryRun`: `native` calls `run` with `dryRun: true` and nothing may change. `describe` calls `describe` instead.
- `check` names a read action that proves the result, used for drift. `undo` names the reverse action, and `run` returns
`undoInput`. `idempotent` lets a rollout retry it. `exclusive` allows one at a time per host. `when` limits it to
hosts by setting or OS.
- `surfaces`: the assistant and MCP are off until you set them true. Only do that for actions safe for a model to
ask for. The rest are on unless you set them false.
- Resources describe a kind of thing, such as a container: a read action on one host returns `{ rows }` as `data`, and
core draws the All hosts table and host page section. Attach actions with `scope: "resource"`.
- Signals are status tiles: a read action returns `{ value, status, label }` as `data`, with status `ok`, `warn`, `fail` or
`unknown`. `fix` names the action offered when it is not ok. Keep these reads cheap, they run for every host.
- Events: declare them, then call `ctx.operations.emit(id, payload, { hostId })`.
- Handlers must watch `signal` and stop when it fires. Throw on failure; a failed run never counts against your
plugin's error budget. Throw an error with `code = "NEEDS_INTERACTION"` when a host needs a person to sign in first.
- Handlers have no request and no session. Take what you need from the input and `hostId`.
- Keep your REST routes. Inside one, call `ctx.operations.run(id, { hostId, input })` so a button press goes through
the same checks and lands in the same history.
- To run another plugin's action, add `operations:invoke`, list it in `operations.invokes` and call
`ctx.operations.invoke(id, { hostId, input, via: "automation" })`. It acts as the current user and rules still apply.
- `risk` is your plugin's claim. Plugins run in-process, so this is not a sandbox. Actions from plugins not signed as
official count as `change` when rules decide what needs a person, and stay off the assistant and MCP until an admin
allows them.
- Set `engine.termix` to at least `>=26.11.1-0`. Keep `engine.api` as it was unless you import something new from
`/ui`, or the phone app will ask people to update to see your plugin.
### Shared packages ### Shared packages
These come from Termix and are never bundled: `@termix-ssh/plugin-sdk`, `express`, `ssh2`, `ws`, `multer`, These come from Termix and are never bundled: `@termix-ssh/plugin-sdk`, `express`, `ssh2`, `ws`, `multer`,
@@ -962,6 +1026,7 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
| `@termix-ssh/plugin-sdk/db` | `defineTable` and column builders | | `@termix-ssh/plugin-sdk/db` | `defineTable` and column builders |
| `@termix-ssh/plugin-sdk/manifest` | `PluginManifest` type and manifest checks | | `@termix-ssh/plugin-sdk/manifest` | `PluginManifest` type and manifest checks |
| `@termix-ssh/plugin-sdk/capabilities` | The capability catalog | | `@termix-ssh/plugin-sdk/capabilities` | The capability catalog |
| `@termix-ssh/plugin-sdk/operations` | Types, input checks and JSON Schema for declared actions |
| `@termix-ssh/plugin-sdk/settings` | Settings field validation | | `@termix-ssh/plugin-sdk/settings` | Settings field validation |
| `@termix-ssh/plugin-sdk/testing` | `createMockCtx`, `createTestDb`, `renderWithApp`, `createFakeContext` | | `@termix-ssh/plugin-sdk/testing` | `createMockCtx`, `createTestDb`, `renderWithApp`, `createFakeContext` |
| `@termix-ssh/plugin-sdk/vitest-preset` | `pluginVitestConfig` | | `@termix-ssh/plugin-sdk/vitest-preset` | `pluginVitestConfig` |
@@ -993,6 +1058,8 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
| `desktop:local` | high | Use the user's own computer, desktop app only | | `desktop:local` | high | Use the user's own computer, desktop app only |
| `hosts:export` | high | Export hosts with their saved logins | | `hosts:export` | high | Export hosts with their saved logins |
| `notify:hub` | high | Receive every alert (the alerts hub only) | | `notify:hub` | high | Receive every alert (the alerts hub only) |
| `operations:invoke` | high | Run other plugins' declared actions, listed in `invokes` |
| `operations:approve` | high | Decide which actions need a person |
| `ui:layout` | high | Replace the whole app layout | | `ui:layout` | high | Replace the whole app layout |
| `hosts:write` | medium | Add and edit hosts | | `hosts:write` | medium | Add and edit hosts |
| `hosts:status-provide` | medium | Be the source of host status dots | | `hosts:status-provide` | medium | Be the source of host status dots |
@@ -1008,6 +1075,7 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
| `users:read` | medium | Usernames, roles and UI language | | `users:read` | medium | Usernames, roles and UI language |
| `events:core` | medium | Listen to core events | | `events:core` | medium | Listen to core events |
| `notify:send` | medium | Send alerts | | `notify:send` | medium | Send alerts |
| `operations:provide` | medium | Offer declared actions and events |
| `audit:read` | medium | Read the audit log | | `audit:read` | medium | Read the audit log |
| `desktop:window` | medium | Open windows in the desktop app | | `desktop:window` | medium | Open windows in the desktop app |
| `hosts:read` | low | Read hosts the user can see | | `hosts:read` | low | Read hosts the user can see |
+5 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@termix-ssh/plugin-sdk", "name": "@termix-ssh/plugin-sdk",
"version": "1.0.22", "version": "1.0.23",
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.", "description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
"license": "Apache-2.0", "license": "Apache-2.0",
"repository": { "repository": {
@@ -61,6 +61,10 @@
"types": "./dist/openapi.d.ts", "types": "./dist/openapi.d.ts",
"default": "./dist/openapi.js" "default": "./dist/openapi.js"
}, },
"./operations": {
"types": "./dist/operations.d.ts",
"default": "./dist/operations.js"
},
"./settings": { "./settings": {
"types": "./dist/settings.d.ts", "types": "./dist/settings.d.ts",
"default": "./dist/settings.js" "default": "./dist/settings.js"
@@ -536,6 +536,31 @@
} }
} }
}, },
"hostSections": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "titleKey"],
"properties": {
"id": { "type": "string", "minLength": 1 },
"titleKey": { "type": "string", "minLength": 1 },
"icon": { "type": "string", "minLength": 1 }
}
}
},
"operations": {
"type": "object",
"description": "What this plugin can do. Every surface finds these on its own. See the operations section of PLUGIN-GUIDE.md.",
"additionalProperties": false,
"properties": {
"actions": { "type": "array", "items": { "type": "object" } },
"events": { "type": "array", "items": { "type": "object" } },
"resources": { "type": "array", "items": { "type": "object" } },
"signals": { "type": "array", "items": { "type": "object" } },
"invokes": { "type": "array", "items": { "type": "string" } }
}
},
"dashboardCards": { "dashboardCards": {
"type": "array", "type": "array",
"items": { "items": {
+137
View File
@@ -14,6 +14,11 @@ import type { HostSshOptions } from "./ssh-options.js";
export type { HostSshOptions } from "./ssh-options.js"; export type { HostSshOptions } from "./ssh-options.js";
import type { PluginManifest, PluginSettingsField } from "./manifest.js"; import type { PluginManifest, PluginSettingsField } from "./manifest.js";
import type { PluginTableDefinition } from "./db.js"; import type { PluginTableDefinition } from "./db.js";
import type {
OperationResult,
OperationRisk,
OperationSurfaces,
} from "./operations.js";
/** /**
* Thrown when a plugin calls a guarded member without the capability. * Thrown when a plugin calls a guarded member without the capability.
@@ -3104,6 +3109,136 @@ export interface PluginPlugins {
list: () => Promise<PluginSummary[]>; list: () => Promise<PluginSummary[]>;
} }
/** What a handler gets besides its input. */
export interface PluginOperationContext {
hostId?: number;
/** The row a resource action targets. */
resourceKey?: string;
/** Null for a background read. */
runId: string | null;
/** Nothing may change when this is set. */
dryRun: boolean;
/** Fires when the run is cancelled, times out or the plugin stops. */
signal: AbortSignal;
log: (line: string) => void;
}
export interface PluginOperationHandler {
run: (
input: Record<string, unknown>,
context: PluginOperationContext,
) => Promise<OperationResult> | OperationResult;
/** The dry run text for an action declared with dryRun "describe". */
describe?: (
input: Record<string, unknown>,
context: PluginOperationContext,
) => string | Promise<string>;
}
export interface PluginOperationInvokeOptions {
hostId?: number;
resourceKey?: string;
input?: Record<string, unknown>;
dryRun?: boolean;
/** A person agreed to a destructive action. */
confirmed?: boolean;
idempotencyKey?: string;
batchId?: string;
/** What this plugin is doing: automation, assistant, mcp, rollout and so on. */
via?: string;
}
export interface PluginOperationOutcome {
runId: string | null;
status: string;
summary?: string;
changed?: boolean;
data?: unknown;
error?: string;
errorCode?: string;
approvalId?: string;
}
export interface PluginOperationApprovalInput {
actionId: string;
pluginId: string;
risk: OperationRisk;
/** What rules should use. Plugins not signed as official count as change. */
effectiveRisk: OperationRisk;
origin: { kind: string; pluginId?: string; via?: string };
userId: string;
hostId?: number;
hostTags?: string[];
batchId?: string;
}
export interface PluginOperationApprovalDecision {
decision: "allow" | "approve" | "deny";
reason?: string;
allowSelf?: boolean;
expiresInMinutes?: number;
}
export interface PluginOperationEvent {
id: string;
eventId: string;
pluginId: string;
hostId?: number;
severity?: string;
payload?: unknown;
runId?: string;
depth: number;
at: string;
}
/**
* Declared actions and events. See contributes.operations in the manifest.
* register and emit need operations:provide. invoke, list and onEvent need
* operations:invoke, and invoke only reaches ids in the manifest's invokes list.
*/
export interface PluginOperations {
register: (id: string, handler: PluginOperationHandler) => void;
/**
* Runs one of this plugin's own actions from its route, so a button press
* goes through the same checks and lands in the same history.
*/
run: (
actionId: string,
options?: PluginOperationInvokeOptions,
) => Promise<PluginOperationOutcome>;
invoke: (
actionId: string,
options?: PluginOperationInvokeOptions,
) => Promise<PluginOperationOutcome>;
list: (options?: {
lang?: string;
surface?: keyof OperationSurfaces;
}) => Promise<unknown>;
emit: (
eventId: string,
payload?: unknown,
options?: { hostId?: number },
) => Promise<void>;
onEvent: (subscription: {
pattern: string;
handler: (event: PluginOperationEvent) => void | Promise<void>;
userId?: string;
cooldownMs?: number;
}) => () => void;
/** Rules for what needs a person. Needs operations:approve. */
approvals: {
provide: (
provider: (
input: PluginOperationApprovalInput,
) =>
| PluginOperationApprovalDecision
| null
| undefined
| Promise<PluginOperationApprovalDecision | null | undefined>,
) => () => void;
};
}
export interface PluginContext { export interface PluginContext {
readonly pluginId: string; readonly pluginId: string;
readonly manifest: PluginManifest; readonly manifest: PluginManifest;
@@ -3144,6 +3279,8 @@ export interface PluginContext {
readonly schedule: PluginSchedule; readonly schedule: PluginSchedule;
/** Core's notification channels. Needs notify:send. */ /** Core's notification channels. Needs notify:send. */
readonly notify: PluginNotify; readonly notify: PluginNotify;
/** Declared actions and events, reachable from every surface. */
readonly operations: PluginOperations;
/** Outbound HTTP through core's SSRF guard. Needs network:outbound. */ /** Outbound HTTP through core's SSRF guard. Needs network:outbound. */
readonly fetch: PluginFetch; readonly fetch: PluginFetch;
/** Plain reachability checks. Needs network:probe. */ /** Plain reachability checks. Needs network:probe. */
+3
View File
@@ -52,6 +52,8 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
entry("ssh:transport", "high"), entry("ssh:transport", "high"),
entry("device:serial", "high"), entry("device:serial", "high"),
entry("notify:hub", "high"), entry("notify:hub", "high"),
entry("operations:invoke", "high"),
entry("operations:approve", "high"),
entry("ui:layout", "high"), entry("ui:layout", "high"),
entry("hosts:write", "medium"), entry("hosts:write", "medium"),
@@ -68,6 +70,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
entry("users:read", "medium"), entry("users:read", "medium"),
entry("events:core", "medium"), entry("events:core", "medium"),
entry("notify:send", "medium"), entry("notify:send", "medium"),
entry("operations:provide", "medium"),
entry("audit:read", "medium"), entry("audit:read", "medium"),
entry("desktop:window", "medium"), entry("desktop:window", "medium"),
entry("desktop:local", "high"), entry("desktop:local", "high"),
+20 -1
View File
@@ -12,6 +12,11 @@
import semver from "semver"; import semver from "semver";
import { isKnownCapability } from "./capabilities.js"; import { isKnownCapability } from "./capabilities.js";
import {
checkOperationReferences,
validateOperations,
type PluginOperationsContribution,
} from "./operations.js";
import { import {
ENV_NAME_PATTERN, ENV_NAME_PATTERN,
MAX_ENV_DESCRIPTION_LENGTH, MAX_ENV_DESCRIPTION_LENGTH,
@@ -37,7 +42,7 @@ export {
* "^1.2" needs 1.2 or later). engine.termix is checked against the core * "^1.2" needs 1.2 or later). engine.termix is checked against the core
* version by the server. * version by the server.
*/ */
export const PLUGIN_API_VERSION = "1.13.0"; export const PLUGIN_API_VERSION = "1.14.0";
/** The API major, the default engine.api for a new plugin. */ /** The API major, the default engine.api for a new plugin. */
export const SUPPORTED_PLUGIN_API_VERSION = String( export const SUPPORTED_PLUGIN_API_VERSION = String(
@@ -465,6 +470,10 @@ export interface PluginContributions {
tabs?: PluginTabContribution[]; tabs?: PluginTabContribution[];
panels?: PluginViewContribution[]; panels?: PluginViewContribution[];
dashboardCards?: PluginViewContribution[]; dashboardCards?: PluginViewContribution[];
/** Cards on the host page, filled with app.registerHostSection. */
hostSections?: PluginViewContribution[];
/** What this plugin can do, for every surface to find on its own. */
operations?: PluginOperationsContribution;
/** /**
* The frontend also runs on anonymous guest pages (shared-session and * The frontend also runs on anonymous guest pages (shared-session and
* collab links), where it is activated with app.guest set. * collab links), where it is activated with app.guest set.
@@ -779,6 +788,8 @@ const ALLOWED_CONTRIBUTES = new Set([
"tabs", "tabs",
"panels", "panels",
"dashboardCards", "dashboardCards",
"hostSections",
"operations",
"guest", "guest",
"guestViews", "guestViews",
"actions", "actions",
@@ -1500,6 +1511,8 @@ function validateContributes(
validateTabs(contributes.tabs, errors); validateTabs(contributes.tabs, errors);
validateViews(contributes.panels, "panels", errors); validateViews(contributes.panels, "panels", errors);
validateViews(contributes.dashboardCards, "dashboardCards", errors); validateViews(contributes.dashboardCards, "dashboardCards", errors);
validateViews(contributes.hostSections, "hostSections", errors);
validateOperations(contributes.operations, pluginId, errors);
if ( if (
contributes.guest !== undefined && contributes.guest !== undefined &&
typeof contributes.guest !== "boolean" typeof contributes.guest !== "boolean"
@@ -2622,6 +2635,12 @@ export function parseManifest(raw: unknown): ParsedManifest {
); );
} }
} }
checkOperationReferences(
manifest.contributes?.operations,
manifest.capabilities,
(name) => declared.has(qualifyPermission(manifest.id, name)),
errors,
);
// A settings field may gate on one of this plugin's own permissions, or on // A settings field may gate on one of this plugin's own permissions, or on
// a core/other-plugin id used as given. Only the first form is checkable // a core/other-plugin id used as given. Only the first form is checkable
// here, and an undeclared one would hide the field from every admin. // here, and an undeclared one would hide the field from every admin.
+951
View File
@@ -0,0 +1,951 @@
/**
* The operations contract: what a plugin declares it can do, in one shape every
* surface reads (Automations, Fleets, AI, CLI, Mobile, palette, API, MCP).
*
* Declarations are data in the manifest, so a surface can list and describe an
* action without loading the plugin's code. Handlers are registered at
* activation with ctx.operations.register and must match a declaration.
*
* No Node imports: the frontend, the CLI and Mobile read this file too.
*/
import { coerceSettingValue, validateSettingValue } from "./settings.js";
import type { PluginSettingsField, PluginSettingsOption } from "./manifest.js";
export const OPERATION_RISKS = ["read", "change", "destructive"] as const;
export type OperationRisk = (typeof OPERATION_RISKS)[number];
export const OPERATION_SCOPES = ["host", "resource", "server"] as const;
export type OperationScope = (typeof OPERATION_SCOPES)[number];
export const OPERATION_HOST_ACCESS = [
"connect",
"view",
"edit",
"manage",
] as const;
export type OperationHostAccess = (typeof OPERATION_HOST_ACCESS)[number];
export const OPERATION_DRY_RUN = ["native", "describe"] as const;
/** native: the handler checks ctx.dryRun. describe: core reports its text. */
export type OperationDryRun = (typeof OPERATION_DRY_RUN)[number];
export const OPERATION_FIELD_TYPES = [
"boolean",
"string",
"number",
"select",
"multiselect",
"secret",
"textarea",
"json",
"resource",
] as const;
export type OperationFieldType = (typeof OPERATION_FIELD_TYPES)[number];
export const OPERATION_SEVERITIES = ["info", "warning", "critical"] as const;
export type OperationSeverity = (typeof OPERATION_SEVERITIES)[number];
export const SIGNAL_KINDS = ["count", "gauge", "status", "list"] as const;
export type SignalKind = (typeof SIGNAL_KINDS)[number];
export const SIGNAL_STATUSES = ["ok", "warn", "fail", "unknown"] as const;
export type SignalStatus = (typeof SIGNAL_STATUSES)[number];
export const RESOURCE_COLUMN_TYPES = [
"text",
"status",
"bytes",
"percent",
"time",
"tags",
] as const;
export type ResourceColumnType = (typeof RESOURCE_COLUMN_TYPES)[number];
/** One input or output of an action. A subset of a settings field, plus rules. */
export interface OperationField {
key: string;
type: OperationFieldType;
labelKey?: string;
descriptionKey?: string;
placeholderKey?: string;
default?: unknown;
options?: PluginSettingsOption[];
min?: number;
max?: number;
/** Key of a boolean field in the same list that must be on. */
requires?: string;
group?: string;
required?: boolean;
/** string and textarea only. A regular expression the whole value matches. */
pattern?: string;
/** Read action whose rows fill the choices, for a list known only at run time. */
optionsFrom?: string;
/** type "resource" only: the resource id whose list fills the picker. */
resource?: string;
/** Outputs only: dropped before a run is stored or shown. */
sensitive?: boolean;
}
export interface OperationWhen {
/** Host setting values that must match, such as { enableDocker: true }. */
settings?: Record<string, boolean | string | number>;
/** Lowercase OS names. A host with no known OS passes. */
os?: string[];
}
export interface OperationSurfaces {
/** Offered to the AI assistant. Off unless set. */
assistant?: boolean;
/** Offered over MCP. Off unless set. */
mcp?: boolean;
/** Set false to keep out of the command palette. */
palette?: boolean;
/** Set false to keep out of Automations. */
automations?: boolean;
/** Set false to keep out of the phone app. */
mobile?: boolean;
/** Set false to keep out of the CLI. */
cli?: boolean;
}
export interface OperationAction {
/** `<pluginId>.<name>`. */
id: string;
/** Bump when inputs change in a way saved references cannot follow. */
version?: number;
titleKey: string;
descriptionKey?: string;
icon?: string;
scope: OperationScope;
/** Required for scope "resource": the resource this acts on. */
resource?: string;
inputs?: OperationField[];
outputs?: OperationField[];
risk: OperationRisk;
/** One of this plugin's own permissions, short or full. */
permission: string;
/** Lowest share level. Required for host and resource scopes. */
hostAccess?: OperationHostAccess;
dryRun?: OperationDryRun;
/** A read action of this plugin that proves the result. */
check?: string;
/** An action of this plugin that reverses this one. */
undo?: string;
/** Safe to run twice, so a rollout may retry it. */
idempotent?: boolean;
/** Only one such action per host at a time. */
exclusive?: boolean;
timeoutMs?: number;
when?: OperationWhen;
surfaces?: OperationSurfaces;
}
export interface OperationEvent {
id: string;
titleKey: string;
descriptionKey?: string;
severity?: OperationSeverity;
scope: "host" | "server";
payload?: OperationField[];
}
export interface ResourceColumn {
key: string;
titleKey: string;
type?: ResourceColumnType;
}
export interface OperationResource {
id: string;
titleKey: string;
icon?: string;
/** Read action that returns { rows: [...] } for one host. */
list: string;
/** Row field that is unique on a host. */
key: string;
/** Row field shown as the name. */
name: string;
/** Row field holding a status word. */
status?: string;
/** Row field holding a link, for auto links on the homepage. */
url?: string;
/** Tab type that opens this on a host. */
tab?: string;
columns?: ResourceColumn[];
filters?: string[];
}
export interface OperationSignal {
id: string;
titleKey: string;
/** Read action that returns a SignalResult. */
read: string;
scope: "host" | "server";
kind: SignalKind;
refreshSec?: number;
/** Action offered as the fix when this signal is not ok. */
fix?: string;
}
export interface PluginOperationsContribution {
actions?: OperationAction[];
events?: OperationEvent[];
resources?: OperationResource[];
signals?: OperationSignal[];
/**
* Actions this plugin may run through ctx.operations.invoke: exact ids,
* `<pluginId>.*`, or `*`.
*/
invokes?: string[];
}
/** What a signal's read action returns. */
export interface SignalResult {
value?: number | string | null;
status: SignalStatus;
label?: string;
items?: { label: string; status?: SignalStatus }[];
link?: { tab?: string; data?: Record<string, unknown> };
}
/** What a resource's list action returns. */
export interface ResourceListResult {
rows: Record<string, unknown>[];
}
/** What a handler returns. */
export interface OperationResult {
/** One plain line for history and per host results. */
summary: string;
/** Whether anything actually changed. */
changed?: boolean;
data?: unknown;
/** Input for the `undo` action that reverses this exact run. */
undoInput?: Record<string, unknown>;
}
export const OPERATION_ID_PATTERN = /^[a-z0-9][a-z0-9-]*\.[a-z0-9][a-z0-9-]*$/;
const FIELD_KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
const INVOKE_PATTERN = /^(\*|[a-z0-9][a-z0-9-]*\.(\*|[a-z0-9][a-z0-9-]*))$/;
export const MAX_OPERATION_TIMEOUT_MS = 3_600_000;
export const REDACTED_VALUE = "[redacted]";
const ALLOWED_OPERATIONS = [
"actions",
"events",
"resources",
"signals",
"invokes",
];
const ALLOWED_ACTION = [
"id",
"version",
"titleKey",
"descriptionKey",
"icon",
"scope",
"resource",
"inputs",
"outputs",
"risk",
"permission",
"hostAccess",
"dryRun",
"check",
"undo",
"idempotent",
"exclusive",
"timeoutMs",
"when",
"surfaces",
];
const ALLOWED_FIELD = [
"key",
"type",
"labelKey",
"descriptionKey",
"placeholderKey",
"default",
"options",
"min",
"max",
"requires",
"group",
"required",
"pattern",
"optionsFrom",
"resource",
"sensitive",
];
const ALLOWED_EVENT = [
"id",
"titleKey",
"descriptionKey",
"severity",
"scope",
"payload",
];
const ALLOWED_RESOURCE = [
"id",
"titleKey",
"icon",
"list",
"key",
"name",
"status",
"url",
"tab",
"columns",
"filters",
];
const ALLOWED_SIGNAL = [
"id",
"titleKey",
"read",
"scope",
"kind",
"refreshSec",
"fix",
];
const ALLOWED_SURFACES = [
"assistant",
"mcp",
"palette",
"automations",
"mobile",
"cli",
];
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function rejectUnknown(
raw: Record<string, unknown>,
allowed: string[],
where: string,
errors: string[],
): void {
for (const key of Object.keys(raw)) {
if (!allowed.includes(key))
errors.push(`Unknown field "${key}" in ${where}`);
}
}
function requireEnum(
value: unknown,
allowed: readonly string[],
where: string,
errors: string[],
): void {
if (typeof value !== "string" || !allowed.includes(value)) {
errors.push(`${where} must be one of: ${allowed.join(", ")}`);
}
}
function requireText(value: unknown, where: string, errors: string[]): void {
if (typeof value !== "string" || value.trim().length === 0) {
errors.push(`${where} must be a non-empty string`);
}
}
function optionalText(value: unknown, where: string, errors: string[]): void {
if (value !== undefined) requireText(value, where, errors);
}
function optionalBoolean(
value: unknown,
where: string,
errors: string[],
): void {
if (value !== undefined && typeof value !== "boolean") {
errors.push(`${where} must be a boolean`);
}
}
function requireId(
value: unknown,
pluginId: string | undefined,
where: string,
seen: Set<string>,
errors: string[],
): void {
if (typeof value !== "string" || !OPERATION_ID_PATTERN.test(value)) {
errors.push(`${where}.id must look like "<pluginId>.<name>" in lowercase`);
return;
}
if (pluginId && !value.startsWith(`${pluginId}.`)) {
errors.push(`${where}.id must start with "${pluginId}."`);
}
if (seen.has(value)) errors.push(`${where}.id "${value}" is declared twice`);
seen.add(value);
}
function eachObject(
value: unknown,
where: string,
errors: string[],
each: (raw: Record<string, unknown>, at: string) => void,
): void {
if (value === undefined) return;
if (!Array.isArray(value)) {
errors.push(`${where} must be an array`);
return;
}
value.forEach((raw, index) => {
const at = `${where}[${index}]`;
if (!isObject(raw)) {
errors.push(`${at} must be an object`);
return;
}
each(raw, at);
});
}
function validateFields(value: unknown, where: string, errors: string[]): void {
const keys = new Set<string>();
eachObject(value, where, errors, (raw, at) => {
rejectUnknown(raw, ALLOWED_FIELD, at, errors);
if (typeof raw.key !== "string" || !FIELD_KEY_PATTERN.test(raw.key)) {
errors.push(`${at}.key must be a short name starting with a letter`);
} else if (keys.has(raw.key)) {
errors.push(`${at}.key "${raw.key}" is declared twice`);
} else {
keys.add(raw.key);
}
requireEnum(raw.type, OPERATION_FIELD_TYPES, `${at}.type`, errors);
optionalText(raw.labelKey, `${at}.labelKey`, errors);
optionalText(raw.descriptionKey, `${at}.descriptionKey`, errors);
optionalText(raw.placeholderKey, `${at}.placeholderKey`, errors);
optionalText(raw.group, `${at}.group`, errors);
optionalText(raw.requires, `${at}.requires`, errors);
optionalText(raw.optionsFrom, `${at}.optionsFrom`, errors);
optionalBoolean(raw.required, `${at}.required`, errors);
optionalBoolean(raw.sensitive, `${at}.sensitive`, errors);
if (raw.type === "resource" && typeof raw.resource !== "string") {
errors.push(`${at}.resource is required for type "resource"`);
}
if (raw.resource !== undefined && raw.type !== "resource") {
errors.push(`${at}.resource needs type "resource"`);
}
if (raw.type === "secret" && raw.default !== undefined) {
errors.push(`${at}.default is not allowed on a secret`);
}
if (
(raw.type === "select" || raw.type === "multiselect") &&
!raw.optionsFrom &&
(!Array.isArray(raw.options) || raw.options.length === 0)
) {
errors.push(`${at} needs options or optionsFrom`);
}
for (const bound of ["min", "max"] as const) {
if (raw[bound] !== undefined && typeof raw[bound] !== "number") {
errors.push(`${at}.${bound} must be a number`);
}
}
if (raw.pattern !== undefined) {
if (typeof raw.pattern !== "string") {
errors.push(`${at}.pattern must be a string`);
} else {
try {
new RegExp(raw.pattern);
} catch {
errors.push(`${at}.pattern is not a valid regular expression`);
}
}
}
});
}
function validateWhen(value: unknown, at: string, errors: string[]): void {
if (value === undefined) return;
if (!isObject(value)) {
errors.push(`${at} must be an object`);
return;
}
rejectUnknown(value, ["settings", "os"], at, errors);
if (value.settings !== undefined && !isObject(value.settings)) {
errors.push(`${at}.settings must be an object`);
}
if (
value.os !== undefined &&
(!Array.isArray(value.os) ||
value.os.some((entry) => typeof entry !== "string"))
) {
errors.push(`${at}.os must be an array of strings`);
}
}
function validateAction(
raw: Record<string, unknown>,
at: string,
errors: string[],
): void {
rejectUnknown(raw, ALLOWED_ACTION, at, errors);
requireText(raw.titleKey, `${at}.titleKey`, errors);
optionalText(raw.descriptionKey, `${at}.descriptionKey`, errors);
optionalText(raw.icon, `${at}.icon`, errors);
requireEnum(raw.scope, OPERATION_SCOPES, `${at}.scope`, errors);
requireEnum(raw.risk, OPERATION_RISKS, `${at}.risk`, errors);
requireText(raw.permission, `${at}.permission`, errors);
validateFields(raw.inputs, `${at}.inputs`, errors);
validateFields(raw.outputs, `${at}.outputs`, errors);
validateWhen(raw.when, `${at}.when`, errors);
optionalBoolean(raw.idempotent, `${at}.idempotent`, errors);
optionalBoolean(raw.exclusive, `${at}.exclusive`, errors);
if (
raw.version !== undefined &&
(!Number.isInteger(raw.version) || (raw.version as number) < 1)
) {
errors.push(`${at}.version must be a whole number from 1`);
}
if (raw.dryRun !== undefined) {
requireEnum(raw.dryRun, OPERATION_DRY_RUN, `${at}.dryRun`, errors);
}
if (
raw.timeoutMs !== undefined &&
(typeof raw.timeoutMs !== "number" ||
raw.timeoutMs <= 0 ||
raw.timeoutMs > MAX_OPERATION_TIMEOUT_MS)
) {
errors.push(`${at}.timeoutMs must be above 0 and at most 1 hour`);
}
if (raw.scope === "resource" && typeof raw.resource !== "string") {
errors.push(`${at}.resource is required for scope "resource"`);
}
if (raw.scope !== "resource" && raw.resource !== undefined) {
errors.push(`${at}.resource needs scope "resource"`);
}
if (raw.scope === "server" && raw.hostAccess !== undefined) {
errors.push(`${at}.hostAccess does not apply to scope "server"`);
}
if (raw.scope === "host" || raw.scope === "resource") {
requireEnum(
raw.hostAccess,
OPERATION_HOST_ACCESS,
`${at}.hostAccess`,
errors,
);
if (
raw.risk === "destructive" &&
(raw.hostAccess === "connect" || raw.hostAccess === "view")
) {
errors.push(`${at}.hostAccess must be edit or manage when destructive`);
}
}
if (raw.surfaces !== undefined) {
if (!isObject(raw.surfaces)) {
errors.push(`${at}.surfaces must be an object`);
} else {
rejectUnknown(raw.surfaces, ALLOWED_SURFACES, `${at}.surfaces`, errors);
for (const [key, value] of Object.entries(raw.surfaces)) {
optionalBoolean(value, `${at}.surfaces.${key}`, errors);
}
}
}
}
function validateResource(
raw: Record<string, unknown>,
at: string,
errors: string[],
): void {
rejectUnknown(raw, ALLOWED_RESOURCE, at, errors);
requireText(raw.titleKey, `${at}.titleKey`, errors);
requireText(raw.list, `${at}.list`, errors);
requireText(raw.key, `${at}.key`, errors);
requireText(raw.name, `${at}.name`, errors);
optionalText(raw.icon, `${at}.icon`, errors);
optionalText(raw.status, `${at}.status`, errors);
optionalText(raw.url, `${at}.url`, errors);
optionalText(raw.tab, `${at}.tab`, errors);
eachObject(raw.columns, `${at}.columns`, errors, (column, where) => {
rejectUnknown(column, ["key", "titleKey", "type"], where, errors);
requireText(column.key, `${where}.key`, errors);
requireText(column.titleKey, `${where}.titleKey`, errors);
if (column.type !== undefined) {
requireEnum(column.type, RESOURCE_COLUMN_TYPES, `${where}.type`, errors);
}
});
if (
raw.filters !== undefined &&
(!Array.isArray(raw.filters) ||
raw.filters.some((entry) => typeof entry !== "string"))
) {
errors.push(`${at}.filters must be an array of strings`);
}
}
/** Shape checks for contributes.operations, run by validateManifest. */
export function validateOperations(
value: unknown,
pluginId: string | undefined,
errors: string[],
): void {
if (value === undefined) return;
const root = "contributes.operations";
if (!isObject(value)) {
errors.push(`Field "${root}" must be an object`);
return;
}
rejectUnknown(value, ALLOWED_OPERATIONS, `"${root}"`, errors);
const ids = new Set<string>();
eachObject(value.actions, `${root}.actions`, errors, (raw, at) => {
requireId(raw.id, pluginId, at, ids, errors);
validateAction(raw, at, errors);
});
const eventIds = new Set<string>();
eachObject(value.events, `${root}.events`, errors, (raw, at) => {
requireId(raw.id, pluginId, at, eventIds, errors);
rejectUnknown(raw, ALLOWED_EVENT, at, errors);
requireText(raw.titleKey, `${at}.titleKey`, errors);
optionalText(raw.descriptionKey, `${at}.descriptionKey`, errors);
requireEnum(raw.scope, ["host", "server"], `${at}.scope`, errors);
if (raw.severity !== undefined) {
requireEnum(raw.severity, OPERATION_SEVERITIES, `${at}.severity`, errors);
}
validateFields(raw.payload, `${at}.payload`, errors);
});
const resourceIds = new Set<string>();
eachObject(value.resources, `${root}.resources`, errors, (raw, at) => {
requireId(raw.id, pluginId, at, resourceIds, errors);
validateResource(raw, at, errors);
});
const signalIds = new Set<string>();
eachObject(value.signals, `${root}.signals`, errors, (raw, at) => {
requireId(raw.id, pluginId, at, signalIds, errors);
rejectUnknown(raw, ALLOWED_SIGNAL, at, errors);
requireText(raw.titleKey, `${at}.titleKey`, errors);
requireText(raw.read, `${at}.read`, errors);
requireEnum(raw.scope, ["host", "server"], `${at}.scope`, errors);
requireEnum(raw.kind, SIGNAL_KINDS, `${at}.kind`, errors);
optionalText(raw.fix, `${at}.fix`, errors);
if (
raw.refreshSec !== undefined &&
(typeof raw.refreshSec !== "number" || raw.refreshSec < 10)
) {
errors.push(`${at}.refreshSec must be at least 10`);
}
});
if (value.invokes !== undefined) {
if (
!Array.isArray(value.invokes) ||
value.invokes.some(
(entry) => typeof entry !== "string" || !INVOKE_PATTERN.test(entry),
)
) {
errors.push(
`${root}.invokes must list action ids, "<pluginId>.*" or "*"`,
);
}
}
}
/**
* The cross-field rules: every id one declaration names must exist in this
* plugin, or a surface would offer a button that goes nowhere.
*/
export function checkOperationReferences(
operations: PluginOperationsContribution | undefined,
capabilities: readonly string[],
hasPermission: (name: string) => boolean,
errors: string[],
): void {
if (!operations) return;
const root = "contributes.operations";
const actions = new Map(
(operations.actions ?? []).map((action) => [action.id, action]),
);
const resources = new Set((operations.resources ?? []).map((r) => r.id));
const declaresAnything =
actions.size > 0 ||
(operations.events?.length ?? 0) > 0 ||
resources.size > 0 ||
(operations.signals?.length ?? 0) > 0;
if (declaresAnything && !capabilities.includes("operations:provide")) {
errors.push(`${root} needs the "operations:provide" capability`);
}
if (
operations.invokes?.length &&
!capabilities.includes("operations:invoke")
) {
errors.push(`${root}.invokes needs the "operations:invoke" capability`);
}
const readAction = (id: string, where: string) => {
const target = actions.get(id);
if (!target) errors.push(`${where} "${id}" is not a declared action`);
else if (target.risk !== "read") {
errors.push(`${where} "${id}" must be a read action`);
}
};
for (const action of actions.values()) {
const at = `${root}.actions["${action.id}"]`;
if (!hasPermission(action.permission)) {
errors.push(
`${at}.permission "${action.permission}" is not declared in contributes.permissions`,
);
}
if (action.resource && !resources.has(action.resource)) {
errors.push(`${at}.resource "${action.resource}" is not declared`);
}
if (action.check) readAction(action.check, `${at}.check`);
if (action.undo && !actions.has(action.undo)) {
errors.push(`${at}.undo "${action.undo}" is not a declared action`);
}
for (const field of action.inputs ?? []) {
if (field.optionsFrom) {
readAction(field.optionsFrom, `${at} input "${field.key}" optionsFrom`);
}
if (field.resource && !resources.has(field.resource)) {
errors.push(`${at} input "${field.key}" names an unknown resource`);
}
}
}
for (const resource of operations.resources ?? []) {
readAction(resource.list, `${root}.resources["${resource.id}"].list`);
}
for (const signal of operations.signals ?? []) {
const at = `${root}.signals["${signal.id}"]`;
readAction(signal.read, `${at}.read`);
if (signal.fix && !actions.has(signal.fix)) {
errors.push(`${at}.fix "${signal.fix}" is not a declared action`);
}
}
}
/** Whether a plugin's invokes list lets it run an action. */
export function invokeAllowed(
invokes: readonly string[] | undefined,
actionId: string,
): boolean {
for (const entry of invokes ?? []) {
if (entry === "*" || entry === actionId) return true;
if (entry.endsWith(".*") && actionId.startsWith(entry.slice(0, -1))) {
return true;
}
}
return false;
}
const RISK_RANK: Record<OperationRisk, number> = {
read: 0,
change: 1,
destructive: 2,
};
export function riskRank(risk: OperationRisk): number {
return RISK_RANK[risk];
}
/** A surface's own switch. The assistant and MCP are opt in, the rest opt out. */
export function onSurface(
action: Pick<OperationAction, "surfaces">,
surface: keyof OperationSurfaces,
): boolean {
const value = action.surfaces?.[surface];
if (surface === "assistant" || surface === "mcp") return value === true;
return value !== false;
}
/** Whether an action applies to a host, from fields any client can read. */
export function matchesWhen(
when: OperationWhen | undefined,
host: { settings?: Record<string, unknown>; os?: string | null },
): boolean {
if (!when) return true;
for (const [key, expected] of Object.entries(when.settings ?? {})) {
if (host.settings?.[key] !== expected) return false;
}
if (when.os?.length && host.os) {
if (!when.os.includes(host.os.toLowerCase())) return false;
}
return true;
}
function asSettingsField(field: OperationField): PluginSettingsField {
const type = field.type === "resource" ? "string" : field.type;
return { ...field, type } as PluginSettingsField;
}
function isEmpty(value: unknown): boolean {
return value === undefined || value === null || value === "";
}
export interface OperationInputResult {
values: Record<string, unknown>;
errors: string[];
}
/**
* Turns raw input into validated values. Keys no field declares are refused,
* so nothing a handler did not ask for ever reaches it.
*/
export function parseOperationInput(
fields: readonly OperationField[] | undefined,
raw: unknown,
): OperationInputResult {
const errors: string[] = [];
const values: Record<string, unknown> = {};
const list = fields ?? [];
if (raw !== undefined && raw !== null && !isObject(raw)) {
return { values, errors: ["Input must be an object"] };
}
const input = (raw ?? {}) as Record<string, unknown>;
for (const key of Object.keys(input)) {
if (!list.some((field) => field.key === key)) {
errors.push(`"${key}" is not an input of this action`);
}
}
for (const field of list) {
if (field.requires && input[field.requires] !== true) continue;
let value = input[field.key];
if (isEmpty(value)) {
if (field.default !== undefined) {
values[field.key] = field.default;
} else if (field.required) {
errors.push(`"${field.key}" is required`);
}
continue;
}
const settingsField = asSettingsField(field);
value = coerceSettingValue(settingsField, value);
if (field.type === "secret" && typeof value !== "string") {
errors.push(`"${field.key}" must be a string`);
continue;
}
if (field.type === "json" && typeof value === "string") {
try {
value = JSON.parse(value);
} catch {
errors.push(`"${field.key}" must be valid JSON`);
continue;
}
}
// A list known only at run time is checked by the handler.
const skipChoices =
field.optionsFrom &&
(field.type === "select" || field.type === "multiselect");
const problem = skipChoices
? field.type === "select"
? typeof value === "string"
? null
: `"${field.key}" must be a string`
: Array.isArray(value)
? null
: `"${field.key}" must be an array`
: validateSettingValue(settingsField, value);
if (problem) {
errors.push(problem);
continue;
}
if (
field.pattern &&
typeof value === "string" &&
!new RegExp(`^(?:${field.pattern})$`).test(value)
) {
errors.push(`"${field.key}" has the wrong format`);
continue;
}
values[field.key] = value;
}
return { values, errors };
}
/** A copy safe to store or show: secret and sensitive values are replaced. */
export function redactFields(
fields: readonly OperationField[] | undefined,
values: unknown,
): unknown {
if (!isObject(values)) return values;
const copy: Record<string, unknown> = { ...values };
for (const field of fields ?? []) {
if (field.type !== "secret" && !field.sensitive) continue;
if (field.key in copy) copy[field.key] = REDACTED_VALUE;
}
return copy;
}
/** Trims text to a byte-friendly length with a plain marker. */
export function capText(text: string, maxLength: number): string {
return text.length <= maxLength
? text
: `${text.slice(0, maxLength)}\n[cut at ${maxLength} characters]`;
}
function fieldSchema(field: OperationField): Record<string, unknown> {
const schema: Record<string, unknown> = {};
if (field.default !== undefined) schema.default = field.default;
const values = field.optionsFrom
? undefined
: field.options?.map((option) => option.value);
switch (field.type) {
case "boolean":
schema.type = "boolean";
break;
case "number":
schema.type = "number";
if (field.min !== undefined) schema.minimum = field.min;
if (field.max !== undefined) schema.maximum = field.max;
break;
case "select":
schema.type = "string";
if (values) schema.enum = values;
break;
case "multiselect":
schema.type = "array";
schema.items = values
? { type: "string", enum: values }
: { type: "string" };
break;
case "json":
break;
default:
schema.type = "string";
if (field.pattern) schema.pattern = `^(?:${field.pattern})$`;
}
if (field.type === "resource" && field.resource) {
schema["x-termix-resource"] = field.resource;
}
if (field.type === "secret") schema.writeOnly = true;
return schema;
}
/** JSON Schema for a field list, for AI tools, MCP and OpenAPI. */
export function toJsonSchema(
fields: readonly OperationField[] | undefined,
describe?: (field: OperationField) => string | undefined,
): Record<string, unknown> {
const properties: Record<string, unknown> = {};
const required: string[] = [];
for (const field of fields ?? []) {
const schema = fieldSchema(field);
const description = describe?.(field);
if (description) schema.description = description;
properties[field.key] = schema;
if (field.required && field.default === undefined && !field.requires) {
required.push(field.key);
}
}
return { type: "object", properties, required, additionalProperties: false };
}
+53 -1
View File
@@ -68,7 +68,11 @@ import type {
PluginSshKeyCredential, PluginSshKeyCredential,
PluginSshKeyCredentialInput, PluginSshKeyCredentialInput,
} from "./backend.js"; } from "./backend.js";
import type { PluginSyncHost, SyncEntityRegistration } from "./backend.js"; import type {
PluginOperationHandler,
PluginSyncHost,
SyncEntityRegistration,
} from "./backend.js";
import type { PluginDatabase } from "./backend.js"; import type { PluginDatabase } from "./backend.js";
import type { import type {
PluginExternalClientRequest, PluginExternalClientRequest,
@@ -355,6 +359,10 @@ export interface FakePluginContext {
}>; }>;
/** The hub the plugin registered with ctx.notify.serve, if any. */ /** The hub the plugin registered with ctx.notify.serve, if any. */
notifyHub: { current: PluginNotifyHub | null }; notifyHub: { current: PluginNotifyHub | null };
/** Handlers from ctx.operations.register, by action id. */
operationHandlers: Map<string, PluginOperationHandler>;
/** Every ctx.operations.emit call. */
operationEvents: { eventId: string; payload: unknown; hostId?: number }[];
/** Backing store behind ctx.secrets.get/set, keyed "<userId>:<key>". */ /** Backing store behind ctx.secrets.get/set, keyed "<userId>:<key>". */
secretStore: Map<string, string>; secretStore: Map<string, string>;
/** Every ctx.fetch call, in order. */ /** Every ctx.fetch call, in order. */
@@ -566,6 +574,8 @@ export function createFakeContext(
const createdSshKeys: FakePluginContext["createdSshKeys"] = []; const createdSshKeys: FakePluginContext["createdSshKeys"] = [];
const notifications: FakePluginContext["notifications"] = []; const notifications: FakePluginContext["notifications"] = [];
const notifyHub: FakePluginContext["notifyHub"] = { current: null }; const notifyHub: FakePluginContext["notifyHub"] = { current: null };
const operationHandlers: FakePluginContext["operationHandlers"] = new Map();
const operationEvents: FakePluginContext["operationEvents"] = [];
const fetches: FakePluginContext["fetches"] = []; const fetches: FakePluginContext["fetches"] = [];
const processRuns: FakePluginContext["processRuns"] = []; const processRuns: FakePluginContext["processRuns"] = [];
const binaries: FakePluginContext["binaries"] = []; const binaries: FakePluginContext["binaries"] = [];
@@ -1391,6 +1401,46 @@ export function createFakeContext(
}, },
}, },
operations: {
register: (id, handler) => {
operationHandlers.set(id, handler);
},
run: async (actionId, options = {}) => {
const handler = operationHandlers.get(actionId);
if (!handler) throw new Error(`No action "${actionId}"`);
const result = await handler.run(options.input ?? {}, {
hostId: options.hostId,
resourceKey: options.resourceKey,
runId: null,
dryRun: options.dryRun === true,
signal: new AbortController().signal,
log: () => {},
});
return { runId: null, status: "success", ...result };
},
invoke: async (actionId, options = {}) => {
const handler = operationHandlers.get(actionId);
if (!handler) throw new Error(`No action "${actionId}"`);
const result = await handler.run(options.input ?? {}, {
hostId: options.hostId,
resourceKey: options.resourceKey,
runId: null,
dryRun: options.dryRun === true,
signal: new AbortController().signal,
log: () => {},
});
return { runId: null, status: "success", ...result };
},
list: async () => ({
actions: manifest.contributes?.operations?.actions ?? [],
}),
emit: async (eventId, payload, options) => {
operationEvents.push({ eventId, payload, hostId: options?.hostId });
},
onEvent: () => () => {},
approvals: { provide: () => () => {} },
},
notify: { notify: {
channels: async () => { channels: async () => {
if (!actor) throw new Error("ctx.notify needs an acting user"); if (!actor) throw new Error("ctx.notify needs an acting user");
@@ -1554,6 +1604,8 @@ export function createFakeContext(
createdSshKeys, createdSshKeys,
notifications, notifications,
notifyHub, notifyHub,
operationHandlers,
operationEvents,
fetches, fetches,
processRuns, processRuns,
binaries, binaries,
Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

+32
View File
@@ -537,6 +537,38 @@ describe("default look", () => {
).toMatchObject({ id: "look", defaultLook: true }); ).toMatchObject({ id: "look", defaultLook: true });
}); });
it("leaves out new plugins with no usable release when pinning all", () => {
const stable = {
version: "1.0.0",
url: "https://x/a.tmxplug",
sha256: SHA,
};
const index = {
plugins: [
{ id: "ready", versions: [stable] },
{
id: "beta-only",
versions: [],
prereleases: [{ ...stable, version: "1.0.0-beta.1" }],
},
{
id: "too-new",
versions: [],
gated: [{ ...stable, engine: { termix: ">=99.0.0" } }],
},
],
};
expect(
pinsFromIndex(index, null, [], "26.11.1").map(
(pin: { id: string }) => pin.id,
),
).toEqual(["ready"]);
expect(() =>
pinsFromIndex(index, null, [{ id: "beta-only" }], "26.11.1"),
).toThrow(/beta-only/);
expect(() => pinsFromIndex(index, ["beta-only"])).toThrow(/beta-only/);
});
it("copies a local build's theme files", () => { it("copies a local build's theme files", () => {
const repo = tempDir(); const repo = tempDir();
fs.mkdirSync(path.join(repo, "dist"), { recursive: true }); fs.mkdirSync(path.join(repo, "dist"), { recursive: true });
+14
View File
@@ -45,6 +45,18 @@ function buildTable(version, mobileVersion, mobileTag) {
].join("\n"); ].join("\n");
} }
function buildFullBuilds(version) {
const base = `https://github.com/Termix-SSH/Termix/releases/download/v${version}`;
const link = (label, file) => `[${label}](${base}/${file})`;
return [
"The builds above include the standard plugins. The full builds include every official plugin, which helps on a network with no internet. You can still turn plugins on or off in the Plugins tab. Full builds are x64 only (universal on Mac):",
"",
`- Windows: ${link("EXE", "termix_windows_x64_full_nsis.exe")}`,
`- Linux: ${link("AppImage", "termix_linux_x64_full_appimage.AppImage")} · ${link("DEB", "termix_linux_x64_full_deb.deb")}`,
`- Mac: ${link("DMG", "termix_macos_universal_full_dmg.dmg")}`,
].join("\n");
}
function buildBody({ version, mobileVersion, mobileTag, changelog }) { function buildBody({ version, mobileVersion, mobileTag, changelog }) {
const notes = changelogSection(changelog, version); const notes = changelogSection(changelog, version);
if (!notes) fail(`CHANGELOG.md has no notes for ${version}`); if (!notes) fail(`CHANGELOG.md has no notes for ${version}`);
@@ -62,6 +74,8 @@ function buildBody({ version, mobileVersion, mobileTag, changelog }) {
"### Downloads", "### Downloads",
"", "",
buildTable(version, mobileVersion, mobileTag), buildTable(version, mobileVersion, mobileTag),
"",
buildFullBuilds(version),
].join("\n"); ].join("\n");
} }
+12
View File
@@ -34,6 +34,18 @@ describe("buildBody", () => {
); );
}); });
it("links the full builds", () => {
const body = buildBody({
version: "26.10.0",
mobileVersion: "1.2.0",
changelog: CHANGELOG,
});
expect(body).toContain("v26.10.0/termix_windows_x64_full_nsis.exe");
expect(body).toContain("v26.10.0/termix_linux_x64_full_appimage.AppImage");
expect(body).toContain("v26.10.0/termix_linux_x64_full_deb.deb");
expect(body).toContain("v26.10.0/termix_macos_universal_full_dmg.dmg");
});
it("works for the real changelog", () => { it("works for the real changelog", () => {
const fs = require("fs"); const fs = require("fs");
const body = buildBody({ const body = buildBody({
+8 -3
View File
@@ -278,14 +278,15 @@ function pluginsForProfile(plugins, profile) {
* pin list, so fields the registry does not know (onboarding, tier) carry over; * pin list, so fields the registry does not know (onboarding, tier) carry over;
* a plugin new to the list starts in the full tier. coreVersion, when given, * a plugin new to the list starts in the full tier. coreVersion, when given,
* skips releases whose engine.termix range does not include it, so the image * skips releases whose engine.termix range does not include it, so the image
* never ships a plugin its own core cannot run. * never ships a plugin its own core cannot run. With ids null, a plugin new to
* the list that has no usable release (only betas, say) is left out.
*/ */
function pinsFromIndex(index, ids, current = [], coreVersion = null) { function pinsFromIndex(index, ids, current = [], coreVersion = null) {
const plugins = Array.isArray(index?.plugins) ? index.plugins : []; const plugins = Array.isArray(index?.plugins) ? index.plugins : [];
const byId = new Map(plugins.map((plugin) => [plugin.id, plugin])); const byId = new Map(plugins.map((plugin) => [plugin.id, plugin]));
const currentById = new Map(current.map((entry) => [entry.id, entry])); const currentById = new Map(current.map((entry) => [entry.id, entry]));
const wanted = ids ?? plugins.map((plugin) => plugin.id); const wanted = ids ?? plugins.map((plugin) => plugin.id);
return [...wanted].sort().map((id) => { const pins = [...wanted].sort().map((id) => {
const listed = byId.get(id); const listed = byId.get(id);
// gated holds releases that need a newer Termix than old builds check for. // gated holds releases that need a newer Termix than old builds check for.
const latest = [...(listed?.versions ?? []), ...(listed?.gated ?? [])] const latest = [...(listed?.versions ?? []), ...(listed?.gated ?? [])]
@@ -300,7 +301,10 @@ function pinsFromIndex(index, ids, current = [], coreVersion = null) {
}), }),
) )
.sort((a, b) => semver.rcompare(a.version, b.version))[0]; .sort((a, b) => semver.rcompare(a.version, b.version))[0];
if (!latest) throw new Error(`${id} is not in the registry index`); if (!latest) {
if (ids === null && !currentById.has(id)) return null;
throw new Error(`${id} is not in the registry index`);
}
const onboarding = currentById.get(id)?.onboarding; const onboarding = currentById.get(id)?.onboarding;
const defaultLook = currentById.get(id)?.defaultLook === true; const defaultLook = currentById.get(id)?.defaultLook === true;
const tier = currentById.get(id)?.tier ?? "full"; const tier = currentById.get(id)?.tier ?? "full";
@@ -314,6 +318,7 @@ function pinsFromIndex(index, ids, current = [], coreVersion = null) {
...(defaultLook ? { defaultLook } : {}), ...(defaultLook ? { defaultLook } : {}),
}; };
}); });
return pins.filter(Boolean);
} }
const LOCAL_FILES = [ const LOCAL_FILES = [
+1 -1
View File
@@ -4,7 +4,7 @@
* re-released. * re-released.
* *
* node scripts/pin-bundled-plugins.cjs refresh the plugins already listed * node scripts/pin-bundled-plugins.cjs refresh the plugins already listed
* node scripts/pin-bundled-plugins.cjs --all also add plugins new to the index (tier full) * node scripts/pin-bundled-plugins.cjs --all also add plugins new to the index that have a stable release (tier full)
* --core-version X skip releases whose engine.termix excludes X * --core-version X skip releases whose engine.termix excludes X
* *
* The release workflow runs it with --all and the version being released. * The release workflow runs it with --all and the version being released.
+2
View File
@@ -30,6 +30,7 @@ import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
import { syncForwarder } from "../sync/provider.js"; import { syncForwarder } from "../sync/provider.js";
import { syncChangeWatcher } from "../sync/server/change-watcher.js"; import { syncChangeWatcher } from "../sync/server/change-watcher.js";
import dashboardRoutes from "./routes/dashboard-routes.js"; import dashboardRoutes from "./routes/dashboard-routes.js";
import operationsRoutes from "./routes/operations-routes.js";
import { import {
mountPluginApi, mountPluginApi,
mountPluginLegacyPaths, mountPluginLegacyPaths,
@@ -1627,6 +1628,7 @@ app.use("/ui-preferences", uiPreferencesRoutes);
registerAuditLogRoutes(app, authenticateJWT); registerAuditLogRoutes(app, authenticateJWT);
app.use("/sync", syncForwarder); app.use("/sync", syncForwarder);
app.use("/dashboard", dashboardRoutes); app.use("/dashboard", dashboardRoutes);
app.use("/operations", operationsRoutes);
app.use("/plugins", pluginRoutes); app.use("/plugins", pluginRoutes);
app.use( app.use(
"/plugin-assets", "/plugin-assets",
+71 -4
View File
@@ -599,6 +599,70 @@ async function initializeCompleteDatabase(): Promise<void> {
UNIQUE (role_name, permission) UNIQUE (role_name, permission)
); );
CREATE TABLE IF NOT EXISTS operation_runs (
id TEXT PRIMARY KEY,
action_id TEXT NOT NULL,
action_version INTEGER NOT NULL DEFAULT 1,
plugin_id TEXT NOT NULL,
plugin_version TEXT NOT NULL,
user_id TEXT REFERENCES users (id) ON DELETE SET NULL,
username TEXT NOT NULL,
origin TEXT NOT NULL,
host_id INTEGER,
host_name TEXT,
batch_id TEXT,
dry_run INTEGER NOT NULL DEFAULT 0,
risk TEXT NOT NULL,
status TEXT NOT NULL,
summary TEXT,
changed INTEGER,
input TEXT,
sealed_input TEXT,
output TEXT,
undo_input TEXT,
error TEXT,
error_code TEXT,
undo_of TEXT,
idempotency_key TEXT,
created_at TEXT NOT NULL,
started_at TEXT,
finished_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_operation_runs_created ON operation_runs (created_at);
CREATE INDEX IF NOT EXISTS idx_operation_runs_user_created ON operation_runs (user_id, created_at);
CREATE INDEX IF NOT EXISTS idx_operation_runs_host_created ON operation_runs (host_id, created_at);
CREATE INDEX IF NOT EXISTS idx_operation_runs_status ON operation_runs (status);
CREATE INDEX IF NOT EXISTS idx_operation_runs_batch ON operation_runs (batch_id);
CREATE TABLE IF NOT EXISTS operation_approvals (
id TEXT PRIMARY KEY,
run_id TEXT NOT NULL REFERENCES operation_runs (id) ON DELETE CASCADE,
requested_by TEXT,
reason TEXT,
allow_self INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL,
decided_by TEXT,
comment TEXT,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
decided_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_operation_approvals_status ON operation_approvals (status, expires_at);
CREATE INDEX IF NOT EXISTS idx_operation_approvals_run ON operation_approvals (run_id);
CREATE TABLE IF NOT EXISTS operation_events (
id TEXT PRIMARY KEY,
event_id TEXT NOT NULL,
plugin_id TEXT NOT NULL,
host_id INTEGER,
severity TEXT,
payload TEXT,
run_id TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_operation_events_created ON operation_events (created_at);
CREATE INDEX IF NOT EXISTS idx_operation_events_host_created ON operation_events (host_id, created_at);
CREATE TABLE IF NOT EXISTS api_keys ( CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
@@ -961,6 +1025,7 @@ const enforceUniqueUsernames = () => {
const migrateSchema = () => { const migrateSchema = () => {
repairSnippetsColumns(sqlite); repairSnippetsColumns(sqlite);
addColumnIfNotExists("api_keys", "scopes", "TEXT");
addColumnIfNotExists("user_preferences", "theme", "TEXT"); addColumnIfNotExists("user_preferences", "theme", "TEXT");
addColumnIfNotExists("user_preferences", "font_size", "TEXT"); addColumnIfNotExists("user_preferences", "font_size", "TEXT");
addColumnIfNotExists("user_preferences", "accent_color", "TEXT"); addColumnIfNotExists("user_preferences", "accent_color", "TEXT");
@@ -1812,6 +1877,8 @@ async function saveMemoryDatabaseToFile(): Promise<void> {
if (!memoryDatabase) return; if (!memoryDatabase) return;
try { try {
// Before the copy, so a write during the slow part below stays dirty.
DatabaseSaveTrigger.markClean();
const buffer = memoryDatabase.serialize(); const buffer = memoryDatabase.serialize();
if (!fs.existsSync(dataDir)) { if (!fs.existsSync(dataDir)) {
@@ -1832,11 +1899,10 @@ async function saveMemoryDatabaseToFile(): Promise<void> {
encryptedDbPath, encryptedDbPath,
); );
} else { } else {
fs.writeFileSync(dbPath, buffer); await fs.promises.writeFile(dbPath, buffer);
} }
DatabaseSaveTrigger.markClean();
} catch (error) { } catch (error) {
DatabaseSaveTrigger.markDirty();
databaseLogger.error("Failed to save in-memory database", error, { databaseLogger.error("Failed to save in-memory database", error, {
operation: "memory_db_save_failed", operation: "memory_db_save_failed",
enableFileEncryption, enableFileEncryption,
@@ -1855,7 +1921,8 @@ async function handlePostInitFileEncryption() {
setInterval(() => { setInterval(() => {
if (DatabaseSaveTrigger.isDirty) { if (DatabaseSaveTrigger.isDirty) {
saveMemoryDatabaseToFile(); // Through the trigger so it waits for any save already running.
void DatabaseSaveTrigger.forceSave("periodic_save").catch(() => {});
} }
}, 5 * 60 * 1000); }, 5 * 60 * 1000);
} }
+92
View File
@@ -673,10 +673,102 @@ export const apiKeys = mysqlTable(
expiresAt: text("expires_at"), expiresAt: text("expires_at"),
lastUsedAt: text("last_used_at"), lastUsedAt: text("last_used_at"),
isActive: boolean("is_active").notNull().default(true), isActive: boolean("is_active").notNull().default(true),
// JSON list. Null means full access, which every older key has.
scopes: text("scopes"),
}, },
(table) => [index("idx_api_keys_user_id").on(table.userId)], (table) => [index("idx_api_keys_user_id").on(table.userId)],
); );
export const operationRuns = mysqlTable(
"operation_runs",
{
id: varchar("id", { length: 255 }).primaryKey(),
actionId: text("action_id").notNull(),
actionVersion: int("action_version").notNull().default(1),
pluginId: text("plugin_id").notNull(),
pluginVersion: text("plugin_version").notNull(),
// Nullable on purpose: history outlives the account, like audit_logs.
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "set null" }),
username: text("username").notNull(),
origin: text("origin").notNull(),
hostId: int("host_id"),
hostName: text("host_name"),
batchId: varchar("batch_id", { length: 255 }),
dryRun: boolean("dry_run").notNull().default(false),
risk: text("risk").notNull(),
status: varchar("status", { length: 255 }).notNull(),
summary: text("summary"),
changed: boolean("changed"),
input: text("input"),
// Only until the run ends, then wiped.
sealedInput: text("sealed_input"),
output: text("output"),
undoInput: text("undo_input"),
error: text("error"),
errorCode: text("error_code"),
undoOf: text("undo_of"),
idempotencyKey: text("idempotency_key"),
createdAt: varchar("created_at", { length: 255 }).notNull(),
startedAt: text("started_at"),
finishedAt: text("finished_at"),
},
(table) => [
index("idx_operation_runs_created").on(table.createdAt),
index("idx_operation_runs_user_created").on(table.userId, table.createdAt),
index("idx_operation_runs_host_created").on(table.hostId, table.createdAt),
index("idx_operation_runs_status").on(table.status),
index("idx_operation_runs_batch").on(table.batchId),
],
);
export const operationApprovals = mysqlTable(
"operation_approvals",
{
id: varchar("id", { length: 255 }).primaryKey(),
runId: varchar("run_id", { length: 255 })
.notNull()
.references(() => operationRuns.id, { onDelete: "cascade" }),
requestedBy: text("requested_by"),
reason: text("reason"),
allowSelf: boolean("allow_self")
.notNull()
.default(false),
status: varchar("status", { length: 255 }).notNull(),
decidedBy: text("decided_by"),
comment: text("comment"),
createdAt: text("created_at").notNull(),
expiresAt: varchar("expires_at", { length: 255 }).notNull(),
decidedAt: text("decided_at"),
},
(table) => [
index("idx_operation_approvals_status").on(table.status, table.expiresAt),
index("idx_operation_approvals_run").on(table.runId),
],
);
export const operationEvents = mysqlTable(
"operation_events",
{
id: varchar("id", { length: 255 }).primaryKey(),
eventId: text("event_id").notNull(),
pluginId: text("plugin_id").notNull(),
hostId: int("host_id"),
severity: text("severity"),
payload: text("payload"),
runId: text("run_id"),
createdAt: varchar("created_at", { length: 255 }).notNull(),
},
(table) => [
index("idx_operation_events_created").on(table.createdAt),
index("idx_operation_events_host_created").on(
table.hostId,
table.createdAt,
),
],
);
export const userOpenTabs = mysqlTable( export const userOpenTabs = mysqlTable(
"user_open_tabs", "user_open_tabs",
{ {
+92
View File
@@ -673,10 +673,102 @@ export const apiKeys = pgTable(
expiresAt: text("expires_at"), expiresAt: text("expires_at"),
lastUsedAt: text("last_used_at"), lastUsedAt: text("last_used_at"),
isActive: boolean("is_active").notNull().default(true), isActive: boolean("is_active").notNull().default(true),
// JSON list. Null means full access, which every older key has.
scopes: text("scopes"),
}, },
(table) => [index("idx_api_keys_user_id").on(table.userId)], (table) => [index("idx_api_keys_user_id").on(table.userId)],
); );
export const operationRuns = pgTable(
"operation_runs",
{
id: varchar("id", { length: 255 }).primaryKey(),
actionId: text("action_id").notNull(),
actionVersion: integer("action_version").notNull().default(1),
pluginId: text("plugin_id").notNull(),
pluginVersion: text("plugin_version").notNull(),
// Nullable on purpose: history outlives the account, like audit_logs.
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "set null" }),
username: text("username").notNull(),
origin: text("origin").notNull(),
hostId: integer("host_id"),
hostName: text("host_name"),
batchId: varchar("batch_id", { length: 255 }),
dryRun: boolean("dry_run").notNull().default(false),
risk: text("risk").notNull(),
status: varchar("status", { length: 255 }).notNull(),
summary: text("summary"),
changed: boolean("changed"),
input: text("input"),
// Only until the run ends, then wiped.
sealedInput: text("sealed_input"),
output: text("output"),
undoInput: text("undo_input"),
error: text("error"),
errorCode: text("error_code"),
undoOf: text("undo_of"),
idempotencyKey: text("idempotency_key"),
createdAt: varchar("created_at", { length: 255 }).notNull(),
startedAt: text("started_at"),
finishedAt: text("finished_at"),
},
(table) => [
index("idx_operation_runs_created").on(table.createdAt),
index("idx_operation_runs_user_created").on(table.userId, table.createdAt),
index("idx_operation_runs_host_created").on(table.hostId, table.createdAt),
index("idx_operation_runs_status").on(table.status),
index("idx_operation_runs_batch").on(table.batchId),
],
);
export const operationApprovals = pgTable(
"operation_approvals",
{
id: varchar("id", { length: 255 }).primaryKey(),
runId: varchar("run_id", { length: 255 })
.notNull()
.references(() => operationRuns.id, { onDelete: "cascade" }),
requestedBy: text("requested_by"),
reason: text("reason"),
allowSelf: boolean("allow_self")
.notNull()
.default(false),
status: varchar("status", { length: 255 }).notNull(),
decidedBy: text("decided_by"),
comment: text("comment"),
createdAt: text("created_at").notNull(),
expiresAt: varchar("expires_at", { length: 255 }).notNull(),
decidedAt: text("decided_at"),
},
(table) => [
index("idx_operation_approvals_status").on(table.status, table.expiresAt),
index("idx_operation_approvals_run").on(table.runId),
],
);
export const operationEvents = pgTable(
"operation_events",
{
id: varchar("id", { length: 255 }).primaryKey(),
eventId: text("event_id").notNull(),
pluginId: text("plugin_id").notNull(),
hostId: integer("host_id"),
severity: text("severity"),
payload: text("payload"),
runId: text("run_id"),
createdAt: varchar("created_at", { length: 255 }).notNull(),
},
(table) => [
index("idx_operation_events_created").on(table.createdAt),
index("idx_operation_events_host_created").on(
table.hostId,
table.createdAt,
),
],
);
export const userOpenTabs = pgTable( export const userOpenTabs = pgTable(
"user_open_tabs", "user_open_tabs",
{ {
+92
View File
@@ -663,10 +663,102 @@ export const apiKeys = sqliteTable(
expiresAt: text("expires_at"), expiresAt: text("expires_at"),
lastUsedAt: text("last_used_at"), lastUsedAt: text("last_used_at"),
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true), isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
// JSON list. Null means full access, which every older key has.
scopes: text("scopes"),
}, },
(table) => [index("idx_api_keys_user_id").on(table.userId)], (table) => [index("idx_api_keys_user_id").on(table.userId)],
); );
export const operationRuns = sqliteTable(
"operation_runs",
{
id: text("id").primaryKey(),
actionId: text("action_id").notNull(),
actionVersion: integer("action_version").notNull().default(1),
pluginId: text("plugin_id").notNull(),
pluginVersion: text("plugin_version").notNull(),
// Nullable on purpose: history outlives the account, like audit_logs.
userId: text("user_id").references(() => users.id, { onDelete: "set null" }),
username: text("username").notNull(),
origin: text("origin").notNull(),
hostId: integer("host_id"),
hostName: text("host_name"),
batchId: text("batch_id"),
dryRun: integer("dry_run", { mode: "boolean" }).notNull().default(false),
risk: text("risk").notNull(),
status: text("status").notNull(),
summary: text("summary"),
changed: integer("changed", { mode: "boolean" }),
input: text("input"),
// Only until the run ends, then wiped.
sealedInput: text("sealed_input"),
output: text("output"),
undoInput: text("undo_input"),
error: text("error"),
errorCode: text("error_code"),
undoOf: text("undo_of"),
idempotencyKey: text("idempotency_key"),
createdAt: text("created_at").notNull(),
startedAt: text("started_at"),
finishedAt: text("finished_at"),
},
(table) => [
index("idx_operation_runs_created").on(table.createdAt),
index("idx_operation_runs_user_created").on(table.userId, table.createdAt),
index("idx_operation_runs_host_created").on(table.hostId, table.createdAt),
index("idx_operation_runs_status").on(table.status),
index("idx_operation_runs_batch").on(table.batchId),
],
);
export const operationApprovals = sqliteTable(
"operation_approvals",
{
id: text("id").primaryKey(),
runId: text("run_id")
.notNull()
.references(() => operationRuns.id, { onDelete: "cascade" }),
requestedBy: text("requested_by"),
reason: text("reason"),
allowSelf: integer("allow_self", { mode: "boolean" })
.notNull()
.default(false),
status: text("status").notNull(),
decidedBy: text("decided_by"),
comment: text("comment"),
createdAt: text("created_at").notNull(),
expiresAt: text("expires_at").notNull(),
decidedAt: text("decided_at"),
},
(table) => [
index("idx_operation_approvals_status").on(table.status, table.expiresAt),
index("idx_operation_approvals_run").on(table.runId),
],
);
export const operationEvents = sqliteTable(
"operation_events",
{
id: text("id").primaryKey(),
eventId: text("event_id").notNull(),
pluginId: text("plugin_id").notNull(),
hostId: integer("host_id"),
severity: text("severity"),
payload: text("payload"),
runId: text("run_id"),
createdAt: text("created_at").notNull(),
},
(table) => [
index("idx_operation_events_created").on(table.createdAt),
index("idx_operation_events_host_created").on(
table.hostId,
table.createdAt,
),
],
);
export const userOpenTabs = sqliteTable( export const userOpenTabs = sqliteTable(
"user_open_tabs", "user_open_tabs",
{ {
@@ -13,6 +13,7 @@ import { HostSidebarPreferenceRepository } from "./host-sidebar-preference-repos
import { CredentialSidebarPreferenceRepository } from "./credential-sidebar-preference-repository.js"; import { CredentialSidebarPreferenceRepository } from "./credential-sidebar-preference-repository.js";
import { UiPreferenceRepository } from "./ui-preference-repository.js"; import { UiPreferenceRepository } from "./ui-preference-repository.js";
import { OpenTabRepository } from "./open-tab-repository.js"; import { OpenTabRepository } from "./open-tab-repository.js";
import { OperationRepository } from "./operation-repository.js";
import { PluginRepository } from "./plugin-repository.js"; import { PluginRepository } from "./plugin-repository.js";
import { PluginStorageRepository } from "./plugin-storage-repository.js"; import { PluginStorageRepository } from "./plugin-storage-repository.js";
import { PluginSettingsRepository } from "./plugin-settings-repository.js"; import { PluginSettingsRepository } from "./plugin-settings-repository.js";
@@ -134,6 +135,14 @@ export function createCurrentAuditLogRepository(): AuditLogRepository {
); );
} }
export function createCurrentOperationRepository(): OperationRepository {
return new OperationRepository(
createCurrentRepositoryContext(),
// Rows land on every run. A rollout saves once on its own when it starts.
createCurrentRepositoryLazyWriteHook("operation_repository_write"),
);
}
export function createCurrentCredentialRepository(): CredentialRepository { export function createCurrentCredentialRepository(): CredentialRepository {
return new CredentialRepository( return new CredentialRepository(
createCurrentRepositoryContext(), createCurrentRepositoryContext(),
@@ -0,0 +1,255 @@
import { and, desc, eq, inArray, lt, or, sql } from "drizzle-orm";
import {
operationApprovals,
operationEvents,
operationRuns,
} from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { countValue, rowsAffected } from "./mutation-result.js";
export type OperationRunRecord = typeof operationRuns.$inferSelect;
export type NewOperationRunRecord = typeof operationRuns.$inferInsert;
export type OperationApprovalRecord = typeof operationApprovals.$inferSelect;
export type NewOperationApprovalRecord = typeof operationApprovals.$inferInsert;
export type OperationEventRecord = typeof operationEvents.$inferSelect;
export type NewOperationEventRecord = typeof operationEvents.$inferInsert;
export interface OperationRunFilters {
userId?: string;
/** Rows also visible because the viewer owns the host. */
orHostIds?: number[];
hostId?: number;
actionId?: string;
status?: string;
batchId?: string;
before?: string;
}
const MAX_PAGE = 200;
export class OperationRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async insertRun(run: NewOperationRunRecord): Promise<void> {
await this.context.drizzle.insert(operationRuns).values(run);
await this.onWrite?.();
}
async findRun(id: string): Promise<OperationRunRecord | null> {
const rows = await this.context.drizzle
.select()
.from(operationRuns)
.where(eq(operationRuns.id, id))
.limit(1);
return rows[0] ?? null;
}
async findByIdempotencyKey(
userId: string,
key: string,
): Promise<OperationRunRecord | null> {
const rows = await this.context.drizzle
.select()
.from(operationRuns)
.where(
and(
eq(operationRuns.userId, userId),
eq(operationRuns.idempotencyKey, key),
),
)
.limit(1);
return rows[0] ?? null;
}
async listRuns(
filters: OperationRunFilters,
limit = 50,
): Promise<OperationRunRecord[]> {
const owner = filters.userId
? filters.orHostIds?.length
? or(
eq(operationRuns.userId, filters.userId),
inArray(operationRuns.hostId, filters.orHostIds),
)
: eq(operationRuns.userId, filters.userId)
: undefined;
const conditions = [
owner,
filters.hostId !== undefined
? eq(operationRuns.hostId, filters.hostId)
: undefined,
filters.actionId
? eq(operationRuns.actionId, filters.actionId)
: undefined,
filters.status ? eq(operationRuns.status, filters.status) : undefined,
filters.batchId ? eq(operationRuns.batchId, filters.batchId) : undefined,
filters.before ? lt(operationRuns.createdAt, filters.before) : undefined,
].filter((condition) => condition !== undefined);
return this.context.drizzle
.select()
.from(operationRuns)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(desc(operationRuns.createdAt))
.limit(Math.min(Math.max(limit, 1), MAX_PAGE));
}
/** Moves a run on only from the status given, so two writers cannot race. */
async updateRunFrom(
id: string,
fromStatus: string,
patch: Partial<NewOperationRunRecord>,
): Promise<boolean> {
const result = await this.context.drizzle
.update(operationRuns)
.set(patch)
.where(
and(eq(operationRuns.id, id), eq(operationRuns.status, fromStatus)),
);
await this.onWrite?.();
return rowsAffected(result) > 0;
}
/** Anything still running when the server stopped has an unknown outcome. */
async markRunningInterrupted(finishedAt: string): Promise<number> {
const result = await this.context.drizzle
.update(operationRuns)
.set({
status: "interrupted",
finishedAt,
sealedInput: null,
error: "The server stopped during this run. Check the host.",
})
.where(eq(operationRuns.status, "running"));
await this.onWrite?.();
return rowsAffected(result);
}
async insertApproval(approval: NewOperationApprovalRecord): Promise<void> {
await this.context.drizzle.insert(operationApprovals).values(approval);
await this.onWrite?.();
}
async findApproval(id: string): Promise<OperationApprovalRecord | null> {
const rows = await this.context.drizzle
.select()
.from(operationApprovals)
.where(eq(operationApprovals.id, id))
.limit(1);
return rows[0] ?? null;
}
async findApprovalForRun(
runId: string,
): Promise<OperationApprovalRecord | null> {
const rows = await this.context.drizzle
.select()
.from(operationApprovals)
.where(eq(operationApprovals.runId, runId))
.limit(1);
return rows[0] ?? null;
}
async listPendingApprovals(limit = 100): Promise<OperationApprovalRecord[]> {
return this.context.drizzle
.select()
.from(operationApprovals)
.where(eq(operationApprovals.status, "pending"))
.orderBy(desc(operationApprovals.createdAt))
.limit(Math.min(Math.max(limit, 1), MAX_PAGE));
}
/** One shot: only the first decision on a pending row wins. */
async decideApproval(
id: string,
status: "approved" | "rejected" | "expired",
decidedBy: string | null,
comment: string | null,
decidedAt: string,
): Promise<boolean> {
const result = await this.context.drizzle
.update(operationApprovals)
.set({ status, decidedBy, comment, decidedAt })
.where(
and(
eq(operationApprovals.id, id),
eq(operationApprovals.status, "pending"),
),
);
await this.onWrite?.();
return rowsAffected(result) > 0;
}
async listExpiredApprovals(now: string): Promise<OperationApprovalRecord[]> {
return this.context.drizzle
.select()
.from(operationApprovals)
.where(
and(
eq(operationApprovals.status, "pending"),
lt(operationApprovals.expiresAt, now),
),
)
.limit(MAX_PAGE);
}
async insertEvent(event: NewOperationEventRecord): Promise<void> {
await this.context.drizzle.insert(operationEvents).values(event);
await this.onWrite?.();
}
async listEvents(
filters: { hostId?: number; before?: string },
limit = 50,
): Promise<OperationEventRecord[]> {
const conditions = [
filters.hostId !== undefined
? eq(operationEvents.hostId, filters.hostId)
: undefined,
filters.before
? lt(operationEvents.createdAt, filters.before)
: undefined,
].filter((condition) => condition !== undefined);
return this.context.drizzle
.select()
.from(operationEvents)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(desc(operationEvents.createdAt))
.limit(Math.min(Math.max(limit, 1), MAX_PAGE));
}
/** Drops rows older than the cutoff, finished runs only. */
async prune(before: string): Promise<number> {
const runs = await this.context.drizzle
.delete(operationRuns)
.where(
and(
lt(operationRuns.createdAt, before),
sql`${operationRuns.status} NOT IN ('running', 'pending_approval')`,
),
);
const events = await this.context.drizzle
.delete(operationEvents)
.where(lt(operationEvents.createdAt, before));
await this.onWrite?.();
return rowsAffected(runs) + rowsAffected(events);
}
async countRuns(): Promise<number> {
const rows = await this.context.drizzle
.select({ value: sql<number>`count(*)` })
.from(operationRuns);
return countValue(rows[0]?.value);
}
/** History outlives the account, but the name goes with it. */
async anonymizeUser(userId: string): Promise<void> {
await this.context.drizzle
.update(operationRuns)
.set({ username: "deleted user" })
.where(eq(operationRuns.userId, userId));
await this.onWrite?.();
}
}
@@ -3,6 +3,7 @@ import { recentActivity } from "../../plugins/activity-provider.js";
import { import {
createCurrentApiKeyRepository, createCurrentApiKeyRepository,
createCurrentAuditLogRepository, createCurrentAuditLogRepository,
createCurrentOperationRepository,
createCurrentCredentialRepository, createCurrentCredentialRepository,
createCurrentHostFolderRepository, createCurrentHostFolderRepository,
createCurrentHostRepository, createCurrentHostRepository,
@@ -61,6 +62,7 @@ export async function deleteUserAndRelatedData(
await createCurrentRoleRepository().removeAllRolesFromUser(userId); await createCurrentRoleRepository().removeAllRolesFromUser(userId);
await createCurrentAuditLogRepository().anonymizeByUserId(userId); await createCurrentAuditLogRepository().anonymizeByUserId(userId);
await createCurrentOperationRepository().anonymizeUser(userId);
await createCurrentSshCredentialUsageRepository().deleteByUserId(userId); await createCurrentSshCredentialUsageRepository().deleteByUserId(userId);
@@ -0,0 +1,760 @@
import express, { type Request, type Response } from "express";
import type {
ResourceListResult,
SignalResult,
} from "@termix-ssh/plugin-sdk/operations";
import { AuthManager } from "../../utils/auth-manager.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import { databaseLogger } from "../../utils/logger.js";
import { getErrorMessage } from "../../utils/error-message.js";
import { createCurrentOperationRepository } from "../repositories/factory.js";
import type { OperationRunRecord } from "../repositories/operation-repository.js";
import { buildCatalog } from "../../operations/catalog.js";
import {
cancelOperationRun,
decideOperationApproval,
OperationError,
runOperation,
} from "../../operations/gate.js";
import { getAction } from "../../operations/registry.js";
import {
flattenResourceRows,
readAcrossHosts,
} from "../../operations/resources.js";
import type { ActorOrigin } from "../../plugins/actor.js";
const router = express.Router();
const authenticateJWT = AuthManager.getInstance().createAuthMiddleware();
const permissions = PermissionManager.getInstance();
/** Sees every run on the server, not only their own. */
export const VIEW_ALL_RUNS = "admin.operations.view";
type OperationsRequest = Request & {
userId: string;
apiKeyId?: string;
apiKeyScopes?: string[] | null;
actingAdminUserId?: string;
};
function originOf(req: OperationsRequest): ActorOrigin {
return { kind: req.apiKeyId ? "api" : "ui" };
}
function sendError(res: Response, error: unknown, operation: string): void {
if (error instanceof OperationError) {
res.status(error.status).json({
error: error.message,
code: error.code,
details: error.details,
});
return;
}
databaseLogger.error("Operations route failed", error, { operation });
res.status(500).json({ error: getErrorMessage(error, "Request failed") });
}
function parseJson(text: string | null): unknown {
if (!text) return undefined;
try {
return JSON.parse(text);
} catch {
return undefined;
}
}
/** Never includes the sealed input. */
function publicRun(run: OperationRunRecord) {
return {
id: run.id,
actionId: run.actionId,
pluginId: run.pluginId,
pluginVersion: run.pluginVersion,
userId: run.userId,
username: run.username,
origin: parseJson(run.origin),
hostId: run.hostId,
hostName: run.hostName,
batchId: run.batchId,
dryRun: run.dryRun,
risk: run.risk,
status: run.status,
summary: run.summary,
changed: run.changed,
input: parseJson(run.input),
output: parseJson(run.output),
undoAvailable: run.undoInput !== null,
error: run.error,
errorCode: run.errorCode,
undoOf: run.undoOf,
createdAt: run.createdAt,
startedAt: run.startedAt,
finishedAt: run.finishedAt,
};
}
async function canSeeRun(
run: OperationRunRecord,
userId: string,
): Promise<boolean> {
if (run.userId === userId) return true;
if (await permissions.hasPermission(userId, VIEW_ALL_RUNS)) return true;
if (run.hostId === null) return false;
const access = await permissions.canAccessHost(userId, run.hostId, "manage");
return access.hasAccess;
}
function hostIdsFrom(value: unknown): number[] | undefined {
if (typeof value !== "string" || value === "") return undefined;
const ids = value
.split(",")
.map(Number)
.filter((id) => Number.isInteger(id));
return ids.length > 0 ? ids : undefined;
}
function limitFrom(value: unknown): number | undefined {
const limit = Number(value);
return Number.isInteger(limit) && limit > 0 ? limit : undefined;
}
/**
* @openapi
* /operations/catalog:
* get:
* summary: List what this user can do
* description: Every action, event, resource and signal from running plugins that the caller has permission for, with titles in the requested language.
* tags:
* - Operations
* parameters:
* - in: query
* name: lang
* schema:
* type: string
* - in: query
* name: surface
* schema:
* type: string
* enum: [assistant, mcp, palette, automations, mobile, cli]
* responses:
* 200:
* description: The catalog.
*/
router.get("/catalog", authenticateJWT, async (req, res) => {
const { userId } = req as OperationsRequest;
try {
res.json(
await buildCatalog({
userId,
lang: typeof req.query.lang === "string" ? req.query.lang : undefined,
surface:
typeof req.query.surface === "string"
? (req.query.surface as never)
: undefined,
}),
);
} catch (error) {
sendError(res, error, "operations_catalog");
}
});
/**
* @openapi
* /operations/catalog/{id}:
* get:
* summary: Describe one action
* tags:
* - Operations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The action.
* 404:
* description: No such action, or the caller may not use it.
*/
router.get("/catalog/:id", authenticateJWT, async (req, res) => {
const { userId } = req as OperationsRequest;
try {
const catalog = await buildCatalog({
userId,
lang: typeof req.query.lang === "string" ? req.query.lang : undefined,
});
const action = catalog.actions.find((entry) => entry.id === req.params.id);
if (!action) {
res.status(404).json({ error: "Action not found" });
return;
}
res.json(action);
} catch (error) {
sendError(res, error, "operations_catalog_action");
}
});
/**
* @openapi
* /operations/runs:
* get:
* summary: List runs
* description: The caller's own runs, runs on hosts they manage, or every run with the view permission.
* tags:
* - Operations
* parameters:
* - in: query
* name: hostId
* schema:
* type: integer
* - in: query
* name: actionId
* schema:
* type: string
* - in: query
* name: status
* schema:
* type: string
* - in: query
* name: batchId
* schema:
* type: string
* - in: query
* name: before
* schema:
* type: string
* - in: query
* name: limit
* schema:
* type: integer
* responses:
* 200:
* description: Runs, newest first.
*/
router.get("/runs", authenticateJWT, async (req, res) => {
const { userId } = req as OperationsRequest;
try {
const viewAll = await permissions.hasPermission(userId, VIEW_ALL_RUNS);
const hostId = limitFrom(req.query.hostId);
let managed = false;
if (!viewAll && hostId !== undefined) {
managed = (await permissions.canAccessHost(userId, hostId, "manage"))
.hasAccess;
}
const runs = await createCurrentOperationRepository().listRuns(
{
userId: viewAll || managed ? undefined : userId,
hostId,
actionId:
typeof req.query.actionId === "string"
? req.query.actionId
: undefined,
status:
typeof req.query.status === "string" ? req.query.status : undefined,
batchId:
typeof req.query.batchId === "string" ? req.query.batchId : undefined,
before:
typeof req.query.before === "string" ? req.query.before : undefined,
},
limitFrom(req.query.limit),
);
res.json({ runs: runs.map(publicRun) });
} catch (error) {
sendError(res, error, "operations_runs");
}
});
/**
* @openapi
* /operations/runs/{id}:
* get:
* summary: Get a run
* tags:
* - Operations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The run.
* 404:
* description: Not found, or not visible to the caller.
*/
router.get("/runs/:id", authenticateJWT, async (req, res) => {
const { userId } = req as OperationsRequest;
try {
const run = await createCurrentOperationRepository().findRun(
String(req.params.id),
);
if (!run || !(await canSeeRun(run, userId))) {
res.status(404).json({ error: "Run not found" });
return;
}
res.json(publicRun(run));
} catch (error) {
sendError(res, error, "operations_run");
}
});
/**
* @openapi
* /operations/runs/{id}/cancel:
* post:
* summary: Cancel a run
* description: Stops a running action, or withdraws one waiting for approval.
* tags:
* - Operations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Whether anything was cancelled.
* 404:
* description: Not found, or not visible to the caller.
*/
router.post("/runs/:id/cancel", authenticateJWT, async (req, res) => {
const { userId } = req as OperationsRequest;
try {
const run = await createCurrentOperationRepository().findRun(
String(req.params.id),
);
if (!run || !(await canSeeRun(run, userId))) {
res.status(404).json({ error: "Run not found" });
return;
}
res.json({ cancelled: await cancelOperationRun(run) });
} catch (error) {
sendError(res, error, "operations_cancel");
}
});
/**
* @openapi
* /operations/runs/{id}/undo:
* post:
* summary: Undo a run
* description: Runs the action's undo with the input the run recorded. It goes through the same checks as any run.
* tags:
* - Operations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The new run.
* 404:
* description: Not found, or nothing to undo.
*/
router.post("/runs/:id/undo", authenticateJWT, async (req, res) => {
const authReq = req as OperationsRequest;
try {
const run = await createCurrentOperationRepository().findRun(
String(req.params.id),
);
const undo = run ? getAction(run.actionId)?.action.undo : undefined;
if (
!run ||
!undo ||
!run.undoInput ||
!(await canSeeRun(run, authReq.userId))
) {
res.status(404).json({ error: "Nothing to undo" });
return;
}
res.json(
await runOperation({
actionId: undo,
hostId: run.hostId ?? undefined,
input: parseJson(run.undoInput),
confirmed: req.body?.confirmed === true,
undoOf: run.id,
userId: authReq.userId,
origin: originOf(authReq),
apiKeyScopes: authReq.apiKeyScopes,
}),
);
} catch (error) {
sendError(res, error, "operations_undo");
}
});
/**
* @openapi
* /operations/approvals:
* get:
* summary: List waiting approvals
* description: Requests the caller could decide, with the exact input that would run.
* tags:
* - Operations
* responses:
* 200:
* description: Waiting approvals.
*/
router.get("/approvals", authenticateJWT, async (req, res) => {
const { userId } = req as OperationsRequest;
try {
const repository = createCurrentOperationRepository();
const approvals = [];
for (const approval of await repository.listPendingApprovals()) {
const run = await repository.findRun(approval.runId);
if (run && (await canSeeRun(run, userId))) {
approvals.push({ ...approval, run: publicRun(run) });
}
}
res.json({ approvals });
} catch (error) {
sendError(res, error, "operations_approvals");
}
});
/**
* @openapi
* /operations/approvals/{id}:
* post:
* summary: Approve or reject a request
* description: One shot. What runs is the sealed request, never anything sent here.
* tags:
* - Operations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [approve]
* properties:
* approve:
* type: boolean
* comment:
* type: string
* responses:
* 200:
* description: The run's outcome.
* 403:
* description: The caller may not decide this.
* 409:
* description: Already decided, expired, or the host changed.
*/
router.post("/approvals/:id", authenticateJWT, async (req, res) => {
const authReq = req as OperationsRequest;
if (typeof req.body?.approve !== "boolean") {
res.status(400).json({ error: "approve must be true or false" });
return;
}
// A key could be handed to a script, so a person decides from a session.
if (authReq.apiKeyScopes) {
res.status(403).json({ error: "This API key may not approve" });
return;
}
try {
res.json(
await decideOperationApproval({
approvalId: String(req.params.id),
approverId: authReq.userId,
approve: req.body.approve,
comment:
typeof req.body.comment === "string" ? req.body.comment : undefined,
impersonated: !!authReq.actingAdminUserId,
}),
);
} catch (error) {
sendError(res, error, "operations_approve");
}
});
/**
* @openapi
* /operations/events:
* get:
* summary: List declared events
* tags:
* - Operations
* responses:
* 200:
* description: Declared events from running plugins, plus core's run events.
*/
router.get("/events", authenticateJWT, async (req, res) => {
const { userId } = req as OperationsRequest;
try {
const catalog = await buildCatalog({
userId,
lang: typeof req.query.lang === "string" ? req.query.lang : undefined,
});
res.json({ events: catalog.events });
} catch (error) {
sendError(res, error, "operations_events");
}
});
/**
* @openapi
* /operations/timeline:
* get:
* summary: Recent events
* description: Events the caller may see, newest first. Events tied to a host need access to it.
* tags:
* - Operations
* parameters:
* - in: query
* name: hostId
* schema:
* type: integer
* - in: query
* name: before
* schema:
* type: string
* - in: query
* name: limit
* schema:
* type: integer
* responses:
* 200:
* description: Events.
*/
router.get("/timeline", authenticateJWT, async (req, res) => {
const { userId } = req as OperationsRequest;
try {
const rows = await createCurrentOperationRepository().listEvents(
{
hostId: limitFrom(req.query.hostId),
before:
typeof req.query.before === "string" ? req.query.before : undefined,
},
limitFrom(req.query.limit),
);
const viewAll = await permissions.hasPermission(userId, VIEW_ALL_RUNS);
const access = new Map<number, boolean>();
const events = [];
for (const row of rows) {
if (row.hostId === null) {
if (viewAll) events.push(row);
continue;
}
let allowed = access.get(row.hostId);
if (allowed === undefined) {
allowed = (
await permissions.canAccessHost(userId, row.hostId, "connect")
).hasAccess;
access.set(row.hostId, allowed);
}
if (allowed) events.push(row);
}
res.json({
events: events.map((event) => ({
...event,
payload: parseJson(event.payload),
})),
});
} catch (error) {
sendError(res, error, "operations_timeline");
}
});
/**
* @openapi
* /operations/signals:
* get:
* summary: Read status tiles
* description: Answers from memory right away and refreshes stale hosts behind it, so a page should poll. Reads hosts the caller can reach.
* tags:
* - Operations
* parameters:
* - in: query
* name: hostIds
* description: Comma separated. Leave out for every host.
* schema:
* type: string
* - in: query
* name: ids
* description: Comma separated signal ids. Leave out for all.
* schema:
* type: string
* responses:
* 200:
* description: One entry per signal with per host results.
*/
router.get("/signals", authenticateJWT, async (req, res) => {
const authReq = req as OperationsRequest;
try {
const catalog = await buildCatalog({ userId: authReq.userId });
const wanted =
typeof req.query.ids === "string" && req.query.ids
? new Set(req.query.ids.split(","))
: null;
const hostIds = hostIdsFrom(req.query.hostIds);
const signals = [];
for (const signal of catalog.signals) {
if (wanted && !wanted.has(signal.id)) continue;
if (signal.scope === "server") {
const outcome = await runOperation({
actionId: signal.read,
internal: true,
userId: authReq.userId,
origin: originOf(authReq),
}).catch(() => null);
signals.push({
id: signal.id,
hosts: [],
result: outcome?.data as SignalResult | undefined,
});
continue;
}
signals.push({
id: signal.id,
hosts: await readAcrossHosts<SignalResult>({
userId: authReq.userId,
actionId: signal.read,
hostIds,
origin: originOf(authReq),
}),
});
}
res.json({ signals });
} catch (error) {
sendError(res, error, "operations_signals");
}
});
/**
* @openapi
* /operations/resources/{id}:
* get:
* summary: Read one kind of thing across hosts
* description: Rows from cache plus each host's state (ok, loading, offline, error, no-access). Answers right away and refreshes stale hosts behind it, so a page should poll until no host is loading.
* tags:
* - Operations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* - in: query
* name: hostIds
* description: Comma separated. Leave out for every host.
* schema:
* type: string
* - in: query
* name: refresh
* schema:
* type: boolean
* responses:
* 200:
* description: Rows and per host state.
* 404:
* description: No such resource, or the caller may not read it.
*/
router.get("/resources/:id", authenticateJWT, async (req, res) => {
const authReq = req as OperationsRequest;
try {
const catalog = await buildCatalog({ userId: authReq.userId });
const resource = catalog.resources.find(
(entry) => entry.id === req.params.id,
);
if (!resource) {
res.status(404).json({ error: "Resource not found" });
return;
}
const reads = await readAcrossHosts<ResourceListResult>({
userId: authReq.userId,
actionId: resource.list,
hostIds: hostIdsFrom(req.query.hostIds),
refresh: req.query.refresh === "true",
origin: originOf(authReq),
});
res.json(flattenResourceRows(reads, resource.key));
} catch (error) {
sendError(res, error, "operations_resources");
}
});
/**
* @openapi
* /operations/{id}/run:
* post:
* summary: Run an action
* description: Goes through permission, host access, input and approval checks. A change returns as soon as it starts unless wait is true, then poll the run. A read always answers inline.
* tags:
* - Operations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* resourceKey:
* type: string
* input:
* type: object
* dryRun:
* type: boolean
* confirmed:
* type: boolean
* idempotencyKey:
* type: string
* wait:
* type: boolean
* responses:
* 200:
* description: The outcome, or a run waiting for approval.
* 400:
* description: Invalid input.
* 403:
* description: Not allowed.
* 409:
* description: Needs a confirm, or safe mode is on.
*/
router.post("/:id/run", authenticateJWT, async (req, res) => {
const authReq = req as OperationsRequest;
const body = (req.body ?? {}) as Record<string, unknown>;
try {
res.json(
await runOperation({
actionId: String(req.params.id),
hostId: typeof body.hostId === "number" ? body.hostId : undefined,
resourceKey:
typeof body.resourceKey === "string" ? body.resourceKey : undefined,
input: body.input,
dryRun: body.dryRun === true,
confirmed: body.confirmed === true,
idempotencyKey:
typeof body.idempotencyKey === "string"
? body.idempotencyKey.slice(0, 128)
: undefined,
userId: authReq.userId,
origin: originOf(authReq),
apiKeyScopes: authReq.apiKeyScopes,
}),
);
} catch (error) {
sendError(res, error, "operations_run_action");
}
});
export default router;
@@ -99,7 +99,12 @@ async function prepareKey(
if (passphrase) config.passphrase = passphrase; if (passphrase) config.passphrase = passphrase;
// Lets a server that wants key then password (or password only) still work. // Lets a server that wants key then password (or password only) still work.
if (host.password) config.password = host.password; // ssh2 tries password first by default, so it has none left to answer with
// once the key was accepted as the first step.
if (host.password) {
config.password = host.password;
config.authHandler = ["publickey", "password", "keyboard-interactive"];
}
const certPublicKey = host.certPublicKey; const certPublicKey = host.certPublicKey;
if (certPublicKey && certPublicKey.trim()) { if (certPublicKey && certPublicKey.trim()) {
+129
View File
@@ -0,0 +1,129 @@
/**
* Who may run what without a person saying yes.
*
* A plugin fills this through ctx.operations.approvals.provide. Core asks every
* provider and the strictest answer wins. A provider that throws denies the
* run, since skipping a safety rule is worse than a refused action.
*/
import type { OperationRisk } from "@termix-ssh/plugin-sdk/operations";
import { riskRank } from "@termix-ssh/plugin-sdk/operations";
import { hasCapability } from "../plugins/permissions.js";
import { pluginLogger } from "../utils/logger.js";
import type { ActorOrigin } from "../plugins/actor.js";
import type { PluginManifest } from "../plugins/manifest.js";
export interface ApprovalPolicyInput {
actionId: string;
pluginId: string;
/** What the plugin declared. */
risk: OperationRisk;
/** What rules should use. Plugins not signed as official count as change. */
effectiveRisk: OperationRisk;
origin: ActorOrigin;
userId: string;
hostId?: number;
hostTags?: string[];
batchId?: string;
}
export interface ApprovalDecision {
decision: "allow" | "approve" | "deny";
reason?: string;
/** The requester may approve their own run. */
allowSelf?: boolean;
expiresInMinutes?: number;
}
export type ApprovalPolicyProvider = (
input: ApprovalPolicyInput,
) =>
| ApprovalDecision
| null
| undefined
| Promise<ApprovalDecision | null | undefined>;
interface Entry {
pluginId: string;
manifest: PluginManifest;
provider: ApprovalPolicyProvider;
}
const providers = new Map<symbol, Entry>();
export function provideApprovalPolicy(
pluginId: string,
manifest: PluginManifest,
provider: ApprovalPolicyProvider,
): () => void {
const key = Symbol(pluginId);
providers.set(key, { pluginId, manifest, provider });
return () => {
providers.delete(key);
};
}
/** Test seam. */
export function clearApprovalPolicies(): void {
providers.clear();
}
const RANK = { allow: 0, approve: 1, deny: 2 } as const;
const TRUSTED_VIA = new Set(["automation", "rollout", "fleet"]);
const SELF_APPROVE_VIA = new Set(["assistant", "mcp", "automation"]);
/** A person is the check when the request came from something else. */
export function canSelfApprove(origin: ActorOrigin): boolean {
return origin.kind === "plugin" && SELF_APPROVE_VIA.has(origin.via ?? "");
}
/**
* Used when no provider has an opinion. People and their own keys go by RBAC,
* and so do automations they set up. Anything else needs a rule that allows it.
*/
function builtIn(input: ApprovalPolicyInput): ApprovalDecision {
if (riskRank(input.effectiveRisk) === 0) return { decision: "allow" };
if (input.origin.kind !== "plugin") return { decision: "allow" };
if (TRUSTED_VIA.has(input.origin.via ?? "")) return { decision: "allow" };
return {
decision: "deny",
reason: "This needs an approval rule. Turn on approvals to allow it.",
};
}
export async function evaluateApproval(
input: ApprovalPolicyInput,
): Promise<ApprovalDecision> {
let strictest: ApprovalDecision | null = null;
for (const entry of providers.values()) {
let decision: ApprovalDecision | null | undefined;
try {
// Rechecked at use, so a revoked grant stops the provider now.
if (
!(await hasCapability(
entry.pluginId,
"operations:approve",
entry.manifest.capabilities,
))
) {
continue;
}
decision = await entry.provider(input);
} catch (error) {
pluginLogger.warn("Approval rule failed, refusing the run", {
operation: "operation_approval",
pluginId: entry.pluginId,
error: error instanceof Error ? error.message : String(error),
});
return {
decision: "deny",
reason: "Approval rules are not available right now.",
};
}
if (!decision) continue;
if (!strictest || RANK[decision.decision] > RANK[strictest.decision]) {
strictest = decision;
}
}
return strictest ?? builtIn(input);
}
+251
View File
@@ -0,0 +1,251 @@
/**
* What a user can do right now, in their language.
*
* Built from the manifests of running plugins, never from GET /plugins, which
* hands every signed-in user the raw manifest. A plugin's locale file loads
* here on the server, because its frontend may not be loaded on the client:
* the CLI has no frontend, and a linked desktop may not have the plugin at all.
*/
import fs from "node:fs";
import path from "node:path";
import {
onSurface,
toJsonSchema,
type OperationAction,
type OperationEvent,
type OperationField,
type OperationResource,
type OperationSignal,
type OperationSurfaces,
} from "@termix-ssh/plugin-sdk/operations";
import { getAction } from "./registry.js";
import { qualifyPermission, type PluginManifest } from "../plugins/manifest.js";
import { PermissionManager } from "../utils/permission-manager.js";
interface CatalogPlugin {
id: string;
dir: string;
manifest: PluginManifest;
state: string;
official: boolean;
}
export interface CatalogField extends OperationField {
label: string;
description?: string;
placeholder?: string;
optionLabels?: Record<string, string>;
}
export interface CatalogAction extends Omit<
OperationAction,
"inputs" | "outputs"
> {
pluginId: string;
pluginName: string;
official: boolean;
title: string;
description?: string;
inputs: CatalogField[];
outputs: CatalogField[];
inputSchema: Record<string, unknown>;
available: boolean;
}
export interface CatalogEvent extends OperationEvent {
pluginId: string;
title: string;
}
export interface CatalogResource extends OperationResource {
pluginId: string;
title: string;
columns: (NonNullable<OperationResource["columns"]>[number] & {
title: string;
})[];
}
export interface CatalogSignal extends OperationSignal {
pluginId: string;
title: string;
}
export interface OperationCatalog {
actions: CatalogAction[];
events: CatalogEvent[];
resources: CatalogResource[];
signals: CatalogSignal[];
}
type Strings = Record<string, string>;
const localeCache = new Map<string, Strings>();
function flatten(value: unknown, prefix: string, out: Strings): void {
if (typeof value === "string") {
out[prefix] = value;
return;
}
if (typeof value !== "object" || value === null) return;
for (const [key, child] of Object.entries(value)) {
flatten(child, prefix ? `${prefix}.${key}` : key, out);
}
}
function readLocale(file: string): Strings {
const out: Strings = {};
try {
flatten(JSON.parse(fs.readFileSync(file, "utf8")), "", out);
} catch {
// A missing or broken file falls back to the key.
}
return out;
}
/** Locale names reach a path, so only a plain language tag is used. */
function safeLanguage(lang: string | undefined): string {
return lang && /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})?$/.test(lang) ? lang : "en";
}
function stringsFor(plugin: CatalogPlugin, lang: string): Strings {
const cacheKey = `${plugin.id}@${plugin.manifest.version}:${lang}`;
const cached = localeCache.get(cacheKey);
if (cached) return cached;
const dir = path.resolve(plugin.dir, plugin.manifest.locales ?? "locales");
const strings = readLocale(path.join(dir, "en.json"));
if (lang !== "en") {
Object.assign(
strings,
readLocale(path.join(dir, "translated", `${lang}.json`)),
readLocale(path.join(dir, `${lang}.json`)),
);
}
localeCache.set(cacheKey, strings);
return strings;
}
function localizedField(field: OperationField, strings: Strings): CatalogField {
const text = (key?: string) => (key ? (strings[key] ?? key) : undefined);
const optionLabels: Record<string, string> = {};
for (const option of field.options ?? []) {
optionLabels[option.value] = text(option.labelKey) ?? option.value;
}
return {
...field,
label: text(field.labelKey) ?? field.key,
description: text(field.descriptionKey),
placeholder: text(field.placeholderKey),
optionLabels: field.options ? optionLabels : undefined,
};
}
async function runningPlugins(): Promise<CatalogPlugin[]> {
const { getPluginRuntime } = await import("../plugins/index.js");
return getPluginRuntime()
.loader.list()
.filter((plugin) => plugin.state === "active")
.map((plugin) => ({
id: plugin.id,
dir: plugin.dir,
manifest: plugin.manifest,
state: plugin.state,
official: plugin.source === "bundled" || plugin.signedTier === "official",
}));
}
export async function buildCatalog(options: {
userId: string;
lang?: string;
surface?: keyof OperationSurfaces;
}): Promise<OperationCatalog> {
const lang = safeLanguage(options.lang);
const permissions = PermissionManager.getInstance();
const catalog: OperationCatalog = {
actions: [],
events: [],
resources: [],
signals: [],
};
for (const plugin of await runningPlugins()) {
const operations = plugin.manifest.contributes?.operations;
if (!operations) continue;
const strings = stringsFor(plugin, lang);
const text = (key: string) => strings[key] ?? key;
for (const action of operations.actions ?? []) {
if (options.surface && !onSurface(action, options.surface)) continue;
// Community plugins stay away from the assistant and MCP until an admin allows them.
if (
!plugin.official &&
(options.surface === "assistant" || options.surface === "mcp")
) {
continue;
}
const permission = qualifyPermission(plugin.id, action.permission);
if (!(await permissions.hasPermission(options.userId, permission))) {
continue;
}
const inputs = (action.inputs ?? []).map((field) =>
localizedField(field, strings),
);
catalog.actions.push({
...action,
pluginId: plugin.id,
pluginName: plugin.manifest.name,
official: plugin.official,
title: text(action.titleKey),
description: action.descriptionKey
? text(action.descriptionKey)
: undefined,
inputs,
outputs: (action.outputs ?? [])
.filter((field) => !field.sensitive)
.map((field) => localizedField(field, strings)),
inputSchema: toJsonSchema(
action.inputs,
(field) =>
inputs.find((input) => input.key === field.key)?.description,
),
available: getAction(action.id) !== undefined,
});
}
const visible = new Set(catalog.actions.map((action) => action.id));
for (const event of operations.events ?? []) {
catalog.events.push({
...event,
pluginId: plugin.id,
title: text(event.titleKey),
});
}
for (const resource of operations.resources ?? []) {
if (!visible.has(resource.list)) continue;
catalog.resources.push({
...resource,
pluginId: plugin.id,
title: text(resource.titleKey),
columns: (resource.columns ?? []).map((column) => ({
...column,
title: text(column.titleKey),
})),
});
}
for (const signal of operations.signals ?? []) {
if (!visible.has(signal.read)) continue;
catalog.signals.push({
...signal,
pluginId: plugin.id,
title: text(signal.titleKey),
});
}
}
return catalog;
}
/** Test seam. */
export function clearCatalogCache(): void {
localeCache.clear();
}
+8
View File
@@ -0,0 +1,8 @@
/** How many events or actions may lead to one another before the chain stops. */
export const MAX_EVENT_DEPTH = 3;
/** How long finished runs and events stay in history. */
export const RETENTION_DAYS = 90;
/** How long a cached table row or tile counts as fresh. */
export const RESOURCE_FRESH_MS = 60_000;
+155
View File
@@ -0,0 +1,155 @@
/**
* Declared events, separate from the plugin.* bus any plugin can read.
*
* Each event is kept in a capped timeline. A subscriber bound to a user only
* hears about hosts that user can reach, checked at delivery. Events carry a
* depth, so a rule that fires an action that fires the same rule stops.
*/
import { randomUUID } from "node:crypto";
import { MAX_EVENT_DEPTH } from "./constants.js";
import { getActorTrail, runAsActor } from "../plugins/actor.js";
import { PermissionManager } from "../utils/permission-manager.js";
import { pluginLogger } from "../utils/logger.js";
import { createCurrentOperationRepository } from "../database/repositories/factory.js";
export interface OperationEventInput {
eventId: string;
pluginId: string;
hostId?: number;
severity?: string;
payload?: unknown;
runId?: string;
depth?: number;
}
export interface DeliveredEvent {
id: string;
eventId: string;
pluginId: string;
hostId?: number;
severity?: string;
payload?: unknown;
runId?: string;
depth: number;
at: string;
}
export interface OperationEventSubscription {
/** Exact id, `<pluginId>.*` or `*`. */
pattern: string;
handler: (event: DeliveredEvent) => void | Promise<void>;
/** Only events for hosts this user can reach, and runs the handler as them. */
userId?: string;
/** Skips repeats of one event and host inside this window. */
cooldownMs?: number;
}
interface Subscriber extends OperationEventSubscription {
pluginId: string;
lastFired: Map<string, number>;
}
const subscribers = new Set<Subscriber>();
function matches(pattern: string, eventId: string): boolean {
if (pattern === "*" || pattern === eventId) return true;
return pattern.endsWith(".*") && eventId.startsWith(pattern.slice(0, -1));
}
export function subscribeOperationEvents(
pluginId: string,
subscription: OperationEventSubscription,
): () => void {
const entry = { ...subscription, pluginId, lastFired: new Map() };
subscribers.add(entry);
return () => {
subscribers.delete(entry);
};
}
async function deliver(subscriber: Subscriber, event: DeliveredEvent) {
const key = `${event.eventId}|${event.hostId ?? ""}`;
if (subscriber.cooldownMs) {
const last = subscriber.lastFired.get(key) ?? 0;
if (Date.now() - last < subscriber.cooldownMs) return;
subscriber.lastFired.set(key, Date.now());
}
const { userId } = subscriber;
if (userId && event.hostId !== undefined) {
const access = await PermissionManager.getInstance().canAccessHost(
userId,
event.hostId,
"connect",
);
if (!access.hasAccess) return;
}
const fire = () => Promise.resolve(subscriber.handler(event));
await (userId
? runAsActor(userId, "service", fire, undefined, { depth: event.depth })
: fire());
}
/** Never throws. An event must not be able to fail what caused it. */
export async function emitOperationEvent(
input: OperationEventInput,
): Promise<void> {
try {
const trail = getActorTrail();
const depth = input.depth ?? trail.depth ?? 0;
const event: DeliveredEvent = {
id: randomUUID(),
eventId: input.eventId,
pluginId: input.pluginId,
hostId: input.hostId,
severity: input.severity,
payload: input.payload,
runId: input.runId ?? trail.runId,
depth,
at: new Date().toISOString(),
};
await createCurrentOperationRepository().insertEvent({
id: event.id,
eventId: event.eventId,
pluginId: event.pluginId,
hostId: event.hostId ?? null,
severity: event.severity ?? null,
payload:
event.payload === undefined ? null : JSON.stringify(event.payload),
runId: event.runId ?? null,
createdAt: event.at,
});
if (depth >= MAX_EVENT_DEPTH) {
pluginLogger.warn("Event chain stopped at the depth limit", {
operation: "operation_event_depth",
eventId: event.eventId,
});
return;
}
for (const subscriber of subscribers) {
if (!matches(subscriber.pattern, event.eventId)) continue;
void deliver(subscriber, event).catch((error) => {
pluginLogger.warn("Event subscriber failed", {
operation: "operation_event_delivery",
pluginId: subscriber.pluginId,
error: error instanceof Error ? error.message : String(error),
});
});
}
} catch (error) {
pluginLogger.warn("Could not record an event", {
operation: "operation_event",
error: error instanceof Error ? error.message : String(error),
});
}
}
/** Test seam. */
export function clearOperationEvents(): void {
subscribers.clear();
}
+891
View File
@@ -0,0 +1,891 @@
/**
* The one path every action takes, from any surface.
*
* In order: plugin up, grant still held, input valid, role permission, host
* access, key scope, destructive confirm, approval, limits, run row, audit.
* Handlers run in-process, so this is an access check for callers, not a
* sandbox around plugins.
*
* Handler failures stay here. They never reach the plugin error budget, or five
* failed hosts in a rollout would switch the plugin off.
*/
import { randomUUID } from "node:crypto";
import {
capText,
matchesWhen,
parseOperationInput,
redactFields,
riskRank,
type OperationAction,
type OperationRisk,
} from "@termix-ssh/plugin-sdk/operations";
import { MAX_EVENT_DEPTH } from "./constants.js";
import { getAction, type OperationRegistration } from "./registry.js";
import {
canSelfApprove,
evaluateApproval,
type ApprovalDecision,
} from "./approvals.js";
import { withLimits } from "./limits.js";
import { emitOperationEvent } from "./events.js";
import { dropResourceCache } from "./resources.js";
import { loadOperationHost, type OperationHost } from "./hosts.js";
import { runAsActor, type ActorOrigin } from "../plugins/actor.js";
import { hasCapability } from "../plugins/permissions.js";
import { qualifyPermission } from "../plugins/manifest.js";
import { safeModeOn } from "../plugins/safe-mode.js";
import { PermissionManager } from "../utils/permission-manager.js";
import { logAudit } from "../utils/audit-logger.js";
import {
decryptSystemSecret,
encryptSystemSecret,
isSystemEncrypted,
} from "../utils/system-secret-crypto.js";
import { pluginLogger } from "../utils/logger.js";
import {
createCurrentOperationRepository,
createCurrentUserRepository,
} from "../database/repositories/factory.js";
import type {
OperationApprovalRecord,
OperationRunRecord,
} from "../database/repositories/operation-repository.js";
const MAX_OUTPUT = 32_000;
const DEFAULT_TIMEOUT_MS = 300_000;
const READ_TIMEOUT_MS = 60_000;
const DEFAULT_APPROVAL_MINUTES = 24 * 60;
const DRAIN_WAIT_MS = 10_000;
export type OperationErrorCode =
| "NOT_FOUND"
| "UNAVAILABLE"
| "INVALID_INPUT"
| "FORBIDDEN"
| "SCOPE"
| "NEEDS_CONFIRM"
| "DENIED"
| "TOO_DEEP"
| "STALE"
| "CONFLICT"
| "SAFE_MODE";
const STATUS: Record<OperationErrorCode, number> = {
NOT_FOUND: 404,
UNAVAILABLE: 503,
INVALID_INPUT: 400,
FORBIDDEN: 403,
SCOPE: 403,
NEEDS_CONFIRM: 409,
DENIED: 403,
TOO_DEEP: 409,
STALE: 409,
CONFLICT: 409,
SAFE_MODE: 409,
};
/** Refused before anything ran. Carries a status so routes can pass it on. */
export class OperationError extends Error {
readonly status: number;
constructor(
readonly code: OperationErrorCode,
message: string,
readonly details?: unknown,
) {
super(message);
this.name = "OperationError";
this.status = STATUS[code];
}
}
/** A handler throws this when a host needs a person to sign in first. */
export class OperationNeedsInteraction extends Error {
readonly code = "NEEDS_INTERACTION";
}
export interface OperationRequest {
actionId: string;
hostId?: number;
resourceKey?: string;
input?: unknown;
dryRun?: boolean;
/** A person agreed to a destructive action. */
confirmed?: boolean;
idempotencyKey?: string;
batchId?: string;
undoOf?: string;
/** Background reads for tables and tiles. Writes no run row. */
internal?: boolean;
userId: string;
origin: ActorOrigin;
apiKeyScopes?: string[] | null;
depth?: number;
}
export interface OperationOutcome {
runId: string | null;
status: string;
summary?: string;
changed?: boolean;
data?: unknown;
error?: string;
errorCode?: string;
approvalId?: string;
}
interface Prepared {
registration: OperationRegistration;
action: OperationAction;
pluginVersion: string;
input: Record<string, unknown>;
host: OperationHost | null;
effectiveRisk: OperationRisk;
}
/** The sealed copy an approval is bound to. */
interface ApprovalSnapshot {
runId: string;
actionId: string;
actionVersion: number;
pluginVersion: string;
hostId: number | null;
hostUpdatedAt: string | null;
resourceKey: string | null;
input: Record<string, unknown>;
}
const active = new Map<
string,
{ pluginId: string; controller: AbortController; done: Promise<unknown> }
>();
const draining = new Set<string>();
function now(): string {
return new Date().toISOString();
}
function errorText(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
async function loadedPlugin(pluginId: string) {
const { getPluginRuntime } = await import("../plugins/index.js");
return getPluginRuntime().loader.get(pluginId);
}
async function usernameOf(userId: string): Promise<string> {
try {
const user = await createCurrentUserRepository().findById(userId);
return user?.username ?? userId;
} catch {
return userId;
}
}
/** Every check that does not depend on who approved it. */
async function prepare(request: OperationRequest): Promise<Prepared> {
if (safeModeOn()) {
throw new OperationError("SAFE_MODE", "Actions are off in safe mode");
}
if ((request.depth ?? 0) > MAX_EVENT_DEPTH) {
throw new OperationError("TOO_DEEP", "Too many actions led to this one");
}
const registration = getAction(request.actionId);
if (!registration) {
throw new OperationError(
"NOT_FOUND",
`No action "${request.actionId}" is available`,
);
}
const { action, pluginId } = registration;
const plugin = await loadedPlugin(pluginId);
if (plugin?.state !== "active" || draining.has(pluginId)) {
throw new OperationError("UNAVAILABLE", `${pluginId} is not running`);
}
// Rechecked at use, so revoking the grant stops it on the next call.
if (
!(await hasCapability(
pluginId,
"operations:provide",
plugin.manifest.capabilities,
))
) {
throw new OperationError("UNAVAILABLE", `${pluginId} may not run actions`);
}
const parsed = parseOperationInput(action.inputs, request.input);
if (parsed.errors.length > 0) {
throw new OperationError(
"INVALID_INPUT",
parsed.errors.join("; "),
parsed.errors,
);
}
const permissions = PermissionManager.getInstance();
const permission = qualifyPermission(pluginId, action.permission);
if (!(await permissions.hasPermission(request.userId, permission))) {
throw new OperationError(
"FORBIDDEN",
`This needs the ${permission} permission`,
);
}
let host: OperationHost | null = null;
if (action.scope !== "server") {
if (request.hostId === undefined) {
throw new OperationError("INVALID_INPUT", "This action needs a host");
}
if (action.scope === "resource" && !request.resourceKey) {
throw new OperationError("INVALID_INPUT", "This action needs a target");
}
const access = await permissions.canAccessHost(
request.userId,
request.hostId,
action.hostAccess ?? "edit",
);
if (!access.hasAccess) {
throw new OperationError(
"FORBIDDEN",
`You need ${action.hostAccess ?? "edit"} access to this host`,
);
}
host = await loadOperationHost(request.hostId, plugin.manifest);
if (!host) throw new OperationError("NOT_FOUND", "Host not found");
if (!matchesWhen(action.when, host)) {
throw new OperationError(
"UNAVAILABLE",
"This action does not apply to this host",
);
}
}
// A claim by the plugin, so only signed official plugins are taken at
// their word when rules decide what needs a person.
const official =
plugin.source === "bundled" || plugin.signedTier === "official";
const effectiveRisk: OperationRisk =
!official && action.risk === "read" ? "change" : action.risk;
return {
registration,
action,
pluginVersion: plugin.manifest.version,
input: parsed.values,
host,
effectiveRisk,
};
}
function checkKeyScope(request: OperationRequest, action: OperationAction) {
const scopes = request.apiKeyScopes;
if (scopes === undefined || scopes === null) return;
if (scopes.includes("operations:run")) return;
if (scopes.includes("operations:read") && action.risk === "read") return;
throw new OperationError("SCOPE", "This API key may not run this action");
}
function originText(origin: ActorOrigin): string {
return JSON.stringify(origin);
}
function auditRun(
request: OperationRequest,
username: string,
action: OperationAction,
host: OperationHost | null,
success: boolean,
errorMessage?: string,
verb = "operation_run",
): void {
void logAudit({
userId: request.userId,
username,
action: verb,
resourceType: "operation",
resourceId: action.id,
resourceName: host?.name ?? undefined,
details: originText(request.origin),
success,
errorMessage,
}).catch(() => {});
}
async function execute(
prepared: Prepared,
request: OperationRequest,
runId: string | null,
username: string,
): Promise<OperationOutcome> {
const { action, registration, input, host } = prepared;
const repository = createCurrentOperationRepository();
const controller = new AbortController();
const dryRun = request.dryRun === true;
const timeoutMs =
action.timeoutMs ??
(action.risk === "read" ? READ_TIMEOUT_MS : DEFAULT_TIMEOUT_MS);
const lines: string[] = [];
const context = {
hostId: request.hostId,
resourceKey: request.resourceKey,
runId,
dryRun,
signal: controller.signal,
log: (line: string) => {
if (lines.length < 200) lines.push(line);
},
};
const work = async () => {
if (dryRun && action.dryRun !== "native") {
const summary = registration.handler.describe
? await registration.handler.describe(input, context)
: `Would run ${action.id}`;
return { summary, changed: false };
}
return registration.handler.run(input, context);
};
const timer = setTimeout(() => controller.abort(), timeoutMs);
timer.unref?.();
const aborted = new Promise<never>((_, reject) => {
controller.signal.addEventListener("abort", () =>
reject(new Error("The action was stopped or timed out")),
);
});
aborted.catch(() => {});
// Settles when the handler really stops, which can be after the run gives
// up on it. A plugin going down waits on this, not on the run.
let handlerStopped: () => void = () => {};
const stopped = new Promise<void>((resolve) => {
handlerStopped = resolve;
});
const startWork = () => {
// Cancelled while waiting for a slot, so it never starts.
if (controller.signal.aborted) {
handlerStopped();
return aborted;
}
const handling = Promise.resolve().then(work);
void handling.finally(handlerStopped).catch(() => {});
return Promise.race([handling, aborted]);
};
const running = withLimits(
{ hostId: request.hostId, exclusive: action.exclusive },
() =>
runAsActor(request.userId, "service", startWork, undefined, {
origin: request.origin,
apiKeyScopes: request.apiKeyScopes,
runId: runId ?? undefined,
depth: (request.depth ?? 0) + 1,
}),
);
if (runId) {
active.set(runId, {
pluginId: registration.pluginId,
controller,
done: stopped,
});
}
let outcome: OperationOutcome;
try {
const result = await running;
const data = redactFields(action.outputs, result.data);
outcome = {
runId,
status: "success",
summary: result.summary,
changed: result.changed,
data,
};
if (runId) {
await repository.updateRunFrom(runId, "running", {
status: "success",
summary: capText(result.summary ?? "", 2000),
changed: result.changed ?? null,
output:
data === undefined ? null : capText(JSON.stringify(data), MAX_OUTPUT),
undoInput: result.undoInput ? JSON.stringify(result.undoInput) : null,
sealedInput: null,
finishedAt: now(),
});
}
} catch (error) {
const needsPerson =
(error as { code?: unknown } | null)?.code === "NEEDS_INTERACTION";
const stopping = draining.has(registration.pluginId);
const status = needsPerson
? "needs_interaction"
: stopping
? "interrupted"
: "failed";
const message = stopping
? "The plugin stopped during this run. Check the host."
: capText(errorText(error), 2000);
outcome = { runId, status, error: message };
if (needsPerson) outcome.errorCode = "NEEDS_INTERACTION";
if (runId) {
await repository.updateRunFrom(runId, "running", {
status,
error: message,
errorCode: outcome.errorCode ?? null,
sealedInput: null,
finishedAt: now(),
});
}
} finally {
clearTimeout(timer);
if (runId && !draining.has(registration.pluginId)) active.delete(runId);
}
if (action.risk !== "read" && !dryRun) {
if (request.hostId !== undefined && outcome.status === "success") {
dropResourceCache(request.hostId);
}
auditRun(
request,
username,
action,
host,
outcome.status === "success",
outcome.error,
);
void emitOperationEvent({
eventId:
outcome.status === "success"
? "operation.succeeded"
: "operation.failed",
pluginId: registration.pluginId,
hostId: request.hostId,
severity: outcome.status === "success" ? "info" : "warning",
payload: {
actionId: action.id,
summary: outcome.summary,
error: outcome.error,
origin: request.origin,
},
runId: runId ?? undefined,
depth: (request.depth ?? 0) + 1,
});
}
return outcome;
}
function outcomeOf(
run: OperationRunRecord,
approvalId?: string,
): OperationOutcome {
return {
runId: run.id,
status: run.status,
summary: run.summary ?? undefined,
changed: run.changed ?? undefined,
error: run.error ?? undefined,
errorCode: run.errorCode ?? undefined,
approvalId,
};
}
async function requestApproval(
prepared: Prepared,
request: OperationRequest,
decision: ApprovalDecision,
run: { id: string; username: string; createdAt: string },
): Promise<OperationOutcome> {
const { action, host, input } = prepared;
const repository = createCurrentOperationRepository();
const snapshot: ApprovalSnapshot = {
runId: run.id,
actionId: action.id,
actionVersion: action.version ?? 1,
pluginVersion: prepared.pluginVersion,
hostId: request.hostId ?? null,
hostUpdatedAt: host?.updatedAt ?? null,
resourceKey: request.resourceKey ?? null,
input,
};
await repository.updateRunFrom(run.id, "created", {
status: "pending_approval",
summary: decision.reason ?? null,
sealedInput: await encryptSystemSecret(JSON.stringify(snapshot)),
});
const minutes = decision.expiresInMinutes ?? DEFAULT_APPROVAL_MINUTES;
const approvalId = randomUUID();
await repository.insertApproval({
id: approvalId,
runId: run.id,
requestedBy: request.userId,
reason: decision.reason ?? null,
allowSelf: decision.allowSelf === true || canSelfApprove(request.origin),
status: "pending",
createdAt: run.createdAt,
expiresAt: new Date(Date.now() + minutes * 60_000).toISOString(),
});
void emitOperationEvent({
eventId: "operation.approval-requested",
pluginId: prepared.registration.pluginId,
hostId: request.hostId,
severity: "warning",
payload: { actionId: action.id, approvalId },
runId: run.id,
});
return { runId: run.id, status: "pending_approval", approvalId };
}
/** Runs an action, or parks it for approval. Throws OperationError if refused. */
export async function runOperation(
request: OperationRequest,
): Promise<OperationOutcome> {
const registration = getAction(request.actionId);
if (registration) checkKeyScope(request, registration.action);
const repository = createCurrentOperationRepository();
if (request.idempotencyKey) {
const earlier = await repository.findByIdempotencyKey(
request.userId,
request.idempotencyKey,
);
if (earlier) return outcomeOf(earlier);
}
const prepared = await prepare(request);
const { action } = prepared;
const dryRun = request.dryRun === true;
const isRead = action.risk === "read";
if (action.risk === "destructive" && !dryRun && !request.confirmed) {
throw new OperationError("NEEDS_CONFIRM", "This action needs a confirm");
}
let decision: ApprovalDecision = { decision: "allow" };
if (!dryRun && !request.internal) {
decision = await evaluateApproval({
actionId: action.id,
pluginId: prepared.registration.pluginId,
risk: action.risk,
effectiveRisk: prepared.effectiveRisk,
origin: request.origin,
userId: request.userId,
hostId: request.hostId,
hostTags: prepared.host?.tags,
batchId: request.batchId,
});
}
const username = await usernameOf(request.userId);
if (decision.decision === "deny") {
auditRun(
request,
username,
action,
prepared.host,
false,
decision.reason,
"operation_denied",
);
throw new OperationError(
"DENIED",
decision.reason ?? "A rule refused this action",
);
}
// Internal reads poll constantly and would flood history and the save loop.
if (request.internal && isRead) {
return execute(prepared, request, null, username);
}
const run = {
id: randomUUID(),
actionId: action.id,
actionVersion: action.version ?? 1,
pluginId: prepared.registration.pluginId,
pluginVersion: prepared.pluginVersion,
userId: request.userId,
username,
origin: originText(request.origin),
hostId: request.hostId ?? null,
hostName: prepared.host?.name ?? null,
batchId: request.batchId ?? null,
dryRun,
risk: action.risk,
status: decision.decision === "approve" ? "created" : "running",
input: JSON.stringify(redactFields(action.inputs, prepared.input)),
undoOf: request.undoOf ?? null,
idempotencyKey: request.idempotencyKey ?? null,
createdAt: now(),
startedAt: decision.decision === "approve" ? null : now(),
};
await repository.insertRun(run);
if (decision.decision === "approve") {
return requestApproval(prepared, request, decision, run);
}
return execute(prepared, request, run.id, username);
}
function originOf(run: OperationRunRecord): ActorOrigin {
try {
return JSON.parse(run.origin) as ActorOrigin;
} catch {
return { kind: "api" };
}
}
async function failStale(run: OperationRunRecord, why: string): Promise<void> {
await createCurrentOperationRepository().updateRunFrom(
run.id,
"pending_approval",
{
status: "rejected",
error: why,
sealedInput: null,
finishedAt: now(),
},
);
}
/**
* Records a person's decision. One shot: the row moves from pending once, so a
* second click or a replay does nothing. What runs is the sealed copy, never
* what the caller sends now.
*/
export async function decideOperationApproval(options: {
approvalId: string;
approverId: string;
approve: boolean;
comment?: string;
impersonated?: boolean;
}): Promise<OperationOutcome> {
const { approvalId, approverId, approve, comment } = options;
const repository = createCurrentOperationRepository();
const approval = await repository.findApproval(approvalId);
if (!approval) throw new OperationError("NOT_FOUND", "Approval not found");
if (approval.status !== "pending") {
throw new OperationError("CONFLICT", "This was already decided");
}
const run = await repository.findRun(approval.runId);
if (!run) throw new OperationError("NOT_FOUND", "Run not found");
if (options.impersonated) {
throw new OperationError("FORBIDDEN", "You cannot approve as another user");
}
if (approval.requestedBy === approverId && !approval.allowSelf) {
throw new OperationError(
"FORBIDDEN",
"Someone else has to approve your request",
);
}
if (new Date(approval.expiresAt).getTime() < Date.now()) {
await repository.decideApproval(approvalId, "expired", null, null, now());
await failStale(run, "The approval expired");
throw new OperationError("STALE", "This approval expired");
}
// Approving needs the same rights as running it.
const registration = getAction(run.actionId);
if (registration) {
const permissions = PermissionManager.getInstance();
const needed = qualifyPermission(
registration.pluginId,
registration.action.permission,
);
const allowed =
(await permissions.hasPermission(approverId, needed)) &&
(run.hostId === null ||
(
await permissions.canAccessHost(
approverId,
run.hostId,
registration.action.hostAccess ?? "edit",
)
).hasAccess);
if (!allowed) {
throw new OperationError(
"FORBIDDEN",
"You need the same access as this action to approve it",
);
}
}
const won = await repository.decideApproval(
approvalId,
approve ? "approved" : "rejected",
approverId,
comment?.slice(0, 500) ?? null,
now(),
);
if (!won) throw new OperationError("CONFLICT", "This was already decided");
const username = await usernameOf(approverId);
void logAudit({
userId: approverId,
username,
action: approve ? "operation_approved" : "operation_rejected",
resourceType: "operation",
resourceId: run.actionId,
details: run.id,
success: true,
}).catch(() => {});
if (!approve) {
await repository.updateRunFrom(run.id, "pending_approval", {
status: "rejected",
sealedInput: null,
finishedAt: now(),
});
return { runId: run.id, status: "rejected" };
}
return runApproved(run, approval);
}
async function readSnapshot(
run: OperationRunRecord,
): Promise<ApprovalSnapshot | null> {
if (!run.sealedInput || !isSystemEncrypted(run.sealedInput)) return null;
try {
const snapshot = JSON.parse(
await decryptSystemSecret(run.sealedInput),
) as ApprovalSnapshot;
return snapshot.runId === run.id ? snapshot : null;
} catch {
return null;
}
}
async function runApproved(
run: OperationRunRecord,
approval: OperationApprovalRecord,
): Promise<OperationOutcome> {
const snapshot = await readSnapshot(run);
if (!snapshot || !run.userId) {
await failStale(run, "The approved request could not be read");
throw new OperationError("STALE", "The approved request could not be read");
}
const request: OperationRequest = {
actionId: snapshot.actionId,
hostId: snapshot.hostId ?? undefined,
resourceKey: snapshot.resourceKey ?? undefined,
input: snapshot.input,
confirmed: true,
batchId: run.batchId ?? undefined,
userId: run.userId,
origin: originOf(run),
};
// The requester's own rights, as of now. They may have changed.
let prepared: Prepared;
try {
prepared = await prepare(request);
} catch (error) {
await failStale(run, errorText(error));
throw error;
}
const changedSince =
prepared.pluginVersion !== snapshot.pluginVersion ||
(prepared.action.version ?? 1) !== snapshot.actionVersion ||
(prepared.host?.updatedAt ?? null) !== snapshot.hostUpdatedAt;
if (changedSince) {
await failStale(run, "The host or plugin changed after this was requested");
throw new OperationError(
"STALE",
"The host or plugin changed after this was requested",
);
}
const repository = createCurrentOperationRepository();
const claimed = await repository.updateRunFrom(run.id, "pending_approval", {
status: "running",
startedAt: now(),
});
if (!claimed) throw new OperationError("CONFLICT", "This already ran");
const outcome = await execute(prepared, request, run.id, run.username);
return { ...outcome, approvalId: approval.id };
}
export async function cancelOperationRun(
run: OperationRunRecord,
): Promise<boolean> {
const repository = createCurrentOperationRepository();
if (run.status === "pending_approval") {
const approval = await repository.findApprovalForRun(run.id);
if (approval) {
await repository.decideApproval(
approval.id,
"rejected",
null,
"cancelled",
now(),
);
}
return repository.updateRunFrom(run.id, "pending_approval", {
status: "cancelled",
sealedInput: null,
finishedAt: now(),
});
}
const running = active.get(run.id);
if (!running) return false;
running.controller.abort();
return true;
}
/**
* Called when a plugin stops, before its SSH connections are torn down. New
* runs are refused and running ones told to stop. Anything still going after
* the wait has an unknown outcome and is never retried.
*/
export async function drainPlugin(pluginId: string): Promise<void> {
draining.add(pluginId);
try {
const runs = [...active.entries()].filter(
([, entry]) => entry.pluginId === pluginId,
);
if (runs.length === 0) return;
for (const [, entry] of runs) entry.controller.abort();
await Promise.race([
Promise.all(runs.map(([, entry]) => entry.done)),
new Promise((resolve) => setTimeout(resolve, DRAIN_WAIT_MS)),
]);
const repository = createCurrentOperationRepository();
for (const [runId] of runs) {
await repository.updateRunFrom(runId, "running", {
status: "interrupted",
error: "The plugin stopped during this run. Check the host.",
sealedInput: null,
finishedAt: now(),
});
active.delete(runId);
}
} catch (error) {
pluginLogger.warn("Could not finish stopping runs", {
operation: "operation_drain",
pluginId,
error: errorText(error),
});
} finally {
draining.delete(pluginId);
}
}
export function riskAtLeast(
risk: OperationRisk,
floor: OperationRisk,
): boolean {
return riskRank(risk) >= riskRank(floor);
}
/** Test seam. */
export function clearOperationGate(): void {
active.clear();
draining.clear();
}
+68
View File
@@ -0,0 +1,68 @@
/**
* The small view of a host the operations layer needs: enough to name it, test
* an action's `when`, and notice it changed. No secret field is ever read here.
*/
import type { OperationWhen } from "@termix-ssh/plugin-sdk/operations";
import type { PluginManifest } from "../plugins/manifest.js";
import { getSetting } from "../plugins/settings.js";
import { createCurrentHostResolutionRepository } from "../database/repositories/factory.js";
export interface OperationHost {
id: number;
name: string;
updatedAt: string | null;
tags: string[];
os: string | null;
/** Only the setting keys some `when` asks about. */
settings: Record<string, unknown>;
}
export function splitTags(tags: string | null | undefined): string[] {
return (tags ?? "")
.split(",")
.map((tag) => tag.trim())
.filter(Boolean);
}
async function settingsFor(
manifest: PluginManifest,
hostId: number,
): Promise<Record<string, unknown>> {
const keys = new Set<string>();
for (const action of manifest.contributes?.operations?.actions ?? []) {
for (const key of Object.keys(action.when?.settings ?? {})) keys.add(key);
}
const settings: Record<string, unknown> = {};
for (const key of keys) {
settings[key] = await getSetting(manifest, "host", hostId, key);
}
return settings;
}
/** A host with the settings its plugin's `when` rules read. */
export async function loadOperationHost(
hostId: number,
manifest: PluginManifest,
): Promise<OperationHost | null> {
const repository = createCurrentHostResolutionRepository();
const ownerId = await repository.findHostOwnerId(hostId);
if (!ownerId) return null;
const host = (await repository.findHostById(hostId, ownerId)) as {
name: string | null;
ip: string;
tags: string | null;
updatedAt?: string | null;
} | null;
if (!host) return null;
return {
id: hostId,
name: host.name || host.ip,
updatedAt: host.updatedAt ?? null,
tags: splitTags(host.tags),
os: null,
settings: await settingsFor(manifest, hostId),
};
}
export type { OperationWhen };
+44
View File
@@ -0,0 +1,44 @@
/**
* What a scoped API key may reach. Enforced where keys are authenticated, so a
* limited key cannot step around the gate through /plugin-api or a ws ticket. A
* key with no scopes has full access, which is every key made before this.
*/
export const API_KEY_SCOPES = ["operations:read", "operations:run"] as const;
const MCP_PATH = /^\/plugin-api\/[a-z0-9-]+\/mcp(\/|$)/;
const OPERATIONS_PATH = /^\/operations(\/|$)/;
export function parseKeyScopes(
raw: string | null | undefined,
): string[] | null {
if (!raw) return null;
try {
const value = JSON.parse(raw);
return Array.isArray(value)
? value.filter((scope): scope is string => typeof scope === "string")
: [];
} catch {
// Unreadable means no access, not full access.
return [];
}
}
export function keyScopeAllows(
scopes: string[] | null,
method: string,
path: string,
): boolean {
if (scopes === null) return true;
const clean = path.split("?")[0];
if (MCP_PATH.test(clean)) {
return (
scopes.includes("operations:read") || scopes.includes("operations:run")
);
}
if (!OPERATIONS_PATH.test(clean)) return false;
if (scopes.includes("operations:run")) return true;
if (!scopes.includes("operations:read")) return false;
// The gate still checks each action's risk on a run.
return method === "GET" || /^\/operations\/[^/]+\/run$/.test(clean);
}
+90
View File
@@ -0,0 +1,90 @@
/**
* Caps on how much runs at once. The SSH pool caps per key, not per host, so a
* large rollout or an all hosts table needs its own limit here.
*/
export const MAX_RUNNING = 32;
export const MAX_PER_HOST = 3;
class Slots {
private used = 0;
private readonly waiting: (() => void)[] = [];
constructor(private readonly size: number) {}
async take(): Promise<void> {
if (this.used < this.size) {
this.used++;
return;
}
await new Promise<void>((resolve) => this.waiting.push(resolve));
}
give(): void {
const next = this.waiting.shift();
if (next) next();
else this.used--;
}
get idle(): boolean {
return this.used === 0 && this.waiting.length === 0;
}
}
const everything = new Slots(MAX_RUNNING);
const perHost = new Map<number, Slots>();
const exclusive = new Map<number, Slots>();
function slotsFor(
map: Map<number, Slots>,
hostId: number,
size: number,
): Slots {
let slots = map.get(hostId);
if (!slots) {
slots = new Slots(size);
map.set(hostId, slots);
}
return slots;
}
/** Runs `work` once there is room. Exclusive work on a host goes one at a time. */
export async function withLimits<T>(
options: { hostId?: number; exclusive?: boolean },
work: () => Promise<T>,
): Promise<T> {
const { hostId } = options;
const held: { map?: Map<number, Slots>; slots: Slots }[] = [];
const hold = async (slots: Slots, map?: Map<number, Slots>) => {
await slots.take();
held.push({ slots, map });
};
try {
// Same order everywhere, so two runs cannot wait on each other.
if (hostId !== undefined && options.exclusive) {
await hold(slotsFor(exclusive, hostId, 1), exclusive);
}
if (hostId !== undefined) {
await hold(slotsFor(perHost, hostId, MAX_PER_HOST), perHost);
}
await hold(everything);
return await work();
} finally {
for (const { slots, map } of held.reverse()) {
slots.give();
if (map && hostId !== undefined && slots.idle) map.delete(hostId);
}
}
}
/** Shares one in-flight promise per key, so one read serves every waiter. */
const inflight = new Map<string, Promise<unknown>>();
export function coalesce<T>(key: string, work: () => Promise<T>): Promise<T> {
const running = inflight.get(key);
if (running) return running as Promise<T>;
const started = work().finally(() => inflight.delete(key));
inflight.set(key, started);
return started;
}
+47
View File
@@ -0,0 +1,47 @@
/** Startup sweep and periodic cleanup for runs, approvals and events. */
import { RETENTION_DAYS } from "./constants.js";
import { pluginLogger } from "../utils/logger.js";
import { createCurrentOperationRepository } from "../database/repositories/factory.js";
const SWEEP_MS = 10 * 60_000;
let timer: ReturnType<typeof setInterval> | null = null;
/** Parks expired approvals and trims history. Returns rows removed. */
export async function sweepOperations(at = new Date()): Promise<number> {
const repository = createCurrentOperationRepository();
const iso = at.toISOString();
for (const approval of await repository.listExpiredApprovals(iso)) {
await repository.decideApproval(approval.id, "expired", null, null, iso);
await repository.updateRunFrom(approval.runId, "pending_approval", {
status: "expired",
sealedInput: null,
finishedAt: iso,
});
}
const cutoff = new Date(at.getTime() - RETENTION_DAYS * 86_400_000);
return repository.prune(cutoff.toISOString());
}
/** Anything still running at boot lost its process, so its outcome is unknown. */
export async function startOperationsMaintenance(): Promise<void> {
await createCurrentOperationRepository().markRunningInterrupted(
new Date().toISOString(),
);
await sweepOperations();
timer ??= setInterval(() => {
sweepOperations().catch((error) => {
pluginLogger.warn("Operations cleanup failed", {
operation: "operations_sweep",
error: error instanceof Error ? error.message : String(error),
});
});
}, SWEEP_MS);
timer.unref?.();
}
export function stopOperationsMaintenance(): void {
if (timer) clearInterval(timer);
timer = null;
}
+84
View File
@@ -0,0 +1,84 @@
/**
* Handlers plugins registered for the actions their manifest declares.
*
* Same generation rule as service-registry.ts: a plugin that crashed and
* restarted must not have its old disposer remove the new registration, and a
* run that resolved a handler before a reload must not call the stale one.
*/
import type {
OperationAction,
OperationResult,
} from "@termix-ssh/plugin-sdk/operations";
export interface OperationRunContext {
hostId?: number;
resourceKey?: string;
runId: string | null;
dryRun: boolean;
signal: AbortSignal;
log: (line: string) => void;
}
export interface OperationHandler {
run: (
input: Record<string, unknown>,
context: OperationRunContext,
) => Promise<OperationResult> | OperationResult;
/** What a dry run reports when the action has no native dry run. */
describe?: (
input: Record<string, unknown>,
context: OperationRunContext,
) => string | Promise<string>;
}
export interface OperationRegistration {
pluginId: string;
action: OperationAction;
handler: OperationHandler;
generation: number;
}
const registrations = new Map<string, OperationRegistration>();
let generationCounter = 0;
export function registerAction(
pluginId: string,
action: OperationAction,
handler: OperationHandler,
): OperationRegistration {
const existing = registrations.get(action.id);
if (existing && existing.pluginId !== pluginId) {
throw new Error(
`Action "${action.id}" is already registered by ${existing.pluginId}`,
);
}
const entry = {
pluginId,
action,
handler,
generation: ++generationCounter,
};
registrations.set(action.id, entry);
return entry;
}
/** Removes only the exact registration given. */
export function revokeAction(registration: OperationRegistration): boolean {
const current = registrations.get(registration.action.id);
if (!current || current.generation !== registration.generation) return false;
return registrations.delete(registration.action.id);
}
export function getAction(id: string): OperationRegistration | undefined {
return registrations.get(id);
}
export function listActions(): OperationRegistration[] {
return [...registrations.values()];
}
/** Test seam. */
export function clearOperationRegistry(): void {
registrations.clear();
}
+231
View File
@@ -0,0 +1,231 @@
/**
* Reads for All hosts tables and status tiles.
*
* Asking every host over SSH on each page load cannot hold up, so a request
* answers right away from memory and refreshes stale hosts behind it. The page
* polls and the table fills in as hosts answer. Results stay in memory, never
* SQLite, since a save rewrites the whole database.
*
* Keys include the user: someone a host is shared with can have their own
* login there and see different rows.
*/
import type {
ResourceListResult,
SignalResult,
SignalStatus,
} from "@termix-ssh/plugin-sdk/operations";
import { RESOURCE_FRESH_MS } from "./constants.js";
import { coalesce } from "./limits.js";
import { getAction } from "./registry.js";
import { matchesWhen } from "@termix-ssh/plugin-sdk/operations";
import { loadOperationHost } from "./hosts.js";
import type { ActorOrigin } from "../plugins/actor.js";
import { hostStatus } from "../plugins/host-status-provider.js";
import { PermissionManager } from "../utils/permission-manager.js";
import { loadProbeTargets } from "../plugins/ctx-probe.js";
export type HostReadState =
"ok" | "loading" | "offline" | "error" | "no-access" | "not-applicable";
interface CacheEntry {
value: unknown;
error?: string;
fetchedAt: number;
}
export interface HostRead<T> {
hostId: number;
state: HostReadState;
value?: T;
error?: string;
fetchedAt?: string;
}
const cache = new Map<string, CacheEntry>();
const MAX_ENTRIES = 20_000;
function cacheKey(userId: string, actionId: string, hostId: number): string {
return `${userId}|${actionId}|${hostId}`;
}
/** After a change, the next read of this host goes back to the host. */
export function dropResourceCache(hostId: number): void {
const suffix = `|${hostId}`;
for (const key of cache.keys()) {
if (key.endsWith(suffix)) cache.delete(key);
}
}
function remember(key: string, entry: CacheEntry): void {
if (cache.size >= MAX_ENTRIES) {
const oldest = cache.keys().next().value;
if (oldest) cache.delete(oldest);
}
cache.delete(key);
cache.set(key, entry);
}
function isOffline(hostId: number): boolean {
const status = hostStatus.get(hostId) as
string | { status?: string } | null | undefined;
const value = typeof status === "string" ? status : status?.status;
return value === "offline";
}
interface ReadOptions {
userId: string;
actionId: string;
hostIds?: number[];
refresh?: boolean;
input?: Record<string, unknown>;
origin: ActorOrigin;
}
function refreshHost(
options: ReadOptions,
hostId: number,
key: string,
): Promise<unknown> {
return coalesce(key, async () => {
const { runOperation, OperationError } = await import("./gate.js");
try {
const outcome = await runOperation({
actionId: options.actionId,
hostId,
input: options.input,
internal: true,
userId: options.userId,
origin: options.origin,
});
if (outcome.status !== "success") {
remember(key, {
value: undefined,
error: outcome.error ?? outcome.status,
fetchedAt: Date.now(),
});
return;
}
remember(key, { value: outcome.data, fetchedAt: Date.now() });
} catch (error) {
remember(key, {
value: undefined,
error:
error instanceof OperationError || error instanceof Error
? error.message
: String(error),
fetchedAt: Date.now(),
});
}
});
}
/** One entry per host the user can reach. Never waits on a host. */
export async function readAcrossHosts<T>(
options: ReadOptions,
): Promise<HostRead<T>[]> {
const registration = getAction(options.actionId);
if (!registration) return [];
const { action } = registration;
const { getPluginRuntime } = await import("../plugins/index.js");
const manifest = getPluginRuntime().loader.get(
registration.pluginId,
)?.manifest;
if (!manifest) return [];
const permissions = PermissionManager.getInstance();
const targets = await loadProbeTargets(options.userId, options.hostIds);
const reads: HostRead<T>[] = [];
for (const target of targets) {
const hostId = target.id;
const access = await permissions.canAccessHost(
options.userId,
hostId,
action.hostAccess ?? "connect",
);
if (!access.hasAccess) {
reads.push({ hostId, state: "no-access" });
continue;
}
if (action.when) {
const host = await loadOperationHost(hostId, manifest);
if (!host || !matchesWhen(action.when, host)) continue;
}
const key = cacheKey(options.userId, options.actionId, hostId);
const cached = cache.get(key);
const fresh = cached && Date.now() - cached.fetchedAt < RESOURCE_FRESH_MS;
if (isOffline(hostId)) {
reads.push({
hostId,
state: "offline",
value: cached?.value as T | undefined,
fetchedAt: cached
? new Date(cached.fetchedAt).toISOString()
: undefined,
});
continue;
}
if (!fresh || options.refresh) void refreshHost(options, hostId, key);
if (!cached) {
reads.push({ hostId, state: "loading" });
} else if (cached.error) {
reads.push({
hostId,
state: "error",
error: cached.error,
fetchedAt: new Date(cached.fetchedAt).toISOString(),
});
} else {
reads.push({
hostId,
state: "ok",
value: cached.value as T,
fetchedAt: new Date(cached.fetchedAt).toISOString(),
});
}
}
return reads;
}
export interface ResourceRow {
hostId: number;
key: string;
row: Record<string, unknown>;
}
/** Flattens per host reads into table rows, keeping each host's state. */
export function flattenResourceRows(
reads: HostRead<ResourceListResult>[],
keyField: string,
): { rows: ResourceRow[]; hosts: Omit<HostRead<unknown>, "value">[] } {
const rows: ResourceRow[] = [];
const hosts: Omit<HostRead<unknown>, "value">[] = [];
for (const read of reads) {
const { value, ...state } = read;
hosts.push(state);
for (const row of value?.rows ?? []) {
rows.push({ hostId: read.hostId, key: String(row[keyField]), row });
}
}
return { rows, hosts };
}
const WORST: SignalStatus[] = ["fail", "warn", "unknown", "ok"];
/** The worst status across hosts, for a server level tile. */
export function worstStatus(
results: (SignalResult | undefined)[],
): SignalStatus {
for (const status of WORST) {
if (results.some((result) => result?.status === status)) return status;
}
return "unknown";
}
/** Test seam. */
export function clearResourceCache(): void {
cache.clear();
}
+49 -2
View File
@@ -20,7 +20,28 @@
import { AsyncLocalStorage } from "node:async_hooks"; import { AsyncLocalStorage } from "node:async_hooks";
interface ActorStore { /** How a call reached core, for run history and approval rules. */
export interface ActorOrigin {
kind: "ui" | "api" | "plugin";
/** Which plugin asked, set by core from the ctx that made the call. */
pluginId?: string;
/** What the plugin was doing: automation, assistant, mcp, rollout and so on. */
via?: string;
}
/** Follows a call through plugin work, so nothing loses who started it. */
export interface ActorTrail {
origin?: ActorOrigin;
apiKeyId?: string;
/** Null means a key with full access. */
apiKeyScopes?: string[] | null;
/** The operation run this work belongs to. */
runId?: string;
/** How many operations or events led here. */
depth?: number;
}
interface ActorStore extends ActorTrail {
userId: string; userId: string;
/** Where the identity came from, for the audit line. */ /** Where the identity came from, for the audit line. */
source: "request" | "asUser" | "service"; source: "request" | "asUser" | "service";
@@ -35,8 +56,34 @@ export function runAsActor<T>(
source: ActorStore["source"], source: ActorStore["source"],
fn: () => T, fn: () => T,
sessionId?: string, sessionId?: string,
trail?: ActorTrail,
): T { ): T {
return storage.run({ userId, source, sessionId }, fn); const parent = storage.getStore();
return storage.run(
{
origin: parent?.origin,
apiKeyId: parent?.apiKeyId,
apiKeyScopes: parent?.apiKeyScopes,
runId: parent?.runId,
depth: parent?.depth,
...trail,
userId,
source,
sessionId,
},
fn,
);
}
export function getActorTrail(): ActorTrail {
const store = storage.getStore();
return {
origin: store?.origin,
apiKeyId: store?.apiKeyId,
apiKeyScopes: store?.apiKeyScopes,
runId: store?.runId,
depth: store?.depth,
};
} }
export function getActorSessionId(): string | undefined { export function getActorSessionId(): string | undefined {
+195
View File
@@ -0,0 +1,195 @@
/**
* ctx.operations: declaring what a plugin can do, and asking for another
* plugin's action to run.
*
* invoke acts for the current user, so on its own it would let any plugin spend
* that user's full authority. It needs the capability, the action named in the
* manifest's invokes list, and the origin always names the calling plugin, set
* here from the closure rather than from anything the plugin passes.
*/
import type {
PluginOperations,
PluginOperationInvokeOptions,
} from "@termix-ssh/plugin-sdk/backend";
import {
invokeAllowed,
type OperationAction,
} from "@termix-ssh/plugin-sdk/operations";
import type { PluginManifest } from "./manifest.js";
import { assertCapability, capabilityRefused } from "./permissions.js";
import { getActor, getActorTrail, type ActorOrigin } from "./actor.js";
import type { DisposableBag } from "./disposables.js";
import {
registerAction,
revokeAction,
type OperationHandler,
} from "../operations/registry.js";
import { provideApprovalPolicy } from "../operations/approvals.js";
import {
emitOperationEvent,
subscribeOperationEvents,
} from "../operations/events.js";
import {
drainPlugin,
OperationError,
runOperation,
} from "../operations/gate.js";
import { buildCatalog } from "../operations/catalog.js";
interface Deps {
manifest: PluginManifest;
bag: DisposableBag;
}
export function createPluginOperations({
manifest,
bag,
}: Deps): PluginOperations {
const pluginId = manifest.id;
const declared = manifest.capabilities;
const contribution = manifest.contributes?.operations;
let drainRegistered = false;
const declaredAction = (id: string): OperationAction => {
const action = contribution?.actions?.find((entry) => entry.id === id);
if (!action) {
throw new Error(
`Plugin ${pluginId} cannot register action "${id}": it is not declared in contributes.operations`,
);
}
return action;
};
const actingUser = (): string => {
const userId = getActor();
if (!userId) {
throw new Error(
"ctx.operations needs an acting user: call it inside a request or ctx.asUser",
);
}
return userId;
};
return {
register: (id, handler) => {
if (!declared.includes("operations:provide")) {
throw capabilityRefused(pluginId, "operations:provide");
}
const registration = registerAction(
pluginId,
declaredAction(id),
handler as OperationHandler,
);
bag.add(() => void revokeAction(registration), `action "${id}"`);
// Added once and after the actions, so disposal, which runs newest
// first, drains before anything the handlers use is torn down.
if (!drainRegistered) {
drainRegistered = true;
bag.add(() => drainPlugin(pluginId), "operations drain");
}
},
// A plugin's own route running its own action, so a button press lands in
// the same history. The caller stays the origin, not this plugin.
run: async (actionId, options = {}) => {
if (!declared.includes("operations:provide")) {
throw capabilityRefused(pluginId, "operations:provide");
}
declaredAction(actionId);
const trail = getActorTrail();
return runOperation({
actionId,
hostId: options.hostId,
resourceKey: options.resourceKey,
input: options.input,
dryRun: options.dryRun,
confirmed: options.confirmed,
idempotencyKey: options.idempotencyKey,
batchId: options.batchId,
userId: actingUser(),
origin: trail.origin ?? { kind: "plugin", pluginId },
apiKeyScopes: trail.apiKeyScopes,
depth: trail.depth,
});
},
invoke: async (actionId, options = {}) => {
await assertCapability(pluginId, "operations:invoke", declared);
if (!invokeAllowed(contribution?.invokes, actionId)) {
throw new OperationError(
"FORBIDDEN",
`${pluginId} did not declare that it runs ${actionId}`,
);
}
const trail = getActorTrail();
const origin: ActorOrigin = {
kind: "plugin",
pluginId,
via: (options as PluginOperationInvokeOptions).via,
};
return runOperation({
actionId,
hostId: options.hostId,
resourceKey: options.resourceKey,
input: options.input,
dryRun: options.dryRun,
confirmed: options.confirmed,
idempotencyKey: options.idempotencyKey,
batchId: options.batchId,
userId: actingUser(),
origin,
apiKeyScopes: trail.apiKeyScopes,
depth: trail.depth,
});
},
list: async (options) => {
await assertCapability(pluginId, "operations:invoke", declared);
return buildCatalog({
userId: actingUser(),
lang: options?.lang,
surface: options?.surface,
});
},
emit: async (eventId, payload, options) => {
if (!declared.includes("operations:provide")) {
throw capabilityRefused(pluginId, "operations:provide");
}
const event = contribution?.events?.find((entry) => entry.id === eventId);
if (!event) {
throw new Error(
`Plugin ${pluginId} cannot emit "${eventId}": it is not declared in contributes.operations`,
);
}
await emitOperationEvent({
eventId,
pluginId,
hostId: options?.hostId,
severity: event.severity,
payload,
});
},
onEvent: (subscription) => {
if (!declared.includes("operations:invoke")) {
throw capabilityRefused(pluginId, "operations:invoke");
}
const remove = subscribeOperationEvents(pluginId, subscription);
bag.add(remove, `events "${subscription.pattern}"`);
return remove;
},
approvals: {
provide: (provider) => {
if (!declared.includes("operations:approve")) {
throw capabilityRefused(pluginId, "operations:approve");
}
const remove = provideApprovalPolicy(pluginId, manifest, provider);
bag.add(remove, "approval rules");
return remove;
},
},
};
}
+3
View File
@@ -68,6 +68,7 @@ import { createPluginAuth, createPluginSsh } from "./ctx-ssh-auth.js";
import { createPluginHosts } from "./ctx-hosts.js"; import { createPluginHosts } from "./ctx-hosts.js";
import { createPluginSchedule } from "./schedule.js"; import { createPluginSchedule } from "./schedule.js";
import { createPluginFetch, createPluginNotify } from "./ctx-notify.js"; import { createPluginFetch, createPluginNotify } from "./ctx-notify.js";
import { createPluginOperations } from "./ctx-operations.js";
import { createPluginProcess } from "./ctx-process.js"; import { createPluginProcess } from "./ctx-process.js";
import { createPluginSystem } from "./ctx-system.js"; import { createPluginSystem } from "./ctx-system.js";
import { createPluginPlugins } from "./ctx-plugins.js"; import { createPluginPlugins } from "./ctx-plugins.js";
@@ -976,6 +977,8 @@ export function createPluginContext(
void reportRuntimeError(pluginId, error); void reportRuntimeError(pluginId, error);
}), }),
ssh: createPluginSsh({ manifest, bag: handle.bag, audit: auditCall }), ssh: createPluginSsh({ manifest, bag: handle.bag, audit: auditCall }),
operations: createPluginOperations({ manifest, bag: handle.bag }),
notify: createPluginNotify({ notify: createPluginNotify({
manifest, manifest,
audit: auditCall, audit: auditCall,
+8 -2
View File
@@ -354,15 +354,21 @@ export function createPluginRouter({
// After auth, so the actor is the user core authenticated rather than // After auth, so the actor is the user core authenticated rather than
// whatever the request claimed. // whatever the request claimed.
outer.use((req: Request, _res: Response, next: NextFunction) => { outer.use((req: Request, _res: Response, next: NextFunction) => {
const { userId, sessionId } = req as Request & { const { userId, sessionId, apiKeyId, apiKeyScopes } = req as Request & {
userId?: string; userId?: string;
sessionId?: string; sessionId?: string;
apiKeyId?: string;
apiKeyScopes?: string[] | null;
}; };
if (!userId) { if (!userId) {
next(); next();
return; return;
} }
runAsActor(userId, "request", () => next(), sessionId); runAsActor(userId, "request", () => next(), sessionId, {
origin: { kind: apiKeyId ? "api" : "ui" },
apiKeyId,
apiKeyScopes,
});
}); });
outer.use(inner); outer.use(inner);
+13 -2
View File
@@ -5,7 +5,7 @@
import { pluginLogger } from "../utils/logger.js"; import { pluginLogger } from "../utils/logger.js";
import { getPluginRuntime } from "./index.js"; import { getPluginRuntime } from "./index.js";
import { onboardingDefaults } from "./bundled-index.js"; import { isDefaultLookPlugin, onboardingDefaults } from "./bundled-index.js";
import { import {
isLinkedDesktop, isLinkedDesktop,
serialized, serialized,
@@ -99,6 +99,11 @@ export async function offerPluginSetup(fresh: boolean): Promise<void> {
await settings.set(PLUGIN_SETUP_OFFERED_KEY, "1"); await settings.set(PLUGIN_SETUP_OFFERED_KEY, "1");
} }
// The shipped default look always stays on, so the picker never offers it.
function inPicker(plugin: { id: string; source: string }): boolean {
return !(plugin.source === "bundled" && isDefaultLookPlugin(plugin.id));
}
function choicePlugins(): ChoicePlugin[] { function choicePlugins(): ChoicePlugin[] {
return getPluginRuntime() return getPluginRuntime()
.loader.list() .loader.list()
@@ -118,6 +123,7 @@ export async function describeOnboardingPlugins(): Promise<OnboardingPluginList>
); );
const plugins = getPluginRuntime() const plugins = getPluginRuntime()
.loader.list() .loader.list()
.filter(inPicker)
.map((plugin): OnboardingPluginInfo => { .map((plugin): OnboardingPluginInfo => {
const defaults = const defaults =
plugin.source === "bundled" plugin.source === "bundled"
@@ -158,7 +164,12 @@ export function applyOnboardingChoices(
): Promise<OnboardingApplyResult> { ): Promise<OnboardingApplyResult> {
return serialized(async () => { return serialized(async () => {
const plugins = choicePlugins(); const plugins = choicePlugins();
const installed = new Set(plugins.map((plugin) => plugin.id)); const installed = new Set(
getPluginRuntime()
.loader.list()
.filter(inPicker)
.map((plugin) => plugin.id),
);
const wanted: Record<string, PluginChoice> = {}; const wanted: Record<string, PluginChoice> = {};
for (const [id, choice] of Object.entries(choices)) { for (const [id, choice] of Object.entries(choices)) {
if (installed.has(id)) wanted[id] = choice; if (installed.has(id)) wanted[id] = choice;
+3
View File
@@ -243,6 +243,9 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
// Last, so a plugin's activate() sees a fully wired server. A plugin that // Last, so a plugin's activate() sees a fully wired server. A plugin that
// fails to load must not stop the backend, so this never rejects. // fails to load must not stop the backend, so this never rejects.
try { try {
const { startOperationsMaintenance } =
await import("./operations/maintenance.js");
await startOperationsMaintenance();
const { initializePlugins } = await import("./plugins/index.js"); const { initializePlugins } = await import("./plugins/index.js");
const loaded = await initializePlugins({ freshInstall }); const loaded = await initializePlugins({ freshInstall });
if (loaded.length > 0) { if (loaded.length > 0) {
@@ -0,0 +1,277 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
userId: "user-1",
apiKeyId: undefined as string | undefined,
apiKeyScopes: undefined as string[] | null | undefined,
actingAdminUserId: undefined as string | undefined,
viewAll: false,
hostAccess: false,
runs: new Map<string, Record<string, unknown>>(),
runOperation: vi.fn(),
decide: vi.fn(),
cancel: vi.fn(async () => true),
}));
vi.mock("../../../utils/logger.js", () => ({
databaseLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() },
pluginLogger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() },
}));
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() =>
(
req: Record<string, unknown>,
_res: unknown,
next: () => void,
): void => {
req.userId = state.userId;
req.apiKeyId = state.apiKeyId;
req.apiKeyScopes = state.apiKeyScopes;
req.actingAdminUserId = state.actingAdminUserId;
next();
},
}),
},
}));
vi.mock("../../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
hasPermission: async () => state.viewAll,
canAccessHost: async () => ({ hasAccess: state.hostAccess }),
}),
},
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentOperationRepository: () => ({
findRun: async (id: string) => state.runs.get(id) ?? null,
listRuns: async (filters: { userId?: string }) =>
[...state.runs.values()].filter(
(run) => !filters.userId || run.userId === filters.userId,
),
listPendingApprovals: async () => [],
listEvents: async () => [],
}),
}));
vi.mock("../../../operations/catalog.js", () => ({
buildCatalog: vi.fn(async () => ({
actions: [{ id: "demo.restart" }],
events: [],
resources: [],
signals: [],
})),
}));
vi.mock("../../../operations/resources.js", () => ({
flattenResourceRows: vi.fn(),
readAcrossHosts: vi.fn(async () => []),
}));
vi.mock("../../../operations/gate.js", () => {
class OperationError extends Error {
status: number;
constructor(
readonly code: string,
message: string,
status = 403,
) {
super(message);
this.status = status;
}
}
return {
OperationError,
runOperation: state.runOperation,
decideOperationApproval: state.decide,
cancelOperationRun: state.cancel,
};
});
const { default: router } =
await import("../../../database/routes/operations-routes.js");
const { OperationError } = await import("../../../operations/gate.js");
const app = express();
app.use(express.json());
app.use("/operations", router);
const run = (overrides: Record<string, unknown> = {}) => ({
id: "run-1",
actionId: "demo.restart",
pluginId: "demo",
pluginVersion: "1.0.0",
userId: "user-1",
username: "name",
origin: '{"kind":"ui"}',
hostId: 1,
hostName: "web",
batchId: null,
dryRun: false,
risk: "change",
status: "success",
summary: "done",
changed: true,
input: '{"token":"[redacted]"}',
sealedInput: "sealed:secret",
output: null,
undoInput: null,
error: null,
errorCode: null,
undoOf: null,
createdAt: "2026-01-01",
startedAt: null,
finishedAt: null,
...overrides,
});
beforeEach(() => {
state.userId = "user-1";
state.apiKeyId = undefined;
state.apiKeyScopes = undefined;
state.actingAdminUserId = undefined;
state.viewAll = false;
state.hostAccess = false;
state.runs.clear();
state.runOperation.mockReset();
state.runOperation.mockResolvedValue({ runId: "r", status: "success" });
state.decide.mockReset();
state.decide.mockResolvedValue({ runId: "r", status: "success" });
});
describe("POST /operations/:id/run", () => {
it("passes a session as a ui origin", async () => {
await request(app)
.post("/operations/demo.restart/run")
.send({ hostId: 1, input: { name: "x" } })
.expect(200);
expect(state.runOperation).toHaveBeenCalledWith(
expect.objectContaining({
actionId: "demo.restart",
hostId: 1,
userId: "user-1",
origin: { kind: "ui" },
}),
);
});
it("passes a key as an api origin with its scopes", async () => {
state.apiKeyId = "key";
state.apiKeyScopes = ["operations:read"];
await request(app)
.post("/operations/demo.restart/run")
.send({})
.expect(200);
expect(state.runOperation).toHaveBeenCalledWith(
expect.objectContaining({
origin: { kind: "api" },
apiKeyScopes: ["operations:read"],
}),
);
});
it("does not take the user or origin from the body", async () => {
await request(app)
.post("/operations/demo.restart/run")
.send({ userId: "admin", origin: { kind: "plugin", via: "automation" } })
.expect(200);
expect(state.runOperation).toHaveBeenCalledWith(
expect.objectContaining({ userId: "user-1", origin: { kind: "ui" } }),
);
});
it("sends a refusal's status and code", async () => {
state.runOperation.mockRejectedValue(
new (
OperationError as never as new (code: string, message: string) => Error
)("FORBIDDEN", "No"),
);
const response = await request(app)
.post("/operations/demo.restart/run")
.send({})
.expect(403);
expect(response.body).toMatchObject({ code: "FORBIDDEN", error: "No" });
});
});
describe("runs", () => {
it("never returns the sealed input", async () => {
state.runs.set("run-1", run());
const response = await request(app)
.get("/operations/runs/run-1")
.expect(200);
expect(JSON.stringify(response.body)).not.toContain("secret");
});
it("hides another user's run", async () => {
state.runs.set("run-1", run({ userId: "someone" }));
await request(app).get("/operations/runs/run-1").expect(404);
});
it("shows a run to someone who manages its host", async () => {
state.runs.set("run-1", run({ userId: "someone" }));
state.hostAccess = true;
await request(app).get("/operations/runs/run-1").expect(200);
});
it("lists only a user's own runs", async () => {
state.runs.set("a", run({ id: "a" }));
state.runs.set("b", run({ id: "b", userId: "someone" }));
const response = await request(app).get("/operations/runs").expect(200);
expect(response.body.runs.map((row: { id: string }) => row.id)).toEqual([
"a",
]);
});
it("lists everything with the view permission", async () => {
state.viewAll = true;
state.runs.set("a", run({ id: "a" }));
state.runs.set("b", run({ id: "b", userId: "someone" }));
const response = await request(app).get("/operations/runs").expect(200);
expect(response.body.runs).toHaveLength(2);
});
it("does not cancel another user's run", async () => {
state.runs.set("run-1", run({ userId: "someone" }));
await request(app).post("/operations/runs/run-1/cancel").expect(404);
expect(state.cancel).not.toHaveBeenCalled();
});
});
describe("POST /operations/approvals/:id", () => {
it("needs a clear answer", async () => {
await request(app)
.post("/operations/approvals/a")
.send({ approve: "yes" })
.expect(400);
expect(state.decide).not.toHaveBeenCalled();
});
it("refuses a scoped key", async () => {
state.apiKeyScopes = ["operations:run"];
await request(app)
.post("/operations/approvals/a")
.send({ approve: true })
.expect(403);
expect(state.decide).not.toHaveBeenCalled();
});
it("marks an impersonated request", async () => {
state.actingAdminUserId = "admin";
await request(app)
.post("/operations/approvals/a")
.send({ approve: true })
.expect(200);
expect(state.decide).toHaveBeenCalledWith(
expect.objectContaining({ impersonated: true }),
);
});
});
describe("GET /operations/catalog", () => {
it("answers", async () => {
const response = await request(app).get("/operations/catalog").expect(200);
expect(response.body.actions).toEqual([{ id: "demo.restart" }]);
});
});
@@ -0,0 +1,68 @@
import { afterEach, describe, expect, it } from "vitest";
import ssh2 from "ssh2";
const { Client, Server, utils } = ssh2;
// The order prepareKey sets when a host has both a key and a password.
const AUTH_ORDER = ["publickey", "password", "keyboard-interactive"];
const keys = utils.generateKeyPairSync("ed25519");
let server: InstanceType<typeof Server> | null = null;
afterEach(() => server?.close());
/** A server with AuthenticationMethods publickey,password. */
function start(): Promise<number> {
server = new Server({ hostKeys: [keys.private] }, (client) => {
let keyDone = false;
client.on("authentication", (ctx) => {
if (ctx.method === "publickey") {
if (ctx.signature) {
keyDone = true;
ctx.reject(["password"], true);
} else {
ctx.accept();
}
} else if (ctx.method === "password" && keyDone) {
ctx.accept();
} else {
ctx.reject(keyDone ? ["password"] : ["publickey"]);
}
});
client.on("ready", () => client.end());
client.on("error", () => {});
});
return new Promise((resolve) => {
server!.listen(0, "127.0.0.1", () =>
resolve((server!.address() as { port: number }).port),
);
});
}
function connect(port: number, authHandler?: string[]) {
return new Promise<string>((resolve) => {
const client = new Client();
client.on("ready", () => {
client.end();
resolve("ready");
});
client.on("error", (error) => resolve(error.message));
client.connect({
host: "127.0.0.1",
port,
username: "u",
privateKey: keys.private,
password: "pw",
...(authHandler ? { authHandler } : {}),
} as never);
});
}
describe("a server that wants a key, then a password", () => {
it("logs in with the key first order", async () => {
expect(await connect(await start(), AUTH_ORDER)).toBe("ready");
});
it("fails with ssh2's default order, which tries the password first", async () => {
expect(await connect(await start())).toMatch(/authentication/i);
});
});
@@ -205,6 +205,11 @@ describe("buildConnectConfig per auth type", () => {
expect(Buffer.isBuffer(config.privateKey)).toBe(true); expect(Buffer.isBuffer(config.privateKey)).toBe(true);
expect(config.passphrase).toBe("secret"); expect(config.passphrase).toBe("secret");
expect(config.password).toBe("fallback"); expect(config.password).toBe("fallback");
expect(config.authHandler).toEqual([
"publickey",
"password",
"keyboard-interactive",
]);
expect(mocks.applyCertificateAuth).not.toHaveBeenCalled(); expect(mocks.applyCertificateAuth).not.toHaveBeenCalled();
}); });
+694
View File
@@ -0,0 +1,694 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OperationAction } from "@termix-ssh/plugin-sdk/operations";
const state = vi.hoisted(() => ({
permissions: new Set<string>(),
hostLevels: new Map<number, string>(),
plugin: {
state: "active",
source: "bundled",
signedTier: undefined as string | undefined,
manifest: {
version: "1.0.0",
capabilities: ["operations:provide"],
},
},
capability: true,
safeMode: false,
host: { name: "web", updatedAt: "2026-01-01", tags: ["prod"] } as {
name: string;
updatedAt: string;
tags: string[];
},
runs: new Map<string, Record<string, unknown>>(),
approvals: new Map<string, Record<string, unknown>>(),
audits: [] as Record<string, unknown>[],
events: [] as Record<string, unknown>[],
}));
vi.mock("../../utils/logger.js", () => ({
pluginLogger: {
warn: vi.fn(),
info: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
state.audits.push(entry);
},
}));
vi.mock("../../utils/system-secret-crypto.js", () => ({
encryptSystemSecret: async (text: string) => `sealed:${text}`,
decryptSystemSecret: async (text: string) => text.slice("sealed:".length),
isSystemEncrypted: (text: string) => text.startsWith("sealed:"),
}));
vi.mock("../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
hasPermission: async (userId: string, permission: string) =>
state.permissions.has(`${userId}:${permission}`),
canAccessHost: async (userId: string, hostId: number, level: string) => {
const held = state.hostLevels.get(hostId);
const rank = ["connect", "view", "edit", "manage"];
return {
hasAccess:
held !== undefined && rank.indexOf(held) >= rank.indexOf(level),
};
},
}),
},
}));
vi.mock("../../plugins/permissions.js", () => ({
hasCapability: async () => state.capability,
}));
vi.mock("../../plugins/safe-mode.js", () => ({
safeModeOn: () => state.safeMode,
}));
vi.mock("../../plugins/manifest.js", () => ({
qualifyPermission: (pluginId: string, name: string) =>
name.startsWith(`${pluginId}.`) ? name : `${pluginId}.${name}`,
}));
vi.mock("../../plugins/index.js", () => ({
getPluginRuntime: () => ({ loader: { get: () => state.plugin } }),
}));
vi.mock("../../plugins/host-status-provider.js", () => ({
hostStatus: { get: () => null },
}));
vi.mock("../../plugins/ctx-probe.js", () => ({
loadProbeTargets: async () => [],
}));
vi.mock("../../operations/hosts.js", () => ({
loadOperationHost: async (id: number) => ({
id,
...state.host,
os: null,
settings: {},
}),
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentUserRepository: () => ({
findById: async (id: string) => ({ username: `name-${id}` }),
}),
createCurrentOperationRepository: () => ({
insertRun: async (run: Record<string, unknown>) => {
state.runs.set(run.id as string, { ...run });
},
findRun: async (id: string) => state.runs.get(id) ?? null,
findByIdempotencyKey: async (userId: string, key: string) =>
[...state.runs.values()].find(
(run) => run.userId === userId && run.idempotencyKey === key,
) ?? null,
updateRunFrom: async (
id: string,
from: string,
patch: Record<string, unknown>,
) => {
const run = state.runs.get(id);
if (!run || run.status !== from) return false;
Object.assign(run, patch);
return true;
},
insertApproval: async (row: Record<string, unknown>) => {
state.approvals.set(row.id as string, { ...row });
},
findApproval: async (id: string) => state.approvals.get(id) ?? null,
findApprovalForRun: async (runId: string) =>
[...state.approvals.values()].find((row) => row.runId === runId) ?? null,
decideApproval: async (id: string, status: string) => {
const row = state.approvals.get(id);
if (!row || row.status !== "pending") return false;
row.status = status;
return true;
},
insertEvent: async (event: Record<string, unknown>) => {
state.events.push(event);
},
}),
}));
const { registerAction, clearOperationRegistry } =
await import("../../operations/registry.js");
const { provideApprovalPolicy, clearApprovalPolicies } =
await import("../../operations/approvals.js");
const {
cancelOperationRun,
clearOperationGate,
decideOperationApproval,
drainPlugin,
OperationError,
OperationNeedsInteraction,
runOperation,
} = await import("../../operations/gate.js");
const USER = "user-1";
const APPROVER = "user-2";
const action = (overrides: Partial<OperationAction> = {}): OperationAction => ({
id: "demo.restart",
titleKey: "t",
scope: "host",
risk: "change",
permission: "use",
hostAccess: "edit",
inputs: [
{ key: "name", type: "string", required: true },
{ key: "token", type: "secret" },
],
dryRun: "native",
...overrides,
});
function register(
overrides: Partial<OperationAction> = {},
run = vi.fn(async () => ({ summary: "done", changed: true })),
) {
registerAction("demo", action(overrides), { run });
return run;
}
const manifestForPolicy = {
capabilities: ["operations:approve"],
} as never;
const base = {
actionId: "demo.restart",
hostId: 1,
input: { name: "nginx" },
userId: USER,
origin: { kind: "ui" } as const,
};
async function refused(request: Parameters<typeof runOperation>[0]) {
try {
await runOperation(request);
} catch (error) {
expect(error).toBeInstanceOf(OperationError);
return (error as InstanceType<typeof OperationError>).code;
}
throw new Error("Expected a refusal");
}
beforeEach(() => {
state.permissions = new Set([`${USER}:demo.use`, `${APPROVER}:demo.use`]);
state.hostLevels = new Map([[1, "edit"]]);
state.plugin.state = "active";
state.plugin.source = "bundled";
state.plugin.manifest.version = "1.0.0";
state.capability = true;
state.safeMode = false;
state.host = { name: "web", updatedAt: "2026-01-01", tags: ["prod"] };
state.runs.clear();
state.approvals.clear();
state.audits.length = 0;
state.events.length = 0;
});
afterEach(() => {
clearOperationRegistry();
clearApprovalPolicies();
clearOperationGate();
});
describe("refusals", () => {
it("does not run an unknown action", async () => {
expect(await refused(base)).toBe("NOT_FOUND");
});
it("does not run in safe mode", async () => {
register();
state.safeMode = true;
expect(await refused(base)).toBe("SAFE_MODE");
});
it("does not run for a plugin that stopped", async () => {
register();
state.plugin.state = "disabled";
expect(await refused(base)).toBe("UNAVAILABLE");
});
it("rechecks the grant at use", async () => {
const run = register();
state.capability = false;
expect(await refused(base)).toBe("UNAVAILABLE");
expect(run).not.toHaveBeenCalled();
});
it("needs the role permission", async () => {
const run = register();
state.permissions.clear();
expect(await refused(base)).toBe("FORBIDDEN");
expect(run).not.toHaveBeenCalled();
});
it("needs the declared host level", async () => {
const run = register();
state.hostLevels.set(1, "connect");
expect(await refused(base)).toBe("FORBIDDEN");
expect(run).not.toHaveBeenCalled();
});
it("refuses bad input before anything runs", async () => {
const run = register();
expect(await refused({ ...base, input: { nope: 1 } })).toBe(
"INVALID_INPUT",
);
expect(run).not.toHaveBeenCalled();
});
it("needs a host for a host action", async () => {
register();
expect(await refused({ ...base, hostId: undefined })).toBe("INVALID_INPUT");
});
it("needs a confirm for destructive actions", async () => {
register({ risk: "destructive" });
expect(await refused(base)).toBe("NEEDS_CONFIRM");
expect((await runOperation({ ...base, confirmed: true })).status).toBe(
"success",
);
});
it("stops long chains", async () => {
register();
expect(await refused({ ...base, depth: 4 })).toBe("TOO_DEEP");
});
});
describe("api key scopes", () => {
it("lets a read key run reads only", async () => {
register();
register({ id: "demo.read", risk: "read", hostAccess: "connect" });
expect(await refused({ ...base, apiKeyScopes: ["operations:read"] })).toBe(
"SCOPE",
);
const read = await runOperation({
...base,
actionId: "demo.read",
apiKeyScopes: ["operations:read"],
});
expect(read.status).toBe("success");
});
it("treats no scopes as full access", async () => {
register();
const outcome = await runOperation({ ...base, apiKeyScopes: null });
expect(outcome.status).toBe("success");
});
});
describe("runs", () => {
it("records a run and never stores a secret", async () => {
const run = register();
const outcome = await runOperation({
...base,
input: { name: "nginx", token: "hunter2" },
});
expect(outcome.status).toBe("success");
expect(run.mock.calls[0]?.[0]).toMatchObject({ token: "hunter2" });
const row = state.runs.get(outcome.runId!)!;
expect(row.status).toBe("success");
expect(JSON.stringify(row)).not.toContain("hunter2");
expect(JSON.stringify(state.audits)).not.toContain("hunter2");
expect(JSON.stringify(state.events)).not.toContain("hunter2");
});
it("keeps failures in the run, not thrown", async () => {
register(
{},
vi.fn(async () => {
throw new Error("no such unit");
}),
);
const outcome = await runOperation(base);
expect(outcome).toMatchObject({ status: "failed", error: "no such unit" });
expect(state.runs.get(outcome.runId!)!.status).toBe("failed");
});
it("reports hosts that need a person", async () => {
register(
{},
vi.fn(async () => {
throw new OperationNeedsInteraction("Sign in first");
}),
);
const outcome = await runOperation(base);
expect(outcome.status).toBe("needs_interaction");
});
it("passes dry run to a native handler", async () => {
const run = register();
await runOperation({ ...base, dryRun: true });
expect(run.mock.calls[0]).toBeDefined();
expect(
(run.mock.calls[0] as unknown as [unknown, { dryRun: boolean }])[1]
.dryRun,
).toBe(true);
});
it("never calls run for a describe dry run", async () => {
const run = vi.fn(async () => ({ summary: "x" }));
registerAction("demo", action({ dryRun: "describe" }), {
run,
describe: () => "Would restart nginx",
});
const outcome = await runOperation({ ...base, dryRun: true });
expect(outcome.summary).toBe("Would restart nginx");
expect(run).not.toHaveBeenCalled();
});
it("drops sensitive outputs", async () => {
register(
{ outputs: [{ key: "conf", type: "textarea", sensitive: true }] },
vi.fn(async () => ({
summary: "ok",
data: { conf: "private key" },
})),
);
const outcome = await runOperation(base);
expect(JSON.stringify(outcome)).not.toContain("private key");
expect(JSON.stringify(state.runs.get(outcome.runId!))).not.toContain(
"private key",
);
});
it("writes no row for a background read", async () => {
register({ risk: "read", hostAccess: "connect" });
const outcome = await runOperation({ ...base, internal: true });
expect(outcome.runId).toBeNull();
expect(state.runs.size).toBe(0);
});
it("does not run twice for one idempotency key", async () => {
const run = register();
const first = await runOperation({ ...base, idempotencyKey: "abc" });
const second = await runOperation({ ...base, idempotencyKey: "abc" });
expect(second.runId).toBe(first.runId);
expect(run).toHaveBeenCalledTimes(1);
});
it("emits a run event for a change", async () => {
register();
await runOperation(base);
await vi.waitFor(() =>
expect(state.events[0]).toMatchObject({
eventId: "operation.succeeded",
}),
);
});
it("stops a run on cancel", async () => {
let started = () => {};
const ready = new Promise<void>((resolve) => (started = resolve));
register(
{},
vi.fn(
(_input, context: { signal: AbortSignal }) =>
new Promise((_, reject) => {
started();
context.signal.addEventListener("abort", () =>
reject(new Error("stopped")),
);
}),
) as never,
);
const running = runOperation(base);
await ready;
const row = [...state.runs.values()][0]!;
expect(await cancelOperationRun(row as never)).toBe(true);
expect((await running).status).toBe("failed");
});
});
describe("policy", () => {
it("keeps AI and MCP away from changes with no rule", async () => {
register();
for (const via of ["assistant", "mcp"]) {
expect(
await refused({
...base,
origin: { kind: "plugin", pluginId: "x", via },
}),
).toBe("DENIED");
}
});
it("refuses a plugin with no stated purpose", async () => {
register();
expect(
await refused({ ...base, origin: { kind: "plugin", pluginId: "x" } }),
).toBe("DENIED");
});
it("lets a person's automation go by RBAC", async () => {
register();
const outcome = await runOperation({
...base,
origin: { kind: "plugin", pluginId: "x", via: "automation" },
});
expect(outcome.status).toBe("success");
});
it("treats a community read as a change", async () => {
state.plugin.source = "user";
state.plugin.signedTier = "community";
register({ risk: "read", hostAccess: "connect" });
expect(
await refused({
...base,
origin: { kind: "plugin", pluginId: "x", via: "assistant" },
}),
).toBe("DENIED");
state.plugin.signedTier = undefined;
});
it("denies when a provider throws", async () => {
register();
provideApprovalPolicy("rules", manifestForPolicy, () => {
throw new Error("boom");
});
expect(await refused(base)).toBe("DENIED");
});
it("takes the strictest answer", async () => {
register();
provideApprovalPolicy("a", manifestForPolicy, () => ({
decision: "allow",
}));
provideApprovalPolicy("b", manifestForPolicy, () => ({
decision: "deny",
reason: "No",
}));
expect(await refused(base)).toBe("DENIED");
});
it("audits a denial", async () => {
register();
provideApprovalPolicy("a", manifestForPolicy, () => ({ decision: "deny" }));
await refused(base);
expect(state.audits[0]).toMatchObject({
action: "operation_denied",
success: false,
});
});
});
describe("approvals", () => {
const needApproval = () =>
provideApprovalPolicy("rules", manifestForPolicy, () => ({
decision: "approve",
}));
async function pending(run = register()) {
needApproval();
const outcome = await runOperation({
...base,
input: { name: "nginx", token: "hunter2" },
});
expect(outcome.status).toBe("pending_approval");
expect(run).not.toHaveBeenCalled();
return outcome;
}
const decide = (approvalId: string, approverId = APPROVER) =>
decideOperationApproval({ approvalId, approverId, approve: true });
it("runs the sealed input once approved, then wipes it", async () => {
const run = register();
const { approvalId, runId } = await pending(run);
const row = state.runs.get(runId!)!;
expect(row.input).not.toContain("hunter2");
expect(row.sealedInput).toBeTruthy();
const outcome = await decide(approvalId!);
expect(outcome.status).toBe("success");
expect(run.mock.calls[0]?.[0]).toMatchObject({ token: "hunter2" });
expect(state.runs.get(runId!)!.sealedInput).toBeNull();
});
it("is one shot", async () => {
const run = register();
const { approvalId } = await pending(run);
await decide(approvalId!);
await expect(decide(approvalId!)).rejects.toMatchObject({
code: "CONFLICT",
});
expect(run).toHaveBeenCalledTimes(1);
});
it("does not let a person approve their own request", async () => {
const { approvalId } = await pending();
await expect(decide(approvalId!, USER)).rejects.toMatchObject({
code: "FORBIDDEN",
});
});
it("lets a person approve what an AI asked for", async () => {
register();
needApproval();
const outcome = await runOperation({
...base,
origin: { kind: "plugin", pluginId: "x", via: "assistant" },
});
const done = await decide(outcome.approvalId!, USER);
expect(done.status).toBe("success");
});
it("does not let someone impersonating approve", async () => {
const { approvalId } = await pending();
await expect(
decideOperationApproval({
approvalId: approvalId!,
approverId: APPROVER,
approve: true,
impersonated: true,
}),
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
it("needs the approver to hold the action's rights", async () => {
const { approvalId } = await pending();
state.permissions.delete(`${APPROVER}:demo.use`);
await expect(decide(approvalId!)).rejects.toMatchObject({
code: "FORBIDDEN",
});
});
it("rechecks the requester when it runs", async () => {
const run = register();
const { approvalId, runId } = await pending(run);
state.permissions.delete(`${USER}:demo.use`);
await expect(decide(approvalId!)).rejects.toMatchObject({
code: "FORBIDDEN",
});
expect(run).not.toHaveBeenCalled();
expect(state.runs.get(runId!)!.status).toBe("rejected");
});
it("goes stale when the host changed", async () => {
const run = register();
const { approvalId } = await pending(run);
state.host.updatedAt = "2026-02-02";
await expect(decide(approvalId!)).rejects.toMatchObject({ code: "STALE" });
expect(run).not.toHaveBeenCalled();
});
it("goes stale when the plugin changed", async () => {
const run = register();
const { approvalId } = await pending(run);
state.plugin.manifest.version = "2.0.0";
await expect(decide(approvalId!)).rejects.toMatchObject({ code: "STALE" });
expect(run).not.toHaveBeenCalled();
});
it("refuses a sealed copy from another run", async () => {
const run = register();
const first = await pending(run);
const second = await runOperation(base);
state.runs.get(second.runId!)!.sealedInput = state.runs.get(
first.runId!,
)!.sealedInput;
await expect(decide(second.approvalId!)).rejects.toMatchObject({
code: "STALE",
});
expect(run).not.toHaveBeenCalled();
});
it("refuses an expired approval", async () => {
const run = register();
const { approvalId } = await pending(run);
state.approvals.get(approvalId!)!.expiresAt = "2020-01-01T00:00:00.000Z";
await expect(decide(approvalId!)).rejects.toMatchObject({ code: "STALE" });
expect(run).not.toHaveBeenCalled();
});
it("wipes the sealed input on reject", async () => {
const { approvalId, runId } = await pending();
const outcome = await decideOperationApproval({
approvalId: approvalId!,
approverId: APPROVER,
approve: false,
});
expect(outcome.status).toBe("rejected");
expect(state.runs.get(runId!)!.sealedInput).toBeNull();
});
});
describe("drain", () => {
it("waits for a handler that is still stopping", async () => {
let finish = () => {};
register(
{},
vi.fn(
() =>
new Promise<never>((_, reject) => {
finish = () => reject(new Error("closed"));
}),
) as never,
);
vi.useFakeTimers();
void runOperation(base).catch(() => {});
await vi.advanceTimersByTimeAsync(0);
let drained = false;
const draining = drainPlugin("demo").then(() => (drained = true));
await vi.advanceTimersByTimeAsync(5_000);
expect(drained).toBe(false);
finish();
await vi.advanceTimersByTimeAsync(0);
await draining;
vi.useRealTimers();
expect(drained).toBe(true);
});
it("stops running work and marks it interrupted", async () => {
register({}, vi.fn(() => new Promise(() => {})) as never);
vi.useFakeTimers();
const running = runOperation(base);
await vi.advanceTimersByTimeAsync(0);
const draining = drainPlugin("demo");
await vi.advanceTimersByTimeAsync(11_000);
await draining;
await running;
vi.useRealTimers();
expect([...state.runs.values()][0]!.status).toBe("interrupted");
});
it("refuses new runs while draining", async () => {
register({}, vi.fn(() => new Promise(() => {})) as never);
vi.useFakeTimers();
void runOperation(base);
await vi.advanceTimersByTimeAsync(0);
const draining = drainPlugin("demo");
await expect(runOperation(base)).rejects.toMatchObject({
code: "UNAVAILABLE",
});
await vi.advanceTimersByTimeAsync(11_000);
await draining;
vi.useRealTimers();
});
});
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import { keyScopeAllows, parseKeyScopes } from "../../operations/key-scopes.js";
const READ = ["operations:read"];
const RUN = ["operations:run"];
describe("keyScopeAllows", () => {
it("leaves a key with no scopes alone", () => {
expect(keyScopeAllows(null, "POST", "/users/ws-ticket/api-key")).toBe(true);
expect(keyScopeAllows(null, "DELETE", "/host/1")).toBe(true);
});
it("keeps a scoped key off every other route", () => {
for (const path of [
"/users/ws-ticket/api-key",
"/host/1",
"/plugin-api/docker/1/containers",
"/credentials",
]) {
expect(keyScopeAllows(RUN, "GET", path)).toBe(false);
}
});
it("lets a read key read and start runs the gate then checks", () => {
expect(keyScopeAllows(READ, "GET", "/operations/catalog?lang=en")).toBe(
true,
);
expect(keyScopeAllows(READ, "POST", "/operations/docker.list/run")).toBe(
true,
);
expect(keyScopeAllows(READ, "POST", "/operations/approvals/abc")).toBe(
false,
);
expect(keyScopeAllows(READ, "POST", "/operations/runs/abc/cancel")).toBe(
false,
);
});
it("reaches a plugin's MCP route only", () => {
expect(keyScopeAllows(READ, "POST", "/plugin-api/some-plugin/mcp")).toBe(
true,
);
expect(
keyScopeAllows(READ, "POST", "/plugin-api/some-plugin/mcp-admin"),
).toBe(false);
});
it("does not match lookalike paths", () => {
expect(keyScopeAllows(RUN, "GET", "/operationsx/catalog")).toBe(false);
});
it("grants nothing with an empty list", () => {
expect(keyScopeAllows([], "GET", "/operations/catalog")).toBe(false);
});
});
describe("parseKeyScopes", () => {
it("reads a list", () => {
expect(parseKeyScopes('["operations:read"]')).toEqual(READ);
});
it("keeps old keys at full access", () => {
expect(parseKeyScopes(null)).toBeNull();
});
it("fails closed on junk", () => {
expect(parseKeyScopes("{nope")).toEqual([]);
expect(parseKeyScopes('"x"')).toEqual([]);
});
});
@@ -0,0 +1,210 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginManifest } from "../../plugins/manifest.js";
const gate = vi.hoisted(() => ({
runOperation: vi.fn(async () => ({ runId: "r", status: "success" })),
drainPlugin: vi.fn(async () => {}),
}));
vi.mock("../../utils/logger.js", () => ({
pluginLogger: {
warn: vi.fn(),
info: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
vi.mock("../../operations/gate.js", () => ({
...gate,
OperationError: class extends Error {
constructor(_code: string, message: string) {
super(message);
}
},
}));
vi.mock("../../operations/catalog.js", () => ({
buildCatalog: vi.fn(async () => ({ actions: [] })),
}));
vi.mock("../../operations/events.js", () => ({
emitOperationEvent: vi.fn(async () => {}),
subscribeOperationEvents: vi.fn(() => () => {}),
}));
vi.mock("../../plugins/permissions.js", () => ({
assertCapability: vi.fn(
async (_id: string, capability: string, declared: string[]) => {
if (!declared.includes(capability)) throw new Error("refused");
},
),
capabilityRefused: (pluginId: string, capability: string) =>
new Error(`${pluginId} lacks ${capability}`),
}));
const { createPluginOperations } =
await import("../../plugins/ctx-operations.js");
const { DisposableBag } = await import("../../plugins/disposables.js");
const { clearOperationRegistry, getAction } =
await import("../../operations/registry.js");
const { runAsActor } = await import("../../plugins/actor.js");
const action = {
id: "demo.restart",
titleKey: "t",
scope: "host",
risk: "change",
permission: "use",
hostAccess: "edit",
};
function build(capabilities: string[], invokes?: string[]) {
const bag = new DisposableBag("demo");
const manifest = {
id: "demo",
capabilities,
contributes: { operations: { actions: [action], invokes } },
} as unknown as PluginManifest;
return { bag, operations: createPluginOperations({ manifest, bag }) };
}
const handler = { run: async () => ({ summary: "ok" }) };
beforeEach(() => {
clearOperationRegistry();
gate.runOperation.mockClear();
gate.drainPlugin.mockClear();
});
describe("register", () => {
it("needs the capability", () => {
const { operations } = build([]);
expect(() => operations.register("demo.restart", handler)).toThrow(
"operations:provide",
);
});
it("refuses an undeclared action", () => {
const { operations } = build(["operations:provide"]);
expect(() => operations.register("demo.other", handler)).toThrow(
"not declared",
);
});
it("drains, then revokes, when the plugin stops", async () => {
const { bag, operations } = build(["operations:provide"]);
operations.register("demo.restart", handler);
expect(getAction("demo.restart")).toBeDefined();
await bag.disposeAll();
expect(gate.drainPlugin).toHaveBeenCalledWith("demo");
expect(getAction("demo.restart")).toBeUndefined();
});
it("keeps the new handler when an old disposer runs after a restart", async () => {
const first = build(["operations:provide"]);
first.operations.register("demo.restart", handler);
const second = build(["operations:provide"]);
const fresh = { run: async () => ({ summary: "new" }) };
second.operations.register("demo.restart", fresh);
await first.bag.disposeAll();
expect(getAction("demo.restart")?.handler).toBe(fresh);
});
});
describe("invoke", () => {
const asUser = <T>(fn: () => Promise<T>) =>
runAsActor("user", "request", fn, undefined, {
origin: { kind: "ui" },
apiKeyScopes: ["operations:read"],
});
it("needs the capability", async () => {
const { operations } = build([], ["*"]);
await expect(
asUser(() => operations.invoke("docker.restart")),
).rejects.toThrow("refused");
});
it("only reaches what the manifest lists", async () => {
const { operations } = build(["operations:invoke"], ["docker.*"]);
await expect(
asUser(() => operations.invoke("firewall.allow")),
).rejects.toThrow("did not declare");
expect(gate.runOperation).not.toHaveBeenCalled();
});
it("names the plugin as origin and keeps the key's limits", async () => {
const { operations } = build(["operations:invoke"], ["docker.*"]);
await asUser(() =>
operations.invoke("docker.restart", { via: "automation" }),
);
expect(gate.runOperation).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user",
origin: { kind: "plugin", pluginId: "demo", via: "automation" },
apiKeyScopes: ["operations:read"],
}),
);
});
it("does not let a plugin pick its own origin", async () => {
const { operations } = build(["operations:invoke"], ["*"]);
await asUser(() =>
operations.invoke("docker.restart", {
origin: { kind: "ui" },
} as never),
);
expect(gate.runOperation).toHaveBeenCalledWith(
expect.objectContaining({
origin: expect.objectContaining({ kind: "plugin", pluginId: "demo" }),
}),
);
});
it("needs an acting user", async () => {
const { operations } = build(["operations:invoke"], ["*"]);
await expect(operations.invoke("docker.restart")).rejects.toThrow(
"acting user",
);
});
});
describe("run", () => {
it("keeps the caller as origin for a plugin's own action", async () => {
const { operations } = build(["operations:provide"]);
await runAsActor(
"user",
"request",
() => operations.run("demo.restart", { hostId: 1 }),
undefined,
{ origin: { kind: "ui" } },
);
expect(gate.runOperation).toHaveBeenCalledWith(
expect.objectContaining({ origin: { kind: "ui" }, hostId: 1 }),
);
});
it("refuses another plugin's action", async () => {
const { operations } = build(["operations:provide"]);
await expect(
runAsActor("user", "request", () => operations.run("docker.restart")),
).rejects.toThrow("not declared");
});
});
describe("emit", () => {
it("refuses an undeclared event", async () => {
const { operations } = build(["operations:provide"]);
await expect(operations.emit("demo.nothing")).rejects.toThrow(
"not declared",
);
});
});
describe("approvals", () => {
it("needs the capability", () => {
const { operations } = build([]);
expect(() => operations.approvals.provide(() => null)).toThrow(
"operations:approve",
);
});
});
@@ -197,6 +197,13 @@ describe("describeOnboardingPlugins", () => {
}); });
expect(byId.extra).toMatchObject({ recommended: true, consent: true }); expect(byId.extra).toMatchObject({ recommended: true, consent: true });
}); });
it("leaves out the default look", async () => {
writeIndex({ extra: { defaultLook: true } });
await chain();
const list = await onboarding.describeOnboardingPlugins();
expect(list.plugins.map((p) => p.id).sort()).toEqual(["addon", "base"]);
});
}); });
describe("applyOnboardingChoices", () => { describe("applyOnboardingChoices", () => {
@@ -263,6 +270,22 @@ describe("applyOnboardingChoices", () => {
expect(db.settings.get(onboarding.PLUGIN_SETUP_PENDING_KEY)).toBe("1"); expect(db.settings.get(onboarding.PLUGIN_SETUP_PENDING_KEY)).toBe("1");
}); });
it("never turns off or removes the default look", async () => {
writeIndex({ extra: { defaultLook: true } });
const loader = await chain();
const result = await onboarding.applyOnboardingChoices({
extra: "remove",
base: "remove",
addon: "remove",
});
expect(result.failed).toEqual([]);
expect(result.removed).toEqual(["addon", "base"]);
expect(loader.get("extra")?.state).toBe("active");
await onboarding.applyOnboardingChoices({ extra: "disabled" });
expect(loader.get("extra")?.state).toBe("active");
});
it("ignores ids that are not installed", async () => { it("ignores ids that are not installed", async () => {
await chain(); await chain();
const result = await onboarding.applyOnboardingChoices({ const result = await onboarding.applyOnboardingChoices({
@@ -0,0 +1,297 @@
import { describe, expect, it } from "vitest";
import { parseManifest } from "@termix-ssh/plugin-sdk/manifest";
import {
invokeAllowed,
matchesWhen,
onSurface,
parseOperationInput,
redactFields,
toJsonSchema,
type OperationAction,
type OperationField,
} from "@termix-ssh/plugin-sdk/operations";
const action = (overrides: Partial<OperationAction> = {}): OperationAction => ({
id: "demo.allow-port",
titleKey: "ops.allow",
scope: "host",
risk: "change",
permission: "use",
hostAccess: "edit",
...overrides,
});
function manifest(operations: unknown, capabilities = ["operations:provide"]) {
return {
id: "demo",
name: "Demo",
version: "1.0.0",
description: "A demo.",
author: { name: "test" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=26.11.1-0", api: "^1.13" },
capabilities,
contributes: {
permissions: [
{ name: "use", titleKey: "perm.use", descriptionKey: "perm.useDesc" },
],
operations,
},
};
}
function errorsFor(operations: unknown, capabilities?: string[]): string[] {
return parseManifest(manifest(operations, capabilities)).errors ?? [];
}
describe("contributes.operations", () => {
it("accepts a full declaration", () => {
const errors = errorsFor({
actions: [
action({
check: "demo.port-allowed",
inputs: [
{ key: "port", type: "number", required: true, min: 1, max: 65535 },
{ key: "name", type: "resource", resource: "demo.rule" },
],
}),
action({
id: "demo.port-allowed",
risk: "read",
hostAccess: "connect",
}),
action({
id: "demo.list-rules",
risk: "read",
hostAccess: "connect",
}),
],
events: [{ id: "demo.turned-off", titleKey: "e", scope: "host" }],
resources: [
{
id: "demo.rule",
titleKey: "r",
list: "demo.list-rules",
key: "id",
name: "name",
},
],
signals: [
{
id: "demo.rules",
titleKey: "s",
read: "demo.port-allowed",
scope: "host",
kind: "count",
fix: "demo.allow-port",
},
],
});
expect(errors).toEqual([]);
});
it("needs the capability", () => {
expect(errorsFor({ actions: [action()] }, [])).toContainEqual(
expect.stringContaining("operations:provide"),
);
});
it("refuses an id outside the plugin", () => {
expect(
errorsFor({ actions: [action({ id: "docker.stop" })] }),
).toContainEqual(expect.stringContaining('must start with "demo."'));
});
it("refuses a destructive action a connect level user could run", () => {
expect(
errorsFor({
actions: [action({ risk: "destructive", hostAccess: "connect" })],
}),
).toContainEqual(expect.stringContaining("edit or manage"));
});
it("lets a change declare its own level", () => {
expect(errorsFor({ actions: [action({ hostAccess: "connect" })] })).toEqual(
[],
);
});
it("refuses an undeclared permission", () => {
expect(
errorsFor({ actions: [action({ permission: "nope" })] }),
).toContainEqual(expect.stringContaining("is not declared"));
});
it("refuses a check that is not a read", () => {
expect(
errorsFor({
actions: [
action({ check: "demo.other" }),
action({ id: "demo.other" }),
],
}),
).toContainEqual(expect.stringContaining("must be a read action"));
});
it("refuses references that go nowhere", () => {
const errors = errorsFor({
actions: [action({ undo: "demo.gone" })],
resources: [
{
id: "demo.rule",
titleKey: "r",
list: "demo.gone",
key: "id",
name: "n",
},
],
});
expect(errors.length).toBeGreaterThanOrEqual(2);
});
it("refuses unknown fields", () => {
expect(
errorsFor({ actions: [{ ...action(), extra: true }] }),
).toContainEqual(expect.stringContaining('Unknown field "extra"'));
});
it("refuses a default on a secret", () => {
expect(
errorsFor({
actions: [
action({
inputs: [{ key: "token", type: "secret", default: "abc" }],
}),
],
}),
).toContainEqual(expect.stringContaining("not allowed on a secret"));
});
it("needs operations:invoke for invokes", () => {
expect(errorsFor({ invokes: ["docker.*"] }, [])).toContainEqual(
expect.stringContaining("operations:invoke"),
);
});
});
describe("parseOperationInput", () => {
const fields: OperationField[] = [
{ key: "port", type: "number", required: true, min: 1, max: 65535 },
{
key: "proto",
type: "select",
default: "tcp",
options: [
{ value: "tcp", labelKey: "t" },
{ value: "udp", labelKey: "u" },
],
},
{ key: "name", type: "string", pattern: "[a-z]+" },
{ key: "data", type: "json" },
];
it("coerces strings and fills defaults", () => {
expect(parseOperationInput(fields, { port: "443" })).toEqual({
values: { port: 443, proto: "tcp" },
errors: [],
});
});
it("refuses keys no field declares", () => {
expect(
parseOperationInput(fields, { port: 1, sudo: true }).errors,
).toContainEqual(expect.stringContaining('"sudo"'));
});
it("needs required fields", () => {
expect(parseOperationInput(fields, {}).errors).toEqual([
'"port" is required',
]);
});
it("checks bounds, options and the whole pattern", () => {
expect(parseOperationInput(fields, { port: 0 }).errors).toHaveLength(1);
expect(
parseOperationInput(fields, { port: 1, proto: "icmp" }).errors,
).toHaveLength(1);
expect(
parseOperationInput(fields, { port: 1, name: "abc; rm -rf" }).errors,
).toEqual(['"name" has the wrong format']);
});
it("parses json strings", () => {
expect(
parseOperationInput(fields, { port: 1, data: '{"a":1}' }).values.data,
).toEqual({ a: 1 });
});
it("refuses a redacted echo for a secret", () => {
const secret: OperationField[] = [{ key: "token", type: "secret" }];
expect(
parseOperationInput(secret, { token: { set: true } }).errors,
).toHaveLength(1);
});
it("refuses input that is not an object", () => {
expect(parseOperationInput(fields, "nope").errors).toHaveLength(1);
});
});
describe("helpers", () => {
it("redacts secrets and sensitive outputs", () => {
expect(
redactFields(
[
{ key: "token", type: "secret" },
{ key: "conf", type: "textarea", sensitive: true },
{ key: "port", type: "number" },
],
{ token: "abc", conf: "key", port: 1 },
),
).toEqual({ token: "[redacted]", conf: "[redacted]", port: 1 });
});
it("builds JSON Schema", () => {
const schema = toJsonSchema([
{ key: "port", type: "number", required: true, min: 1 },
{ key: "token", type: "secret" },
]) as { properties: Record<string, unknown>; required: string[] };
expect(schema.required).toEqual(["port"]);
expect(schema.properties.port).toEqual({ type: "number", minimum: 1 });
expect(schema.properties.token).toEqual({
type: "string",
writeOnly: true,
});
});
it("keeps AI and MCP opt in, the rest opt out", () => {
expect(onSurface({}, "assistant")).toBe(false);
expect(onSurface({}, "mcp")).toBe(false);
expect(onSurface({}, "cli")).toBe(true);
expect(onSurface({ surfaces: { assistant: true } }, "assistant")).toBe(
true,
);
expect(onSurface({ surfaces: { cli: false } }, "cli")).toBe(false);
});
it("matches invokes patterns", () => {
expect(invokeAllowed(["docker.*"], "docker.stop")).toBe(true);
expect(invokeAllowed(["docker.*"], "dockerx.stop")).toBe(false);
expect(invokeAllowed(["docker.stop"], "docker.start")).toBe(false);
expect(invokeAllowed(["*"], "anything.at-all")).toBe(true);
expect(invokeAllowed(undefined, "docker.stop")).toBe(false);
});
it("tests when against a host", () => {
const when = { settings: { enableDocker: true }, os: ["linux"] };
expect(
matchesWhen(when, { settings: { enableDocker: true }, os: "Linux" }),
).toBe(true);
expect(matchesWhen(when, { settings: {} })).toBe(false);
expect(
matchesWhen(when, { settings: { enableDocker: true }, os: "windows" }),
).toBe(false);
expect(matchesWhen(when, { settings: { enableDocker: true } })).toBe(true);
});
});
@@ -1,7 +1,33 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js"; import {
DatabaseSaveTrigger,
lazySaveGapMs,
} from "../../utils/database-save-trigger.js";
describe("lazySaveGapMs", () => {
it("waits longer after slow saves, within limits", () => {
expect(lazySaveGapMs(0)).toBe(30_000);
expect(lazySaveGapMs(2_000)).toBe(60_000);
expect(lazySaveGapMs(60_000)).toBe(300_000);
});
});
describe("DatabaseSaveTrigger", () => { describe("DatabaseSaveTrigger", () => {
it("stays dirty when a write lands while a save is running", async () => {
let finish: () => void = () => {};
const save = vi.fn(
() => new Promise<void>((resolve) => (finish = resolve)),
);
DatabaseSaveTrigger.initialize(save);
DatabaseSaveTrigger.markDirty();
const running = DatabaseSaveTrigger.forceSave("first");
await new Promise((resolve) => setImmediate(resolve));
DatabaseSaveTrigger.markDirty();
finish();
await running;
expect(DatabaseSaveTrigger.isDirty).toBe(true);
});
afterEach(() => { afterEach(() => {
vi.useRealTimers(); vi.useRealTimers();
DatabaseSaveTrigger.cleanup(); DatabaseSaveTrigger.cleanup();
+12
View File
@@ -17,6 +17,7 @@ import { inArray, isNotNull } from "drizzle-orm";
import type { ExternalSessionRef } from "../auth/types.js"; import type { ExternalSessionRef } from "../auth/types.js";
import { listSecondFactors } from "../auth/registry.js"; import { listSecondFactors } from "../auth/registry.js";
import { SECOND_FACTOR_SETUP } from "../auth/policy.js"; import { SECOND_FACTOR_SETUP } from "../auth/policy.js";
import { keyScopeAllows, parseKeyScopes } from "../operations/key-scopes.js";
import type { DeviceType } from "./user-agent-parser.js"; import type { DeviceType } from "./user-agent-parser.js";
import { getDb } from "../database/db/index.js"; import { getDb } from "../database/db/index.js";
import { sessions } from "../database/db/schema.js"; import { sessions } from "../database/db/schema.js";
@@ -69,6 +70,7 @@ interface AuthenticatedRequest extends Request {
userId?: string; userId?: string;
sessionId?: string; sessionId?: string;
apiKeyId?: string; apiKeyId?: string;
apiKeyScopes?: string[] | null;
pendingTOTP?: boolean; pendingTOTP?: boolean;
dataKey?: Buffer; dataKey?: Buffer;
actingAdminUserId?: string; actingAdminUserId?: string;
@@ -879,8 +881,18 @@ class AuthManager {
}); });
}); });
const scopes = parseKeyScopes(matchedKey.scopes);
if (!keyScopeAllows(scopes, req.method, req.originalUrl ?? "")) {
res.status(403).json({
error: "This API key may not use this route",
code: "API_KEY_SCOPE",
});
return;
}
req.userId = matchedKey.userId; req.userId = matchedKey.userId;
req.apiKeyId = matchedKey.id; req.apiKeyId = matchedKey.id;
req.apiKeyScopes = scopes;
next(); next();
} catch (error) { } catch (error) {
databaseLogger.error("API key authentication failed", error, { databaseLogger.error("API key authentication failed", error, {
+20 -4
View File
@@ -16,6 +16,8 @@ interface EncryptedFileMetadata {
dataSize?: number; dataSize?: number;
} }
const ENCRYPT_CHUNK_BYTES = 4 * 1024 * 1024;
class DatabaseFileEncryption { class DatabaseFileEncryption {
private static readonly VERSION = "v2"; private static readonly VERSION = "v2";
private static readonly ALGORITHM = "aes-256-gcm"; private static readonly ALGORITHM = "aes-256-gcm";
@@ -27,7 +29,7 @@ class DatabaseFileEncryption {
buffer: Buffer, buffer: Buffer,
targetPath: string, targetPath: string,
): Promise<string> { ): Promise<string> {
const tmpPath = `${targetPath}.tmp-${Date.now()}-${process.pid}`; const tmpPath = `${targetPath}.tmp-${Date.now()}-${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
const metadataPath = `${targetPath}${this.METADATA_FILE_SUFFIX}`; const metadataPath = `${targetPath}${this.METADATA_FILE_SUFFIX}`;
try { try {
@@ -38,7 +40,21 @@ class DatabaseFileEncryption {
key, key,
iv, iv,
) as crypto.CipherGCM; ) as crypto.CipherGCM;
const encrypted = Buffer.concat([cipher.update(buffer), cipher.final()]); // This runs on every save, so a big database must not hold up the event
// loop for the whole encryption.
const parts: Buffer[] = [];
for (
let offset = 0;
offset < buffer.length;
offset += ENCRYPT_CHUNK_BYTES
) {
parts.push(
cipher.update(buffer.subarray(offset, offset + ENCRYPT_CHUNK_BYTES)),
);
await new Promise<void>((resolve) => setImmediate(resolve));
}
parts.push(cipher.final());
const encrypted = Buffer.concat(parts);
const tag = cipher.getAuthTag(); const tag = cipher.getAuthTag();
const metadata: EncryptedFileMetadata = { const metadata: EncryptedFileMetadata = {
@@ -62,8 +78,8 @@ class DatabaseFileEncryption {
encrypted, encrypted,
]); ]);
fs.writeFileSync(tmpPath, finalBuffer); await fs.promises.writeFile(tmpPath, finalBuffer);
fs.renameSync(tmpPath, targetPath); await fs.promises.rename(tmpPath, targetPath);
try { try {
if (fs.existsSync(metadataPath)) { if (fs.existsSync(metadataPath)) {
+21 -3
View File
@@ -6,6 +6,17 @@ const DEBOUNCE_MS = 2000;
// Every save rewrites the whole database file, so background writes (metrics // Every save rewrites the whole database file, so background writes (metrics
// samples, audit rows) may only cause one save per this window. // samples, audit rows) may only cause one save per this window.
const MIN_LAZY_SAVE_GAP_MS = 30_000; const MIN_LAZY_SAVE_GAP_MS = 30_000;
const MAX_LAZY_SAVE_GAP_MS = 5 * 60_000;
// A database that takes long to save is saved less often by background writes.
const LAZY_GAP_PER_SAVE_MS = 30;
/** How long background writes wait after a save that took saveMs. */
export function lazySaveGapMs(saveMs: number): number {
return Math.min(
MAX_LAZY_SAVE_GAP_MS,
Math.max(MIN_LAZY_SAVE_GAP_MS, saveMs * LAZY_GAP_PER_SAVE_MS),
);
}
export class DatabaseSaveTrigger { export class DatabaseSaveTrigger {
private static saveFunction: (() => Promise<void>) | null = null; private static saveFunction: (() => Promise<void>) | null = null;
@@ -14,6 +25,7 @@ export class DatabaseSaveTrigger {
private static activeSave: Promise<void> | null = null; private static activeSave: Promise<void> | null = null;
private static saveTimeout: NodeJS.Timeout | null = null; private static saveTimeout: NodeJS.Timeout | null = null;
private static lastSaveAt = 0; private static lastSaveAt = 0;
private static lastSaveMs = 0;
private static _dirty = false; private static _dirty = false;
private static batch = new AsyncLocalStorage<{ private static batch = new AsyncLocalStorage<{
open: boolean; open: boolean;
@@ -56,7 +68,7 @@ export class DatabaseSaveTrigger {
const delay = Math.max( const delay = Math.max(
DEBOUNCE_MS, DEBOUNCE_MS,
this.lastSaveAt + MIN_LAZY_SAVE_GAP_MS - Date.now(), this.lastSaveAt + lazySaveGapMs(this.lastSaveMs) - Date.now(),
); );
this.saveTimeout = setTimeout(async () => { this.saveTimeout = setTimeout(async () => {
@@ -64,7 +76,6 @@ export class DatabaseSaveTrigger {
try { try {
await this.runSave(); await this.runSave();
this._dirty = false;
} catch (error) { } catch (error) {
databaseLogger.error("Database save failed", error, { databaseLogger.error("Database save failed", error, {
operation: "db_save_trigger_failed", operation: "db_save_trigger_failed",
@@ -122,7 +133,6 @@ export class DatabaseSaveTrigger {
try { try {
await this.runSave(); await this.runSave();
this._dirty = false;
} catch (error) { } catch (error) {
databaseLogger.error("Database force save failed", error, { databaseLogger.error("Database force save failed", error, {
operation: "db_save_trigger_force_failed", operation: "db_save_trigger_force_failed",
@@ -152,10 +162,17 @@ export class DatabaseSaveTrigger {
this.activeSave = save; this.activeSave = save;
this.pendingSave = true; this.pendingSave = true;
this.lastSaveAt = Date.now(); this.lastSaveAt = Date.now();
// Cleared up front: saves are slow, and a write landing during one must
// stay dirty for the next.
this._dirty = false;
try { try {
await save; await save;
} catch (error) {
this._dirty = true;
throw error;
} finally { } finally {
this.lastSaveMs = Date.now() - this.lastSaveAt;
if (this.activeSave === save) { if (this.activeSave === save) {
this.activeSave = null; this.activeSave = null;
this.pendingSave = false; this.pendingSave = false;
@@ -181,6 +198,7 @@ export class DatabaseSaveTrigger {
this.saveTimeout = null; this.saveTimeout = null;
} }
this.lastSaveAt = 0; this.lastSaveAt = 0;
this.lastSaveMs = 0;
this.pendingSave = false; this.pendingSave = false;
this.activeSave = null; this.activeSave = null;
+1
View File
@@ -54,6 +54,7 @@ export const PERMISSION_CATALOG: PermissionCatalogEntry[] = [
"admin.roles.manage", "admin.roles.manage",
"admin.settings.manage", "admin.settings.manage",
"admin.sessions.manage", "admin.sessions.manage",
"admin.operations.view",
"admin.plugins.manage", "admin.plugins.manage",
], ],
}, },
+1 -1
View File
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const alertVariants = cva( const alertVariants = cva(
"relative w-full border border-border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", "relative w-full border border-border px-4 py-3 text-sm not-has-[>svg]:block grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{ {
variants: { variants: {
variant: { variant: {
+4 -4
View File
@@ -18,19 +18,19 @@ export const PasswordInput = React.forwardRef<
<Input <Input
ref={ref} ref={ref}
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
className={cn("h-11 text-base pr-12", className)} // extra padding-right className={cn("h-8 pr-8", className)}
{...props} {...props}
/> />
<button <button
type="button" type="button"
onClick={() => setShowPassword((prev) => !prev)} onClick={() => setShowPassword((prev) => !prev)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition" className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition"
aria-label={showPassword ? "Hide password" : "Show password"} aria-label={showPassword ? "Hide password" : "Show password"}
> >
{showPassword ? ( {showPassword ? (
<EyeOff className="h-5 w-5" /> <EyeOff className="size-4" />
) : ( ) : (
<Eye className="h-5 w-5" /> <Eye className="size-4" />
)} )}
</button> </button>
</div> </div>
+34 -6
View File
@@ -31,6 +31,8 @@ class DatabaseHealthMonitor {
private static instance: DatabaseHealthMonitor; private static instance: DatabaseHealthMonitor;
private listeners: Map<string, EventListener[]> = new Map(); private listeners: Map<string, EventListener[]> = new Map();
private degradedActive: boolean = false; private degradedActive: boolean = false;
private probe: (() => Promise<boolean>) | null = null;
private probing: Promise<void> | null = null;
private constructor() {} private constructor() {}
@@ -83,22 +85,48 @@ class DatabaseHealthMonitor {
errorCode === "DATABASE_ERROR" || errorCode === "DATABASE_ERROR" ||
errorCode === "DB_CONNECTION_FAILED"; errorCode === "DB_CONNECTION_FAILED";
// A cancel is on purpose and says nothing about the backend.
if (errorCode === "ERR_CANCELED" || lowerMessage === "canceled") return;
const isBackendUnreachable = const isBackendUnreachable =
errorCode === "ERR_NETWORK" || errorCode === "ERR_NETWORK" ||
errorCode === "ECONNREFUSED" || errorCode === "ECONNREFUSED" ||
errorCode === "ECONNABORTED" ||
errorCode === "ECONNRESET" || errorCode === "ECONNRESET" ||
errorCode === "ETIMEDOUT" ||
errorCode === "ERR_CANCELED" ||
(lowerMessage.includes("network error") && (lowerMessage.includes("network error") &&
errorLike.response === undefined) || errorLike.response === undefined);
// One slow request (like a plugin waiting on an SSH host) is not a lost
// backend, so a timeout only counts when a quick check fails too.
const isTimeout =
errorCode === "ECONNABORTED" ||
errorCode === "ETIMEDOUT" ||
lowerMessage.includes("request aborted") || lowerMessage.includes("request aborted") ||
lowerMessage.includes("timeout"); lowerMessage.includes("timeout");
if (!(isDatabaseError || isBackendUnreachable)) { if (isDatabaseError || isBackendUnreachable) {
return; this.markDegraded(errorMessage, errorCode);
} else if (isTimeout) {
if (!this.probe) {
this.markDegraded(errorMessage, errorCode);
return;
}
this.probing ??= this.probe()
.catch(() => false)
.then((reachable) => {
if (!reachable) this.markDegraded(errorMessage, errorCode);
})
.finally(() => {
this.probing = null;
});
} }
}
/** Sets the quick check used after a request times out. */
setReachabilityProbe(probe: (() => Promise<boolean>) | null) {
this.probe = probe;
}
private markDegraded(errorMessage: string, errorCode: string | undefined) {
if (!this.degradedActive) { if (!this.degradedActive) {
this.degradedActive = true; this.degradedActive = true;
this.emit("database-connection-degraded", { this.emit("database-connection-degraded", {
+12
View File
@@ -1214,6 +1214,18 @@
"title": "Send alerts", "title": "Send alerts",
"consequence": "To you or other users, and through the channels they set up." "consequence": "To you or other users, and through the channels they set up."
}, },
"operations:provide": {
"title": "Offer actions",
"consequence": "Its actions can be run from Automations, the CLI, the phone app and other places, by people allowed to use them."
},
"operations:invoke": {
"title": "Run other plugins' actions",
"consequence": "Acts for the signed-in user on the actions its manifest lists. Approval rules still apply."
},
"operations:approve": {
"title": "Decide what needs approval",
"consequence": "Can let actions run with no approval, or block them, for every user."
},
"notify:hub": { "notify:hub": {
"title": "Receive every alert", "title": "Receive every alert",
"consequence": "Stores and delivers the alerts all plugins send, including their contents." "consequence": "Stores and delivers the alerts all plugins send, including their contents."
+4
View File
@@ -590,6 +590,8 @@
"closeOnOpenDesc": "Maak die sybalk toe nadat jy 'n gasheer daaruit oopgemaak het", "closeOnOpenDesc": "Maak die sybalk toe nadat jy 'n gasheer daaruit oopgemaak het",
"showFolderPaths": "Wys gidsroetes", "showFolderPaths": "Wys gidsroetes",
"showFolderPathsDesc": "Wys die ouergidsroete bo ingeslote subgidsname. Subgidse bly ingespring in beide gevalle.", "showFolderPathsDesc": "Wys die ouergidsroete bo ingeslote subgidsname. Subgidse bly ingespring in beide gevalle.",
"stripedRows": "Wisselende rykleure",
"stripedRowsDesc": "Skadu elke tweede ry in die gasheerlys",
"rowDetailsTitle": "Rybesonderhede", "rowDetailsTitle": "Rybesonderhede",
"rowDetailsDesc": "Kies wat op elke gasheerbediener verskyn.", "rowDetailsDesc": "Kies wat op elke gasheerbediener verskyn.",
"showAddress": "Adres", "showAddress": "Adres",
@@ -775,6 +777,8 @@
"sharedBadge": "Gedeel", "sharedBadge": "Gedeel",
"sharedBadgeTooltip": "Gedeel deur {{owner}} ({{level}}-toegang)", "sharedBadgeTooltip": "Gedeel deur {{owner}} ({{level}}-toegang)",
"viewOnlyBanner": "Hierdie gasheer is met jou gedeel deur {{owner}} met lees-toegang. Die konfigurasie is lees-slegs.", "viewOnlyBanner": "Hierdie gasheer is met jou gedeel deur {{owner}} met lees-toegang. Die konfigurasie is lees-slegs.",
"folderRuleRemoveFromHosts": "Verwyder ook hierdie toegang van elke gasheer in die gids en sy subgidse",
"sharedCopyBanner": "Hierdie gasheer word met jou gedeel deur {{owner}}. Die werkskoppie hou 'n lees-slegs kopie, so verander dit op die bediener en dit sinkroniseer hier.",
"sharedEditBanner": "Hierdie gasheer is met jou gedeel deur {{owner}} met wysig-toegang. Veranderinge word op die werklike gasheer toegepas; autentikasie-verwysings kan slegs deur die eienaar verander word.", "sharedEditBanner": "Hierdie gasheer is met jou gedeel deur {{owner}} met wysig-toegang. Veranderinge word op die werklike gasheer toegepas; autentikasie-verwysings kan slegs deur die eienaar verander word.",
"ownerOnlyControl": "Slegs die gasheer-eienaar kan dit verander", "ownerOnlyControl": "Slegs die gasheer-eienaar kan dit verander",
"ownerAuthPrivate": "Die gasheer-eienaar se SSH-authentikasie is privaat. Gebruik “Stel persoonlike SSH-authentikasie” in die gasheer-menu om jou eie geloofsbewys te kies.", "ownerAuthPrivate": "Die gasheer-eienaar se SSH-authentikasie is privaat. Gebruik “Stel persoonlike SSH-authentikasie” in die gasheer-menu om jou eie geloofsbewys te kies.",
+4
View File
@@ -614,6 +614,8 @@
"closeOnOpenDesc": "إغلاق الشريط الجانبي بعد فتح مضيف منه", "closeOnOpenDesc": "إغلاق الشريط الجانبي بعد فتح مضيف منه",
"showFolderPaths": "إظهار مسارات المجلدات", "showFolderPaths": "إظهار مسارات المجلدات",
"showFolderPathsDesc": "إظهار مسار المجلد الأب فوق أسماء المجلدات الفرعية المتداخلة. تظل المجلدات الفرعية مضمنة في كلتا الحالتين.", "showFolderPathsDesc": "إظهار مسار المجلد الأب فوق أسماء المجلدات الفرعية المتداخلة. تظل المجلدات الفرعية مضمنة في كلتا الحالتين.",
"stripedRows": "ألوان صفوف متناوبة",
"stripedRowsDesc": "تظليل كل صف ثانٍ في قائمة المضيفين",
"rowDetailsTitle": "تفاصيل الصف", "rowDetailsTitle": "تفاصيل الصف",
"rowDetailsDesc": "اختر ما يظهر على كل جهاز.", "rowDetailsDesc": "اختر ما يظهر على كل جهاز.",
"showAddress": "العنوان", "showAddress": "العنوان",
@@ -823,6 +825,8 @@
"sharedBadge": "مشترك", "sharedBadge": "مشترك",
"sharedBadgeTooltip": "مشارك من قبل {{owner}} (وصول {{level}})", "sharedBadgeTooltip": "مشارك من قبل {{owner}} (وصول {{level}})",
"viewOnlyBanner": "هذا المضيف مشارك معك من قبل {{owner}} بصلاحية العرض فقط. التكوين للقراءة فقط.", "viewOnlyBanner": "هذا المضيف مشارك معك من قبل {{owner}} بصلاحية العرض فقط. التكوين للقراءة فقط.",
"folderRuleRemoveFromHosts": "إزالة هذا الوصول أيضاً من كل مضيف في المجلد والمجلدات الفرعية الخاصة به",
"sharedCopyBanner": "تم مشاركة هذا المضيف معك بواسطة {{owner}}. يحتفظ سطح المكتب بنسخة للقراءة فقط، لذا قم بتعديله على الخادم وسيتم مزامنته هنا.",
"sharedEditBanner": "هذا المضيف مشارك معك من قبل {{owner}} بصلاحية التعديل. التغييرات تنطبق على المضيف الفعلي؛ لا يمكن تغيير مراجع المصادقة إلا من قبل المالك.", "sharedEditBanner": "هذا المضيف مشارك معك من قبل {{owner}} بصلاحية التعديل. التغييرات تنطبق على المضيف الفعلي؛ لا يمكن تغيير مراجع المصادقة إلا من قبل المالك.",
"ownerOnlyControl": "يمكن للمالك فقط تغيير هذا", "ownerOnlyControl": "يمكن للمالك فقط تغيير هذا",
"ownerAuthPrivate": "مصادقة SSH الخاصة بمالك المضيف خاصة. استخدم “تعيين مصادقة SSH شخصية” من قائمة المضيف لاختيار بيانات الاعتماد الخاصة بك.", "ownerAuthPrivate": "مصادقة SSH الخاصة بمالك المضيف خاصة. استخدم “تعيين مصادقة SSH شخصية” من قائمة المضيف لاختيار بيانات الاعتماد الخاصة بك.",
+4
View File
@@ -590,6 +590,8 @@
"closeOnOpenDesc": "Затвори страничната лента след като отвориш хост от нея", "closeOnOpenDesc": "Затвори страничната лента след като отвориш хост от нея",
"showFolderPaths": "Показване на пътищата до папките", "showFolderPaths": "Показване на пътищата до папките",
"showFolderPathsDesc": "Показване на пътя до родителската папка над имената на вложените подпапки. Подпапките оставят отстъп в двата случая.", "showFolderPathsDesc": "Показване на пътя до родителската папка над имената на вложените подпапки. Подпапките оставят отстъп в двата случая.",
"stripedRows": "Чередуване на цветовете на редовете",
"stripedRowsDesc": "Оцветяване на всеки втори ред в списъка с хостове",
"rowDetailsTitle": "Детайли на реда", "rowDetailsTitle": "Детайли на реда",
"rowDetailsDesc": "Изберете какво се показва за всеки хост.", "rowDetailsDesc": "Изберете какво се показва за всеки хост.",
"showAddress": "Адрес", "showAddress": "Адрес",
@@ -775,6 +777,8 @@
"sharedBadge": "Споделен", "sharedBadge": "Споделен",
"sharedBadgeTooltip": "Споделен от {{owner}} (достъп: {{level}})", "sharedBadgeTooltip": "Споделен от {{owner}} (достъп: {{level}})",
"viewOnlyBanner": "Този хост е споделен с вас от {{owner}} с достъп само за преглед. Конфигурацията е само за четене.", "viewOnlyBanner": "Този хост е споделен с вас от {{owner}} с достъп само за преглед. Конфигурацията е само за четене.",
"folderRuleRemoveFromHosts": "Премахване на достъпа и от всички хостове в папката и подпапките ѝ",
"sharedCopyBanner": "Този хост е споделен с вас от {{owner}}. Работният плот запазва само за четене копия, така че променете го на сървъра и той ще се синхронизира тук.",
"sharedEditBanner": "Този хост е споделен с вас от {{owner}} с достъп за редактиране. Промените се прилагат към реалния хост; референциите за автентикация могат да бъдат променени само от собственика.", "sharedEditBanner": "Този хост е споделен с вас от {{owner}} с достъп за редактиране. Промените се прилагат към реалния хост; референциите за автентикация могат да бъдат променени само от собственика.",
"ownerOnlyControl": "Само собственикът на хоста може да промени това", "ownerOnlyControl": "Само собственикът на хоста може да промени това",
"ownerAuthPrivate": "SSH автентикацията на собственика на хоста е частна. Използвайте „Задай лична SSH автентикация“ от менюто на хоста, за да изберете свои собствени данни за вход.", "ownerAuthPrivate": "SSH автентикацията на собственика на хоста е частна. Използвайте „Задай лична SSH автентикация“ от менюто на хоста, за да изберете свои собствени данни за вход.",
+4
View File
@@ -590,6 +590,8 @@
"closeOnOpenDesc": "সাইডবার থেকে কোনো হোস্ট খোলার পর সাইডবার বন্ধ করুন", "closeOnOpenDesc": "সাইডবার থেকে কোনো হোস্ট খোলার পর সাইডবার বন্ধ করুন",
"showFolderPaths": "ফোল্ডার পথ দেখান", "showFolderPaths": "ফোল্ডার পথ দেখান",
"showFolderPathsDesc": "নেস্টেড সাবফোল্ডার নামের উপরে প্যারেন্ট ফোল্ডার পথ দেখান। সাবফোল্ডার যেকোনো ক্ষেত্রেই ইনডেন্ট থাকবে।", "showFolderPathsDesc": "নেস্টেড সাবফোল্ডার নামের উপরে প্যারেন্ট ফোল্ডার পথ দেখান। সাবফোল্ডার যেকোনো ক্ষেত্রেই ইনডেন্ট থাকবে।",
"stripedRows": "বিকল্প সারির রঙ",
"stripedRowsDesc": "হোস্ট তালিকায় প্রতিটি বিকল্প সারি রঙিন করুন",
"rowDetailsTitle": "সারির বিবরণ", "rowDetailsTitle": "সারির বিবরণ",
"rowDetailsDesc": "প্রতিটি হোস্টে কী দেখাবে তা নির্বাচন করুন।", "rowDetailsDesc": "প্রতিটি হোস্টে কী দেখাবে তা নির্বাচন করুন।",
"showAddress": "ঠিকানা", "showAddress": "ঠিকানা",
@@ -775,6 +777,8 @@
"sharedBadge": "শেয়ার করা", "sharedBadge": "শেয়ার করা",
"sharedBadgeTooltip": "{{owner}} দ্বারা শেয়ার করা ({{level}} অ্যাক্সেস)", "sharedBadgeTooltip": "{{owner}} দ্বারা শেয়ার করা ({{level}} অ্যাক্সেস)",
"viewOnlyBanner": "{{owner}} এই হোস্টটি আপনার সাথে ভিউ অ্যাক্সেস সহ শেয়ার করেছেন। কনফিগারেশনটি রিড-অনলি।", "viewOnlyBanner": "{{owner}} এই হোস্টটি আপনার সাথে ভিউ অ্যাক্সেস সহ শেয়ার করেছেন। কনফিগারেশনটি রিড-অনলি।",
"folderRuleRemoveFromHosts": "এই ফোল্ডার এবং এর সাবফোল্ডারে থাকা প্রতিটি হোস্ট থেকেও এই অ্যাক্সেস সরিয়ে দিন",
"sharedCopyBanner": "{{owner}} আপনাকে এই হোস্টটি শেয়ার করেছেন। ডেস্কটপটি একটি রিড-অনলি কপি রাখে, তাই সার্ভারে এটি পরিবর্তন করলে এটি এখানে সিঙ্ক হবে।",
"sharedEditBanner": "{{owner}} এই হোস্টটি আপনার সাথে এডিট অ্যাক্সেস সহ শেয়ার করেছেন। পরিবর্তনগুলো আসল হোস্টে প্রয়োগ হবে; অথেন্টিকেশন রেফারেন্স শুধুমাত্র মালিক পরিবর্তন করতে পারবেন।", "sharedEditBanner": "{{owner}} এই হোস্টটি আপনার সাথে এডিট অ্যাক্সেস সহ শেয়ার করেছেন। পরিবর্তনগুলো আসল হোস্টে প্রয়োগ হবে; অথেন্টিকেশন রেফারেন্স শুধুমাত্র মালিক পরিবর্তন করতে পারবেন।",
"ownerOnlyControl": "শুধুমাত্র হোস্ট মালিক এটি পরিবর্তন করতে পারেন", "ownerOnlyControl": "শুধুমাত্র হোস্ট মালিক এটি পরিবর্তন করতে পারেন",
"ownerAuthPrivate": "হোস্ট মালিকের SSH অথেন্টিকেশন প্রাইভেট। আপনার নিজস্ব ক্রেডেনশিয়াল নির্বাচন করতে হোস্ট মেনু থেকে “সেট পার্সোনাল SSH অথেন্টিকেশন” ব্যবহার করুন।", "ownerAuthPrivate": "হোস্ট মালিকের SSH অথেন্টিকেশন প্রাইভেট। আপনার নিজস্ব ক্রেডেনশিয়াল নির্বাচন করতে হোস্ট মেনু থেকে “সেট পার্সোনাল SSH অথেন্টিকেশন” ব্যবহার করুন।",
+4
View File
@@ -596,6 +596,8 @@
"closeOnOpenDesc": "Tanca la barra lateral després d'obrir un amfitrió des d'ella", "closeOnOpenDesc": "Tanca la barra lateral després d'obrir un amfitrió des d'ella",
"showFolderPaths": "Mostra les rutes de les carpetes", "showFolderPaths": "Mostra les rutes de les carpetes",
"showFolderPathsDesc": "Mostra la ruta de la carpeta principal sobre els noms de les subcarpetes anidades. Les subcarpetes es mantenen amb sagnat en qualsevol cas.", "showFolderPathsDesc": "Mostra la ruta de la carpeta principal sobre els noms de les subcarpetes anidades. Les subcarpetes es mantenen amb sagnat en qualsevol cas.",
"stripedRows": "Colors alternats de les files",
"stripedRowsDesc": "Aplicar un color alternat a cada file de la llista d'amfitrions",
"rowDetailsTitle": "Detalls de la fila", "rowDetailsTitle": "Detalls de la fila",
"rowDetailsDesc": "Tria què es mostra en cada amfitrió.", "rowDetailsDesc": "Tria què es mostra en cada amfitrió.",
"showAddress": "Adreça", "showAddress": "Adreça",
@@ -787,6 +789,8 @@
"sharedBadge": "Compartit", "sharedBadge": "Compartit",
"sharedBadgeTooltip": "Compartit per {{owner}} (accés {{level}})", "sharedBadgeTooltip": "Compartit per {{owner}} (accés {{level}})",
"viewOnlyBanner": "Aquest host us ha estat compartit per {{owner}} amb accés de visualització. La configuració és de només lectura.", "viewOnlyBanner": "Aquest host us ha estat compartit per {{owner}} amb accés de visualització. La configuració és de només lectura.",
"folderRuleRemoveFromHosts": "També elimina aquest accés de tots els amfitrions de la carpeta i les seves subcarpetes",
"sharedCopyBanner": "Aquest amfitrió ha estat compartit amb tu per {{owner}}. L'escriptori manté una còpia de només lectura, de manera que si el modifiques al servidor, els canvis es sincronitzaran aquí.",
"sharedEditBanner": "Aquest host us ha estat compartit per {{owner}} amb accés d'edició. Els canvis s'apliquen a l'host real; les referències d'autenticació només les pot modificar el propietari.", "sharedEditBanner": "Aquest host us ha estat compartit per {{owner}} amb accés d'edició. Els canvis s'apliquen a l'host real; les referències d'autenticació només les pot modificar el propietari.",
"ownerOnlyControl": "Només el propietari de l'host pot canviar això", "ownerOnlyControl": "Només el propietari de l'host pot canviar això",
"ownerAuthPrivate": "L'autenticació SSH del propietari de l'host és privada. Utilitzeu «Establir autenticació SSH personal» al menú de l'host per triar la vostra pròpia credencial.", "ownerAuthPrivate": "L'autenticació SSH del propietari de l'host és privada. Utilitzeu «Establir autenticació SSH personal» al menú de l'host per triar la vostra pròpia credencial.",
+4
View File
@@ -602,6 +602,8 @@
"closeOnOpenDesc": "Zavřít postranní panel po otevření hostitele z něj", "closeOnOpenDesc": "Zavřít postranní panel po otevření hostitele z něj",
"showFolderPaths": "Zobrazit cesty složek", "showFolderPaths": "Zobrazit cesty složek",
"showFolderPathsDesc": "Zobrazit cestu nadřazené složky nad jmény vnořených podsložek. Podsložky zůstanou v obou případech odsazené.", "showFolderPathsDesc": "Zobrazit cestu nadřazené složky nad jmény vnořených podsložek. Podsložky zůstanou v obou případech odsazené.",
"stripedRows": "Střídavé barvy řádků",
"stripedRowsDesc": "Ztmavit každý druhý řádek v seznamu hostitelů",
"rowDetailsTitle": "Detaily řádku", "rowDetailsTitle": "Detaily řádku",
"rowDetailsDesc": "Vyberte, co se zobrazí u každého hosta.", "rowDetailsDesc": "Vyberte, co se zobrazí u každého hosta.",
"showAddress": "Adresa", "showAddress": "Adresa",
@@ -799,6 +801,8 @@
"sharedBadge": "Sdílený", "sharedBadge": "Sdílený",
"sharedBadgeTooltip": "Sdíleno uživatelem {{owner}} (přístup {{level}})", "sharedBadgeTooltip": "Sdíleno uživatelem {{owner}} (přístup {{level}})",
"viewOnlyBanner": "Tento hostitel vám byl sdílen uživatelem {{owner}} s přístupem pouze pro zobrazení. Konfigurace je pouze pro čtení.", "viewOnlyBanner": "Tento hostitel vám byl sdílen uživatelem {{owner}} s přístupem pouze pro zobrazení. Konfigurace je pouze pro čtení.",
"folderRuleRemoveFromHosts": "Tento přístup odebrat také ze všech hostitelů v této složce a jejích podsložkách",
"sharedCopyBanner": "Tento hostitel vám sdílí {{owner}}. Desktop si uchovává kopii pouze pro čtení, takže změny proveďte na serveru a synchronizují se zde.",
"sharedEditBanner": "Tento hostitel vám byl sdílen uživatelem {{owner}} s přístupem pro úpravy. Změny se projeví na skutečném hostiteli; ověřovací údaje může měnit pouze vlastník.", "sharedEditBanner": "Tento hostitel vám byl sdílen uživatelem {{owner}} s přístupem pro úpravy. Změny se projeví na skutečném hostiteli; ověřovací údaje může měnit pouze vlastník.",
"ownerOnlyControl": "Toto může změnit pouze vlastník hostitele", "ownerOnlyControl": "Toto může změnit pouze vlastník hostitele",
"ownerAuthPrivate": "SSH ověřování vlastníka hostitele je soukromé. V nabídce hostitele použijte „Nastavit vlastní SSH ověřování“ pro výběr vlastních přihlašovacích údajů.", "ownerAuthPrivate": "SSH ověřování vlastníka hostitele je soukromé. V nabídce hostitele použijte „Nastavit vlastní SSH ověřování“ pro výběr vlastních přihlašovacích údajů.",
+4
View File
@@ -590,6 +590,8 @@
"closeOnOpenDesc": "Luk sidebjælken, når du åbner en vært fra den", "closeOnOpenDesc": "Luk sidebjælken, når du åbner en vært fra den",
"showFolderPaths": "Vis mappesstier", "showFolderPaths": "Vis mappesstier",
"showFolderPathsDesc": "Vis overordnede mappesstier over navne på indlejrede undermapper. Undermapper forbliver indrykket uanset hvad.", "showFolderPathsDesc": "Vis overordnede mappesstier over navne på indlejrede undermapper. Undermapper forbliver indrykket uanset hvad.",
"stripedRows": "Skiftende rækkefarver",
"stripedRowsDesc": "Skyg hver anden række i værtslisten",
"rowDetailsTitle": "Radedetaljer", "rowDetailsTitle": "Radedetaljer",
"rowDetailsDesc": "Vælg, hvad der vises for hver vært.", "rowDetailsDesc": "Vælg, hvad der vises for hver vært.",
"showAddress": "Adresse", "showAddress": "Adresse",
@@ -775,6 +777,8 @@
"sharedBadge": "Delt", "sharedBadge": "Delt",
"sharedBadgeTooltip": "Delt af {{owner}} ({{level}}-adgang)", "sharedBadgeTooltip": "Delt af {{owner}} ({{level}}-adgang)",
"viewOnlyBanner": "Denne vært er delt med dig af {{owner}} med visningsadgang. Konfigurationen er skrivebeskyttet.", "viewOnlyBanner": "Denne vært er delt med dig af {{owner}} med visningsadgang. Konfigurationen er skrivebeskyttet.",
"folderRuleRemoveFromHosts": "Fjern også denne adgang fra alle værter i mappen og dens undermapper",
"sharedCopyBanner": "Denne vært er delt med dig af {{owner}}. Skrivebordet gemmer en skrivebeskyttet kopi, så ændr den på serveren, og den synkroniseres her.",
"sharedEditBanner": "Denne vært er delt med dig af {{owner}} med redigeringsadgang. Ændringer gælder for den faktiske vært; autentificeringsreferencer kan kun ændres af ejeren.", "sharedEditBanner": "Denne vært er delt med dig af {{owner}} med redigeringsadgang. Ændringer gælder for den faktiske vært; autentificeringsreferencer kan kun ændres af ejeren.",
"ownerOnlyControl": "Kun værtejeren kan ændre dette", "ownerOnlyControl": "Kun værtejeren kan ændre dette",
"ownerAuthPrivate": "Værtejerens SSH-autentificering er privat. Brug “Indstil personlig SSH-autentificering” i værtsmenuen for at vælge dine egne loginoplysninger.", "ownerAuthPrivate": "Værtejerens SSH-autentificering er privat. Brug “Indstil personlig SSH-autentificering” i værtsmenuen for at vælge dine egne loginoplysninger.",
+4
View File
@@ -590,6 +590,8 @@
"closeOnOpenDesc": "Schließe die Seitenleiste, nachdem du einen Host daraus geöffnet hast", "closeOnOpenDesc": "Schließe die Seitenleiste, nachdem du einen Host daraus geöffnet hast",
"showFolderPaths": "Verzeichnispfade anzeigen", "showFolderPaths": "Verzeichnispfade anzeigen",
"showFolderPathsDesc": "Zeigt den Pfad des übergeordneten Verzeichnisses über verschachtelten Unterverzeichnisnamen an. Unterverzeichnisse bleiben in jedem Fall eingerückt.", "showFolderPathsDesc": "Zeigt den Pfad des übergeordneten Verzeichnisses über verschachtelten Unterverzeichnisnamen an. Unterverzeichnisse bleiben in jedem Fall eingerückt.",
"stripedRows": "Abwechselnde Zeilenfarben",
"stripedRowsDesc": "Jede zweite Zeile in der Host-Liste einfärben",
"rowDetailsTitle": "Zeilen-Details", "rowDetailsTitle": "Zeilen-Details",
"rowDetailsDesc": "Wählen Sie aus, was bei jedem Host angezeigt wird.", "rowDetailsDesc": "Wählen Sie aus, was bei jedem Host angezeigt wird.",
"showAddress": "Adresse", "showAddress": "Adresse",
@@ -775,6 +777,8 @@
"sharedBadge": "Geteilt", "sharedBadge": "Geteilt",
"sharedBadgeTooltip": "Geteilt von {{owner}} ({{level}}-Zugriff)", "sharedBadgeTooltip": "Geteilt von {{owner}} ({{level}}-Zugriff)",
"viewOnlyBanner": "Dieser Host wurde von {{owner}} mit Leserechten mit Ihnen geteilt. Die Konfiguration ist schreibgeschützt.", "viewOnlyBanner": "Dieser Host wurde von {{owner}} mit Leserechten mit Ihnen geteilt. Die Konfiguration ist schreibgeschützt.",
"folderRuleRemoveFromHosts": "Diesen Zugriff auch von allen Hosts im Ordner und seinen Unterordnern entfernen",
"sharedCopyBanner": "Dieser Host wurde von {{owner}} mit Ihnen geteilt. Der Desktop behält eine schreibgeschützte Kopie, sodass Änderungen am Server hier synchronisiert werden.",
"sharedEditBanner": "Dieser Host wurde von {{owner}} mit Bearbeitungsberechtigungen mit Ihnen geteilt. Änderungen gelten für den echten Host; Authentifizierungsreferenzen können nur vom Besitzer geändert werden.", "sharedEditBanner": "Dieser Host wurde von {{owner}} mit Bearbeitungsberechtigungen mit Ihnen geteilt. Änderungen gelten für den echten Host; Authentifizierungsreferenzen können nur vom Besitzer geändert werden.",
"ownerOnlyControl": "Nur der Host-Besitzer kann dies ändern", "ownerOnlyControl": "Nur der Host-Besitzer kann dies ändern",
"ownerAuthPrivate": "Die SSH-Authentifizierung des Host-Besitzers ist privat. Wählen Sie im Host-Menü „Persönliche SSH-Authentifizierung festlegen“, um Ihre eigenen Anmeldeinformationen auszuwählen.", "ownerAuthPrivate": "Die SSH-Authentifizierung des Host-Besitzers ist privat. Wählen Sie im Host-Menü „Persönliche SSH-Authentifizierung festlegen“, um Ihre eigenen Anmeldeinformationen auszuwählen.",
+4
View File
@@ -590,6 +590,8 @@
"closeOnOpenDesc": "Κλείσιμο της πλευρικής γραμμής μετά το άνοιγμα ενός host από αυτήν", "closeOnOpenDesc": "Κλείσιμο της πλευρικής γραμμής μετά το άνοιγμα ενός host από αυτήν",
"showFolderPaths": "Εμφάνιση διαδρομών φακέλων", "showFolderPaths": "Εμφάνιση διαδρομών φακέλων",
"showFolderPathsDesc": "Εμφάνιση της διαδρομής του γονικού φακέλου πάνω από τα ονόματα των υποφακέλων. Οι υποφάκελοι παραμένουν εσοχή σε κάθε περίπτωση.", "showFolderPathsDesc": "Εμφάνιση της διαδρομής του γονικού φακέλου πάνω από τα ονόματα των υποφακέλων. Οι υποφάκελοι παραμένουν εσοχή σε κάθε περίπτωση.",
"stripedRows": "Εναλλασσόμενα χρώματα γραμμών",
"stripedRowsDesc": "Σκίαση κάθε δεύτερης γραμμής στη λίστα των hosts",
"rowDetailsTitle": "Λεπτομέρειες γραμμής", "rowDetailsTitle": "Λεπτομέρειες γραμμής",
"rowDetailsDesc": "Επιλέξτε τι εμφανίζεται σε κάθε host.", "rowDetailsDesc": "Επιλέξτε τι εμφανίζεται σε κάθε host.",
"showAddress": "Διεύθυνση", "showAddress": "Διεύθυνση",
@@ -775,6 +777,8 @@
"sharedBadge": "Κοινόχρηστο", "sharedBadge": "Κοινόχρηστο",
"sharedBadgeTooltip": "Κοινόχρηστο από τον {{owner}} (πρόσβαση {{level}})", "sharedBadgeTooltip": "Κοινόχρηστο από τον {{owner}} (πρόσβαση {{level}})",
"viewOnlyBanner": "Αυτός ο υπολογιστής έχει κοινόχρηστη πρόσβαση από τον {{owner}} με δικαιώματα προβολής. Η διαμόρφωση είναι μόνο για ανάγνωση.", "viewOnlyBanner": "Αυτός ο υπολογιστής έχει κοινόχρηστη πρόσβαση από τον {{owner}} με δικαιώματα προβολής. Η διαμόρφωση είναι μόνο για ανάγνωση.",
"folderRuleRemoveFromHosts": "Αφαίρεση επίσης αυτής της πρόσβασης από κάθε host στον φάκελο και τους υποφακέλους του",
"sharedCopyBanner": "Αυτός ο host μοιράζεται μαζί σας από τον {{owner}}. Το desktop διατηρεί ένα αντίγραφο μόνο για ανάγνωση, οπότε κάντε αλλαγές στον server και θα συγχρονιστούν εδώ.",
"sharedEditBanner": "Αυτός ο υπολογιστής έχει κοινόχρηστη πρόσβαση από τον {{owner}} με δικαιώματα επεξεργασίας. Οι αλλαγές εφαρμόζονται στον πραγματικό υπολογιστή· οι αναφορές πιστοποίησης μπορούν να αλλάξουν μόνο από τον ιδιοκτήτη.", "sharedEditBanner": "Αυτός ο υπολογιστής έχει κοινόχρηστη πρόσβαση από τον {{owner}} με δικαιώματα επεξεργασίας. Οι αλλαγές εφαρμόζονται στον πραγματικό υπολογιστή· οι αναφορές πιστοποίησης μπορούν να αλλάξουν μόνο από τον ιδιοκτήτη.",
"ownerOnlyControl": "Μόνο ο ιδιοκτήτης του υπολογιστή μπορεί να αλλάξει αυτό", "ownerOnlyControl": "Μόνο ο ιδιοκτήτης του υπολογιστή μπορεί να αλλάξει αυτό",
"ownerAuthPrivate": "Η SSH πιστοποίηση του ιδιοκτήτη του υπολογιστή είναι ιδιωτική. Χρησιμοποιήστε την επιλογή «Ορισμός προσωπικής SSH πιστοποίησης» από το μενού του υπολογιστή για να επιλέξετε το δικό σας διαπιστευτήριο.", "ownerAuthPrivate": "Η SSH πιστοποίηση του ιδιοκτήτη του υπολογιστή είναι ιδιωτική. Χρησιμοποιήστε την επιλογή «Ορισμός προσωπικής SSH πιστοποίησης» από το μενού του υπολογιστή για να επιλέξετε το δικό σας διαπιστευτήριο.",
+4
View File
@@ -596,6 +596,8 @@
"closeOnOpenDesc": "Cerrar la barra lateral después de abrir un host desde ella", "closeOnOpenDesc": "Cerrar la barra lateral después de abrir un host desde ella",
"showFolderPaths": "Mostrar rutas de carpetas", "showFolderPaths": "Mostrar rutas de carpetas",
"showFolderPathsDesc": "Muestra la ruta de la carpeta principal encima de los nombres de las subcarpetas anidadas. Las subcarpetas siguen estando sangradas de todos modos.", "showFolderPathsDesc": "Muestra la ruta de la carpeta principal encima de los nombres de las subcarpetas anidadas. Las subcarpetas siguen estando sangradas de todos modos.",
"stripedRows": "Colores alternos en las filas",
"stripedRowsDesc": "Aplicar un tono diferente a cada fila alterna en la lista de hosts",
"rowDetailsTitle": "Detalles de la fila", "rowDetailsTitle": "Detalles de la fila",
"rowDetailsDesc": "Elige qué se muestra en cada host.", "rowDetailsDesc": "Elige qué se muestra en cada host.",
"showAddress": "Dirección", "showAddress": "Dirección",
@@ -787,6 +789,8 @@
"sharedBadge": "Compartido", "sharedBadge": "Compartido",
"sharedBadgeTooltip": "Compartido por {{owner}} (acceso {{level}})", "sharedBadgeTooltip": "Compartido por {{owner}} (acceso {{level}})",
"viewOnlyBanner": "Este host te ha sido compartido por {{owner}} con acceso de solo lectura. La configuración es de solo lectura.", "viewOnlyBanner": "Este host te ha sido compartido por {{owner}} con acceso de solo lectura. La configuración es de solo lectura.",
"folderRuleRemoveFromHosts": "También eliminar este acceso de todos los hosts de la carpeta y sus subcarpetas",
"sharedCopyBanner": "Este host te ha sido compartido por {{owner}}. El escritorio mantiene una copia de solo lectura, por lo que si lo modificas en el servidor, los cambios se sincronizarán aquí.",
"sharedEditBanner": "Este host te ha sido compartido por {{owner}} con acceso de edición. Los cambios se aplican al host real; solo el propietario puede modificar las referencias de autenticación.", "sharedEditBanner": "Este host te ha sido compartido por {{owner}} con acceso de edición. Los cambios se aplican al host real; solo el propietario puede modificar las referencias de autenticación.",
"ownerOnlyControl": "Solo el propietario del host puede cambiar esto", "ownerOnlyControl": "Solo el propietario del host puede cambiar esto",
"ownerAuthPrivate": "La autenticación SSH del propietario del host es privada. Usa «Establecer autenticación SSH personal» en el menú del host para elegir tu propia credencial.", "ownerAuthPrivate": "La autenticación SSH del propietario del host es privada. Usa «Establecer autenticación SSH personal» en el menú del host para elegir tu propia credencial.",
+4
View File
@@ -590,6 +590,8 @@
"closeOnOpenDesc": "Sulge külgriba pärast hosti sellest avamist", "closeOnOpenDesc": "Sulge külgriba pärast hosti sellest avamist",
"showFolderPaths": "Kuva kausta teed", "showFolderPaths": "Kuva kausta teed",
"showFolderPathsDesc": "Kuva vanemkausta tee sisestatud alamkaustade nimede kohal. Alamkaustad on igal juhul sisse tõstetud.", "showFolderPathsDesc": "Kuva vanemkausta tee sisestatud alamkaustade nimede kohal. Alamkaustad on igal juhul sisse tõstetud.",
"stripedRows": "Vahelduvad ridade värvid",
"stripedRowsDesc": "Varjunda iga teine rida hostide nimekirjas",
"rowDetailsTitle": "Rea detailid", "rowDetailsTitle": "Rea detailid",
"rowDetailsDesc": "Vali, mis iga hosti kohta kuvatakse.", "rowDetailsDesc": "Vali, mis iga hosti kohta kuvatakse.",
"showAddress": "Aadress", "showAddress": "Aadress",
@@ -775,6 +777,8 @@
"sharedBadge": "Jagatud", "sharedBadge": "Jagatud",
"sharedBadgeTooltip": "Jagatud kasutaja {{owner}} poolt ({{level}} ligipääs)", "sharedBadgeTooltip": "Jagatud kasutaja {{owner}} poolt ({{level}} ligipääs)",
"viewOnlyBanner": "See host on jagatud kasutaja {{owner}} poolt vaatamisõigusega. Konfiguratsioon on kirjutuskaitstud.", "viewOnlyBanner": "See host on jagatud kasutaja {{owner}} poolt vaatamisõigusega. Konfiguratsioon on kirjutuskaitstud.",
"folderRuleRemoveFromHosts": "Eemalda see ligipääs ka kõigilt hostidelt kaustas ja selle alamkaustades",
"sharedCopyBanner": "See host on jagatud sinuga kasutaja {{owner}} poolt. Töölaua rakendus hoiab lugemisõigusega koopiat, seega tee muudatused serveris ja need sünkroonitakse siia.",
"sharedEditBanner": "See host on jagatud kasutaja {{owner}} poolt muutmise õigusega. Muudatused rakenduvad tegelikule hostile; autentimise viiteid saab muuta ainult hosti omanik.", "sharedEditBanner": "See host on jagatud kasutaja {{owner}} poolt muutmise õigusega. Muudatused rakenduvad tegelikule hostile; autentimise viiteid saab muuta ainult hosti omanik.",
"ownerOnlyControl": "Seda saab muuta ainult hosti omanik", "ownerOnlyControl": "Seda saab muuta ainult hosti omanik",
"ownerAuthPrivate": "Hosti omaniku SSH-autentimine on privaatne. Kasuta hosti menüüst valikut „Määra isiklik SSH-autentimine“, et valida oma usalduskiri.", "ownerAuthPrivate": "Hosti omaniku SSH-autentimine on privaatne. Kasuta hosti menüüst valikut „Määra isiklik SSH-autentimine“, et valida oma usalduskiri.",
+4
View File
@@ -590,6 +590,8 @@
"closeOnOpenDesc": "Sulje sivupalkki, kun avaat isäntäsen palvelimen siitä", "closeOnOpenDesc": "Sulje sivupalkki, kun avaat isäntäsen palvelimen siitä",
"showFolderPaths": "Näytä kansioiden polut", "showFolderPaths": "Näytä kansioiden polut",
"showFolderPathsDesc": "Näytä vanhemman kansion polku sisäkkäisten alikansioiden nimien yläpuolella. Alikansiot pysyvät sisennettyinä joka tapauksessa.", "showFolderPathsDesc": "Näytä vanhemman kansion polku sisäkkäisten alikansioiden nimien yläpuolella. Alikansiot pysyvät sisennettyinä joka tapauksessa.",
"stripedRows": "Vaihtuvat rivin värit",
"stripedRowsDesc": "Varjostaa toinen toisensa seuraavat rivit isäntälistassa",
"rowDetailsTitle": "Rivin tiedot", "rowDetailsTitle": "Rivin tiedot",
"rowDetailsDesc": "Valitse, mitä näytetään jokaisessa isännässä.", "rowDetailsDesc": "Valitse, mitä näytetään jokaisessa isännässä.",
"showAddress": "Osoite", "showAddress": "Osoite",
@@ -775,6 +777,8 @@
"sharedBadge": "Julkaisu", "sharedBadge": "Julkaisu",
"sharedBadgeTooltip": "Julkaissut {{owner}} ({{level}}-käyttöoikeus)", "sharedBadgeTooltip": "Julkaissut {{owner}} ({{level}}-käyttöoikeus)",
"viewOnlyBanner": "Tämän isäntäkonetta on jaettu sinulle käyttäjän {{owner}} toimesta katselukäyttöoikeudella. Konfiguraatio on vain luettavissa.", "viewOnlyBanner": "Tämän isäntäkonetta on jaettu sinulle käyttäjän {{owner}} toimesta katselukäyttöoikeudella. Konfiguraatio on vain luettavissa.",
"folderRuleRemoveFromHosts": "Poista myös tämä pääsy kaikista kansiossa ja sen alikansioissa olevista isännöistä",
"sharedCopyBanner": "Tämä isäntä on jaettu sinulle käyttäjän {{owner}} toimesta. Työpöytä pitää luettavan kopion, joten tee muutokset palvelimella, ja ne synkronoituvat tänne.",
"sharedEditBanner": "Tämän isäntäkonetta on jaettu sinulle käyttäjän {{owner}} toimesta muokkauskäyttöoikeudella. Muutokset vaikuttavat todelliseen isäntäkoneeseen; todennusviittauksia voi muuttaa vain omistaja.", "sharedEditBanner": "Tämän isäntäkonetta on jaettu sinulle käyttäjän {{owner}} toimesta muokkauskäyttöoikeudella. Muutokset vaikuttavat todelliseen isäntäkoneeseen; todennusviittauksia voi muuttaa vain omistaja.",
"ownerOnlyControl": "Vain isäntäkoneen omistaja voi muuttaa tätä", "ownerOnlyControl": "Vain isäntäkoneen omistaja voi muuttaa tätä",
"ownerAuthPrivate": "Isäntäkoneen omistajan SSH-todennus on yksityinen. Valitse oma tunnistetietosi isäntävalikon kohdasta ”Aseta henkilökohtainen SSH-todennus”.", "ownerAuthPrivate": "Isäntäkoneen omistajan SSH-todennus on yksityinen. Valitse oma tunnistetietosi isäntävalikon kohdasta ”Aseta henkilökohtainen SSH-todennus”.",
+4
View File
@@ -596,6 +596,8 @@
"closeOnOpenDesc": "Fermer la barre latérale après avoir ouvert un hôte depuis celle-ci", "closeOnOpenDesc": "Fermer la barre latérale après avoir ouvert un hôte depuis celle-ci",
"showFolderPaths": "Afficher les chemins des dossiers", "showFolderPaths": "Afficher les chemins des dossiers",
"showFolderPathsDesc": "Afficher le chemin du dossier parent au-dessus des noms des sous-dossiers imbriqués. Les sous-dossiers restent indentés dans les deux cas.", "showFolderPathsDesc": "Afficher le chemin du dossier parent au-dessus des noms des sous-dossiers imbriqués. Les sous-dossiers restent indentés dans les deux cas.",
"stripedRows": "Couleurs de lignes alternées",
"stripedRowsDesc": "Ombrer chaque ligne sur deux dans la liste des hôtes",
"rowDetailsTitle": "Détails de la ligne", "rowDetailsTitle": "Détails de la ligne",
"rowDetailsDesc": "Choisissez ce qui s'affiche pour chaque hôte.", "rowDetailsDesc": "Choisissez ce qui s'affiche pour chaque hôte.",
"showAddress": "Adresse", "showAddress": "Adresse",
@@ -787,6 +789,8 @@
"sharedBadge": "Partagé", "sharedBadge": "Partagé",
"sharedBadgeTooltip": "Partagé par {{owner}} (accès {{level}})", "sharedBadgeTooltip": "Partagé par {{owner}} (accès {{level}})",
"viewOnlyBanner": "Cet hôte vous est partagé par {{owner}} avec un accès en lecture seule. La configuration est en lecture seule.", "viewOnlyBanner": "Cet hôte vous est partagé par {{owner}} avec un accès en lecture seule. La configuration est en lecture seule.",
"folderRuleRemoveFromHosts": "Supprimer également cet accès de chaque hôte dans le dossier et ses sous-dossiers",
"sharedCopyBanner": "Cet hôte vous est partagé par {{owner}}. Le bureau conserve une copie en lecture seule ; modifiez-le sur le serveur et il sera synchronisé ici.",
"sharedEditBanner": "Cet hôte vous est partagé par {{owner}} avec un accès en modification. Les modifications s'appliquent à l'hôte réel ; les références d'authentification ne peuvent être modifiées que par le propriétaire.", "sharedEditBanner": "Cet hôte vous est partagé par {{owner}} avec un accès en modification. Les modifications s'appliquent à l'hôte réel ; les références d'authentification ne peuvent être modifiées que par le propriétaire.",
"ownerOnlyControl": "Seul le propriétaire de l'hôte peut modifier ceci", "ownerOnlyControl": "Seul le propriétaire de l'hôte peut modifier ceci",
"ownerAuthPrivate": "L'authentification SSH du propriétaire de l'hôte est privée. Utilisez « Définir l'authentification SSH personnelle » dans le menu de l'hôte pour choisir vos propres identifiants.", "ownerAuthPrivate": "L'authentification SSH du propriétaire de l'hôte est privée. Utilisez « Définir l'authentification SSH personnelle » dans le menu de l'hôte pour choisir vos propres identifiants.",
+4
View File
@@ -596,6 +596,8 @@
"closeOnOpenDesc": "סגור את סרגל הצד לאחר פתיחת מארח ממנו", "closeOnOpenDesc": "סגור את סרגל הצד לאחר פתיחת מארח ממנו",
"showFolderPaths": "הצג נתיבי תיקייה", "showFolderPaths": "הצג נתיבי תיקייה",
"showFolderPathsDesc": "הצג את נתיב התיקייה האם מעל שמות תתי-תיקיות מקוננות. תתי-תיקיות נשארות מושרשות בכל מקרה.", "showFolderPathsDesc": "הצג את נתיב התיקייה האם מעל שמות תתי-תיקיות מקוננות. תתי-תיקיות נשארות מושרשות בכל מקרה.",
"stripedRows": "צבעי שורות מתחלפים",
"stripedRowsDesc": "הצלל כל שורה שנייה ברשימת המארחים",
"rowDetailsTitle": "פרטי שורה", "rowDetailsTitle": "פרטי שורה",
"rowDetailsDesc": "בחר מה יופיע בכל מארח.", "rowDetailsDesc": "בחר מה יופיע בכל מארח.",
"showAddress": "כתובת", "showAddress": "כתובת",
@@ -787,6 +789,8 @@
"sharedBadge": "משותף", "sharedBadge": "משותף",
"sharedBadgeTooltip": "משותף על ידי {{owner}} (גישה {{level}})", "sharedBadgeTooltip": "משותף על ידי {{owner}} (גישה {{level}})",
"viewOnlyBanner": "מארח זה משותף איתך על ידי {{owner}} עם גישה לצפייה בלבד. התצורה היא לקריאה בלבד.", "viewOnlyBanner": "מארח זה משותף איתך על ידי {{owner}} עם גישה לצפייה בלבד. התצורה היא לקריאה בלבד.",
"folderRuleRemoveFromHosts": "הסר גם גישה זו מכל מארח בתיקייה ובתיקיות המשנה שלה",
"sharedCopyBanner": "מארח זה שותף איתך על ידי {{owner}}. השולחן עבודה שומר עותק לקריאה בלבד, כך ששינויים במארח יסונכרנו לכאן.",
"sharedEditBanner": "מארח זה משותף איתך על ידי {{owner}} עם גישה לעריכה. שינויים מוחלים על המארח האמיתי; הפניות לאימות יכולות להשתנות רק על ידי הבעלים.", "sharedEditBanner": "מארח זה משותף איתך על ידי {{owner}} עם גישה לעריכה. שינויים מוחלים על המארח האמיתי; הפניות לאימות יכולות להשתנות רק על ידי הבעלים.",
"ownerOnlyControl": "רק בעל המארח יכול לשנות זאת", "ownerOnlyControl": "רק בעל המארח יכול לשנות זאת",
"ownerAuthPrivate": "אימות ה-SSH של בעל המארח הוא פרטי. השתמש ב-\"הגדר אימות SSH אישי\" מתפריט המארח כדי לבחור אישור משלך.", "ownerAuthPrivate": "אימות ה-SSH של בעל המארח הוא פרטי. השתמש ב-\"הגדר אימות SSH אישי\" מתפריט המארח כדי לבחור אישור משלך.",
+4
View File
@@ -590,6 +590,8 @@
"closeOnOpenDesc": "इससे होस्ट खोलने के बाद साइडबार बंद करें", "closeOnOpenDesc": "इससे होस्ट खोलने के बाद साइडबार बंद करें",
"showFolderPaths": "फ़ोल्डर पथ दिखाएं", "showFolderPaths": "फ़ोल्डर पथ दिखाएं",
"showFolderPathsDesc": "नेस्टेड सबफ़ोल्डर नामों के ऊपर माता-पिता फ़ोल्डर का पथ दिखाएं। सबफ़ोल्डर दोनों स्थिति में अंतर्निहित रहते हैं।", "showFolderPathsDesc": "नेस्टेड सबफ़ोल्डर नामों के ऊपर माता-पिता फ़ोल्डर का पथ दिखाएं। सबफ़ोल्डर दोनों स्थिति में अंतर्निहित रहते हैं।",
"stripedRows": "बारी-बारी से पंक्ति के रंग",
"stripedRowsDesc": "होस्ट सूची में हर दूसरी पंक्ति को छाया दें",
"rowDetailsTitle": "पंक्ति विवरण", "rowDetailsTitle": "पंक्ति विवरण",
"rowDetailsDesc": "चुनें कि प्रत्येक होस्ट पर क्या दिखाया जाए।", "rowDetailsDesc": "चुनें कि प्रत्येक होस्ट पर क्या दिखाया जाए।",
"showAddress": "पता", "showAddress": "पता",
@@ -775,6 +777,8 @@
"sharedBadge": "साझा किया गया", "sharedBadge": "साझा किया गया",
"sharedBadgeTooltip": "{{owner}} द्वारा साझा किया गया ({{level}} एक्सेस)", "sharedBadgeTooltip": "{{owner}} द्वारा साझा किया गया ({{level}} एक्सेस)",
"viewOnlyBanner": "यह होस्ट {{owner}} द्वारा आपके साथ देखने के एक्सेस के साथ साझा किया गया है। कॉन्फ़िगरेशन रीड-ओनली है।", "viewOnlyBanner": "यह होस्ट {{owner}} द्वारा आपके साथ देखने के एक्सेस के साथ साझा किया गया है। कॉन्फ़िगरेशन रीड-ओनली है।",
"folderRuleRemoveFromHosts": "इस फ़ोल्डर और इसके सबफ़ोल्डर्स में मौजूद हर होस्ट से भी यह एक्सेस हटाएं",
"sharedCopyBanner": "यह होस्ट {{owner}} द्वारा आपके साथ शेयर किया गया है। डेस्कटॉप इसका रीड-ओनली कॉपी रखता है, इसलिए सर्वर पर बदलाव करें और यह यहाँ सिंक हो जाएगा।",
"sharedEditBanner": "यह होस्ट {{owner}} द्वारा आपके साथ एडिट एक्सेस के साथ साझा किया गया है। परिवर्तन असली होस्ट पर लागू होते हैं; प्रमाणीकरण संदर्भों को केवल मालिक ही बदल सकता है।", "sharedEditBanner": "यह होस्ट {{owner}} द्वारा आपके साथ एडिट एक्सेस के साथ साझा किया गया है। परिवर्तन असली होस्ट पर लागू होते हैं; प्रमाणीकरण संदर्भों को केवल मालिक ही बदल सकता है।",
"ownerOnlyControl": "केवल होस्ट मालिक ही इसे बदल सकता है", "ownerOnlyControl": "केवल होस्ट मालिक ही इसे बदल सकता है",
"ownerAuthPrivate": "होस्ट मालिक का SSH प्रमाणीकरण निजी है। अपना क्रेडेंशियल चुनने के लिए होस्ट मेनू से “व्यक्तिगत SSH प्रमाणीकरण सेट करें” का उपयोग करें।", "ownerAuthPrivate": "होस्ट मालिक का SSH प्रमाणीकरण निजी है। अपना क्रेडेंशियल चुनने के लिए होस्ट मेनू से “व्यक्तिगत SSH प्रमाणीकरण सेट करें” का उपयोग करें।",
+4
View File
@@ -596,6 +596,8 @@
"closeOnOpenDesc": "Zatvori bočnu traku nakon otvaranja hosta iz nje", "closeOnOpenDesc": "Zatvori bočnu traku nakon otvaranja hosta iz nje",
"showFolderPaths": "Prikaži putanje mapa", "showFolderPaths": "Prikaži putanje mapa",
"showFolderPathsDesc": "Prikažite putanju nadređene mape iznad naziva ugniježđenih podmapa. Podmape ostaju uvučene u svakom slučaju.", "showFolderPathsDesc": "Prikažite putanju nadređene mape iznad naziva ugniježđenih podmapa. Podmape ostaju uvučene u svakom slučaju.",
"stripedRows": "Naizmjenične boje redaka",
"stripedRowsDesc": "Obojite svaki drugi redak u popisu hostova",
"rowDetailsTitle": "Detalji retka", "rowDetailsTitle": "Detalji retka",
"rowDetailsDesc": "Odaberite što se prikazuje za svaki host.", "rowDetailsDesc": "Odaberite što se prikazuje za svaki host.",
"showAddress": "Adresa", "showAddress": "Adresa",
@@ -787,6 +789,8 @@
"sharedBadge": "Dijeljeno", "sharedBadge": "Dijeljeno",
"sharedBadgeTooltip": "Dijeli {{owner}} (razina pristupa: {{level}})", "sharedBadgeTooltip": "Dijeli {{owner}} (razina pristupa: {{level}})",
"viewOnlyBanner": "Ovaj host dijeli s vama korisnik {{owner}} s pristupom za pregled. Konfiguracija je samo za čitanje.", "viewOnlyBanner": "Ovaj host dijeli s vama korisnik {{owner}} s pristupom za pregled. Konfiguracija je samo za čitanje.",
"folderRuleRemoveFromHosts": "Ukloni i ovaj pristup sa svih hostova u ovoj mapi i podmapama",
"sharedCopyBanner": "Ovaj host dijeli s vama {{owner}}. Desktop zadržava kopiju samo za čitanje, pa ga promijenite na poslužitelju i sinkronizirat će se ovdje.",
"sharedEditBanner": "Ovaj host dijeli s vama korisnik {{owner}} s pristupom za uređivanje. Promjene se primjenjuju na stvarni host; reference za autentifikaciju može mijenjati samo vlasnik.", "sharedEditBanner": "Ovaj host dijeli s vama korisnik {{owner}} s pristupom za uređivanje. Promjene se primjenjuju na stvarni host; reference za autentifikaciju može mijenjati samo vlasnik.",
"ownerOnlyControl": "Ovo može promijeniti samo vlasnik hosta", "ownerOnlyControl": "Ovo može promijeniti samo vlasnik hosta",
"ownerAuthPrivate": "SSH autentifikacija vlasnika hosta je privatna. Odaberite vlastite vjerodajnice putem opcije „Postavi osobnu SSH autentifikaciju“ u izborniku hosta.", "ownerAuthPrivate": "SSH autentifikacija vlasnika hosta je privatna. Odaberite vlastite vjerodajnice putem opcije „Postavi osobnu SSH autentifikaciju“ u izborniku hosta.",
+4
View File
@@ -590,6 +590,8 @@
"closeOnOpenDesc": "Az oldalsáv bezárása egy gazdagép megnyitása után", "closeOnOpenDesc": "Az oldalsáv bezárása egy gazdagép megnyitása után",
"showFolderPaths": "Mappa-utak megjelenítése", "showFolderPaths": "Mappa-utak megjelenítése",
"showFolderPathsDesc": "A szülőmappa-út megjelenítése az egymásba ágyazott almappanevek felett. Az almappák mindkét esetben behúzásban maradnak.", "showFolderPathsDesc": "A szülőmappa-út megjelenítése az egymásba ágyazott almappanevek felett. Az almappák mindkét esetben behúzásban maradnak.",
"stripedRows": "Váltakozó sorszínek",
"stripedRowsDesc": "A hosztlista minden második sorának árnyékolása",
"rowDetailsTitle": "Sor részletei", "rowDetailsTitle": "Sor részletei",
"rowDetailsDesc": "Válassza ki, mi jelenjen meg minden kiszolgálónál.", "rowDetailsDesc": "Válassza ki, mi jelenjen meg minden kiszolgálónál.",
"showAddress": "Cím", "showAddress": "Cím",
@@ -775,6 +777,8 @@
"sharedBadge": "Megosztott", "sharedBadge": "Megosztott",
"sharedBadgeTooltip": "Megosztotta: {{owner}} ({{level}} hozzáférés)", "sharedBadgeTooltip": "Megosztotta: {{owner}} ({{level}} hozzáférés)",
"viewOnlyBanner": "Ezt a hosztot a(z) {{owner}} osztotta meg veled megtekintési jogosultsággal. A konfiguráció csak olvasható.", "viewOnlyBanner": "Ezt a hosztot a(z) {{owner}} osztotta meg veled megtekintési jogosultsággal. A konfiguráció csak olvasható.",
"folderRuleRemoveFromHosts": "Az engedélyt eltávolítja a mappában és almappáiban lévő összes hosztról is",
"sharedCopyBanner": "Ezt a hosztot a(z) {{owner}} osztotta meg veled. Az asztali alkalmazás egy csak olvasható másolatot tart meg, így ha a szerveren módosítod, a változás szinkronizálódik ide.",
"sharedEditBanner": "Ezt a hosztot a(z) {{owner}} osztotta meg veled szerkesztési jogosultsággal. A módosítások a valódi hosztra vonatkoznak; a hitelesítési hivatkozásokat csak a tulajdonos módosíthatja.", "sharedEditBanner": "Ezt a hosztot a(z) {{owner}} osztotta meg veled szerkesztési jogosultsággal. A módosítások a valódi hosztra vonatkoznak; a hitelesítési hivatkozásokat csak a tulajdonos módosíthatja.",
"ownerOnlyControl": "Ezt csak a hoszt tulajdonosa módosíthatja", "ownerOnlyControl": "Ezt csak a hoszt tulajdonosa módosíthatja",
"ownerAuthPrivate": "A hoszt tulajdonosa SSH-hitelesítése privát. A saját hitelesítő adataid kiválasztásához használd a „Saját SSH-hitelesítés beállítása” menüpontot a hoszt menüjében.", "ownerAuthPrivate": "A hoszt tulajdonosa SSH-hitelesítése privát. A saját hitelesítő adataid kiválasztásához használd a „Saját SSH-hitelesítés beállítása” menüpontot a hoszt menüjében.",
Loaded 100 of 133 files, more files were not shown because too many files have changed in this diff. Show more