mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-05 18:15:24 +00:00
Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c265c4bc6 | ||
|
|
57eb7fab5c | ||
|
|
a73782e2bc | ||
|
|
18e7f5689b | ||
|
|
a91339f09f | ||
|
|
a2308c9d71 | ||
|
|
2ee31ac9cc | ||
|
|
4aead47049 | ||
|
|
19bdd589f3 | ||
|
|
1867be3f58 | ||
|
|
09e04d076a | ||
|
|
ea4e2d2124 | ||
|
|
c7aa1c64b5 | ||
|
|
9613b787a7 | ||
|
|
1c4fa52de3 | ||
|
|
2e79bf9d0d | ||
|
|
03029cd372 | ||
|
|
446596989e | ||
|
|
b7f749cc9d | ||
|
|
3ae56a96d0 | ||
|
|
631f6ac9f7 | ||
|
|
cc725c7889 | ||
|
|
b87fe3c6a5 | ||
|
|
b9df36b09a | ||
|
|
31df4b12e3 | ||
|
|
80612c304b | ||
|
|
a2aafc11ec | ||
|
|
3ab3022451 | ||
|
|
037b0329bd | ||
|
|
78a1e90d93 | ||
|
|
f5895f15f2 | ||
|
|
4fb91850a0 | ||
|
|
6b06059411 | ||
|
|
1d4eac3e20 | ||
|
|
c759a147c6 | ||
|
|
cba845a7b8 | ||
|
|
f90621a54e |
No files matched your search
@@ -1316,7 +1316,9 @@ jobs:
|
||||
--app_identifier "com.karmaa.termix" \
|
||||
--skip_metadata false \
|
||||
--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 \
|
||||
--submit_for_review true \
|
||||
--automatic_release true \
|
||||
|
||||
@@ -1,5 +1,58 @@
|
||||
# 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, when an admin makes them
|
||||
- A click on a host can open its host page, when a plugin offers one
|
||||
- Plugins can build pages of tiles you drag and resize, and add many copies of one dashboard card, each with its own settings
|
||||
- A plugin a newer release ships is downloaded on its own when your image does not have it, and tried again on the next start when offline
|
||||
|
||||
### Changed
|
||||
|
||||
- Plugin cards and pages use the dashboard look everywhere, with shared lines between sections instead of separate boxes with gaps
|
||||
- The Plugins tab and plugin pages use the same look
|
||||
- Line charts that cover a few minutes show seconds in their times
|
||||
- Plugins a new release adds to the image start on only when they are recommended. The rest are installed and left off for an admin to turn on
|
||||
|
||||
- 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
|
||||
- Dropdowns and text areas are the same size as the text fields next to them
|
||||
- A page's own tabs sit above its search bar
|
||||
|
||||
### 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
|
||||
- An installed plugin update older than the copy in a newer image is removed at startup instead of logging an error every boot
|
||||
- Canceling a download to the desktop app right as it starts no longer leaves a partial file behind
|
||||
- Changing two interface settings at once, like resetting the dashboard, now saves both instead of only the last one
|
||||
- Release notes on a plugin's page are no longer cut off on the left
|
||||
- The Community badge in Browse is no longer missing its top border
|
||||
- Official plugin pages no longer show a stray divider before the author
|
||||
- The version picker on a plugin's page is no longer empty for a plugin installed from a local build
|
||||
- The plugin video no longer has a second border around it
|
||||
- The hint at the bottom of Browse and the stored data footer on plugin pages have the same padding as the rest of the page
|
||||
- Sign in methods on the Security page have a line between them
|
||||
- Chart axis labels with long units, like KiB/s, are no longer cut off
|
||||
|
||||
## 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
|
||||
|
||||
### Added
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
cask "termix" do
|
||||
version "26.10.5"
|
||||
sha256 "808c467c5c34402193c7950ce61df0fa0445782f276348cf31ef235a6427d21c"
|
||||
version "26.11.0"
|
||||
sha256 "1b873e1315d769f8e416c1690032142efb152d1265c2f078b33f72cec3e6e03e"
|
||||
|
||||
url "https://github.com/Termix-SSH/Termix/releases/download/v#{version}/termix_macos_universal_dmg.dmg"
|
||||
name "Termix"
|
||||
|
||||
@@ -64,15 +64,18 @@ Plugins add everything else. The official ones ship with Termix, and admins can
|
||||
| [Alerts](https://github.com/Termix-SSH/Plugin-Alerts) | One inbox for every alert in Termix, with delivery to webhooks, ntfy, Discord and email. |
|
||||
| [Audit Tools](https://github.com/Termix-SSH/Plugin-Audit-Tools) | Send the audit log to your SIEM and export it as CSV or NDJSON. |
|
||||
| [Automations](https://github.com/Termix-SSH/Plugin-Automations) | Run steps on your hosts on a schedule or when something happens. |
|
||||
| [Backups](https://github.com/Termix-SSH/Plugin-Backups) | Back up folders, databases and Docker data on your hosts with restic, then browse, download and restore. |
|
||||
| [Certificates](https://github.com/Termix-SSH/Plugin-Certificates) | See, issue and renew TLS certificates on your hosts with certbot and acme.sh, and get an alert before they expire. |
|
||||
| [Classic Layout](https://github.com/Termix-SSH/Plugin-Classic-Layout) | The classic Termix layout with an icon rail, sidebar, tab bar and dock. |
|
||||
| [Classic Themes](https://github.com/Termix-SSH/Plugin-Classic-Themes) | Light, dark and seven more color themes for Termix. |
|
||||
| [Cloud Providers](https://github.com/Termix-SSH/Plugin-Cloud-Providers) | Add hosts from AWS, Azure, Google Cloud, DigitalOcean, Hetzner and other clouds, and keep them in sync. |
|
||||
| [Credential Tools](https://github.com/Termix-SSH/Plugin-Credential-Tools) | Generate SSH keys, check them and copy a public key to a host. |
|
||||
| [Cron](https://github.com/Termix-SSH/Plugin-Cron) | See, add and run cron jobs on your hosts, with a schedule preview and the system cron files. |
|
||||
| [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. |
|
||||
| [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. |
|
||||
| [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. |
|
||||
| [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. |
|
||||
@@ -82,7 +85,9 @@ Plugins add everything else. The official ones ship with Termix, and admins can
|
||||
| [LDAP](https://github.com/Termix-SSH/Plugin-LDAP) | Sign in with your LDAP or Active Directory username and password. |
|
||||
| [Login Security](https://github.com/Termix-SSH/Plugin-Login-Security) | Sign people out when idle, limit session length, turn off remember me and require a second factor at login. |
|
||||
| [Network Topology](https://github.com/Termix-SSH/Plugin-Network-Topology) | Draws your hosts and the links between them as a live, interactive graph. |
|
||||
| [Operations Center](https://github.com/Termix-SSH/Plugin-Operations-Center) | Run, roll out, approve and track every plugin action across your hosts, with a page per host and tables across all of them. |
|
||||
| [OPKSSH](https://github.com/Termix-SSH/Plugin-OPKSSH) | Connect to hosts with short-lived OpenPubkey SSH certificates after signing in with your identity provider. |
|
||||
| [Packages](https://github.com/Termix-SSH/Plugin-Package-Manager) | See and install updates on your hosts, search, install and remove packages, and read the package history. |
|
||||
| [Passkeys](https://github.com/Termix-SSH/Plugin-WebAuthn) | Sign in with a passkey or security key instead of a password. |
|
||||
| [Proxmox](https://github.com/Termix-SSH/Plugin-Proxmox) | Import Proxmox VE guests as hosts, keep them in sync and watch node stats. |
|
||||
| [Remote Desktop](https://github.com/Termix-SSH/Plugin-Remote-Desktop) | RDP, VNC and Telnet sessions in your browser, with clipboard, file transfer, sharing and recording. |
|
||||
@@ -96,6 +101,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. |
|
||||
| [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. |
|
||||
| [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. |
|
||||
| [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. |
|
||||
@@ -105,9 +111,11 @@ 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. |
|
||||
| [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. |
|
||||
| [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. |
|
||||
| [Users](https://github.com/Termix-SSH/Plugin-User-Manager) | Manage the user accounts, groups, passwords and SSH keys on your hosts. |
|
||||
| [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. |
|
||||
| [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. |
|
||||
|
||||
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 +135,15 @@ Want to build your own? Start from the [plugin template](https://github.com/Term
|
||||
</tr>
|
||||
<tr>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<td align="center"><b>iOS/iPadOS</b> <sub>v15.1+</sub></td>
|
||||
|
||||
+1
-1
@@ -114,7 +114,7 @@ ENV DATA_DIR=/app/data \
|
||||
PORT=8080 \
|
||||
NODE_ENV=production
|
||||
|
||||
RUN apt-get update && apt-get install -y nginx gettext-base openssl ca-certificates gosu wget && \
|
||||
RUN apt-get update && apt-get install -y nginx gettext-base openssl ca-certificates gosu wget iputils-ping && \
|
||||
update-ca-certificates && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
mkdir -p /app/data /app/uploads /app/nginx /tmp/nginx && \
|
||||
|
||||
+62
-46
@@ -10,15 +10,15 @@
|
||||
{
|
||||
"id": "ai",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.3.0/ai-1.3.0.tmxplug",
|
||||
"sha256": "87ad17324cec6036dc224135fee342250c37413dccb2963f5f6b554d4ab8f5a6",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.3.1/ai-1.3.1.tmxplug",
|
||||
"sha256": "dbee8bc101b8584e9e356bc3539787bb534ffc25809cd92c4f5d45d52cad4ce2",
|
||||
"tier": "full"
|
||||
},
|
||||
{
|
||||
"id": "alerts",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.1.0/alerts-1.1.0.tmxplug",
|
||||
"sha256": "a7ce9e20ce3c15f77c15479b4901563adf3c793e24a3aacd7226950491f0434c",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.2.0/alerts-1.2.0.tmxplug",
|
||||
"sha256": "488947482eed0306893c2983f701a0e14c55975ca2a0c4bb0799a40fce9db054",
|
||||
"tier": "standard",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
@@ -42,8 +42,8 @@
|
||||
{
|
||||
"id": "classic-layout",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Classic-Layout/releases/download/v1.0.2/classic-layout-1.0.2.tmxplug",
|
||||
"sha256": "cf54f9b2e81c33b7e58cd3a21e203805f08ba37b3bc78412a08eb21b9fef8f14",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Classic-Layout/releases/download/v1.1.1/classic-layout-1.1.1.tmxplug",
|
||||
"sha256": "2b0cee0c57b21d8017ed833aa923e934b57b908fa68ff395951b4d52e5113e47",
|
||||
"tier": "minimal",
|
||||
"defaultLook": true
|
||||
},
|
||||
@@ -67,20 +67,24 @@
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Credential-Tools/releases/download/v1.0.0/credential-tools-1.0.0.tmxplug",
|
||||
"sha256": "413eff710b5c832a2575a890bbab1c90d28ea55f10cd7a7b94b851d51dd77dce",
|
||||
"tier": "standard"
|
||||
"tier": "standard",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "databases",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Databases/releases/download/v1.1.0/databases-1.1.0.tmxplug",
|
||||
"sha256": "4d9504cde705688a6f1f7706139d4e4c49b2276eccc67538fdee8251f29920b8",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Databases/releases/download/v1.1.1/databases-1.1.1.tmxplug",
|
||||
"sha256": "316e26c682f5b07b0809e87f8ea94e2b819a9c46fc0e22611b24069c804312d0",
|
||||
"tier": "full"
|
||||
},
|
||||
{
|
||||
"id": "docker",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.2.1/docker-1.2.1.tmxplug",
|
||||
"sha256": "b2ca797465d88cfb549a04c3f0161708669def22a5ca73cb107879847c2db8ff",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.2.3/docker-1.2.3.tmxplug",
|
||||
"sha256": "c997d5d1a751703facef544e901fb52e93fb79d5dc1c59496d4cb624210bbe14",
|
||||
"tier": "standard",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
@@ -90,8 +94,8 @@
|
||||
{
|
||||
"id": "file-manager",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.1.1/file-manager-1.1.1.tmxplug",
|
||||
"sha256": "ebba0b7d14facb3354f22a00e059e9f5a69e233458cb24985bf4ed5e5f8d2abd",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.1.3/file-manager-1.1.3.tmxplug",
|
||||
"sha256": "b17315011c06db2223f04dac4606da505249dd8f2fec8aa24f1322c4dfd4d6ca",
|
||||
"tier": "standard",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
@@ -101,8 +105,8 @@
|
||||
{
|
||||
"id": "fleets",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.2/fleets-1.0.2.tmxplug",
|
||||
"sha256": "50dab38e1c6c95fdb4d0544ce50cdf84dae84789e64aa34ea9d04fdac3a38c59",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.1.0/fleets-1.1.0.tmxplug",
|
||||
"sha256": "5e0218c6145524ae8776b8fcbae043749c893d9b04fb67a9b3fe262ff76c0573",
|
||||
"tier": "full"
|
||||
},
|
||||
{
|
||||
@@ -115,8 +119,8 @@
|
||||
{
|
||||
"id": "host-metrics",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.2.0/host-metrics-1.2.0.tmxplug",
|
||||
"sha256": "8522cd23b558c3209821cee6d177daeb80f439b8ff652c577c5e4955315cc7a3",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.3.1/host-metrics-1.3.1.tmxplug",
|
||||
"sha256": "3c14a72b3efa2a8b0010229acba64a6a4098e8c2e35d5a01cbc3cb731df39fb5",
|
||||
"tier": "standard",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
@@ -128,7 +132,11 @@
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Host-Status/releases/download/v1.0.0/host-status-1.0.0.tmxplug",
|
||||
"sha256": "1ba62577dc7373a2a2787bab7efdb89b544a30a8003b1de91ce51f372fc4f170",
|
||||
"tier": "standard"
|
||||
"tier": "standard",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "hosts-config",
|
||||
@@ -140,9 +148,13 @@
|
||||
{
|
||||
"id": "import-export",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Import-Export/releases/download/v1.0.0/import-export-1.0.0.tmxplug",
|
||||
"sha256": "6670b6c147e81eed0ddfd249f9a7c9564f1ab50e45f2771161d2e30c47f6fd58",
|
||||
"tier": "standard"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Import-Export/releases/download/v1.0.1/import-export-1.0.1.tmxplug",
|
||||
"sha256": "d8f2f865e1e6b226f4fe38501bf69b752f47a643bed1d9d127181083e27da2f0",
|
||||
"tier": "standard",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "kubernetes",
|
||||
@@ -182,15 +194,15 @@
|
||||
{
|
||||
"id": "proxmox",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.1.0/proxmox-1.1.0.tmxplug",
|
||||
"sha256": "8937d239ad40033d69fba59085fe8896ee25c61951c21f66ff98531fd4c63cbf",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.2.1/proxmox-1.2.1.tmxplug",
|
||||
"sha256": "d5a6e9cd50756785ede6aec351e2de08927816f71e54f3162c52331e0a804d74",
|
||||
"tier": "full"
|
||||
},
|
||||
{
|
||||
"id": "remote-desktop",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.2.1/remote-desktop-1.2.1.tmxplug",
|
||||
"sha256": "ad244f31016f0ea5df5c303bf6f48af35c6c8e52b12098d381c8296626b73de7",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.3.1/remote-desktop-1.3.1.tmxplug",
|
||||
"sha256": "41483f8d64995206e0199cca7d37033e5b1f1194725da3525e1b8afa1b4e09a9",
|
||||
"tier": "standard",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
@@ -200,8 +212,8 @@
|
||||
{
|
||||
"id": "reverse-proxy",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Reverse-Proxy/releases/download/v1.1.0/reverse-proxy-1.1.0.tmxplug",
|
||||
"sha256": "c8a9851da7ca6291c1f47cf6ab235da5057455a86b4ed0dc1c1a3026afb30aa7",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Reverse-Proxy/releases/download/v1.1.1/reverse-proxy-1.1.1.tmxplug",
|
||||
"sha256": "f5e0089ca5eae1254f21ce18b222e70eb2beeae3bd874c2abc4faa1a68d3e47a",
|
||||
"tier": "full"
|
||||
},
|
||||
{
|
||||
@@ -214,8 +226,8 @@
|
||||
{
|
||||
"id": "secret-sources",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.2/secret-sources-1.0.2.tmxplug",
|
||||
"sha256": "82e2b1e95b0837dd3e5fee277be4eea2d1ce68567b0e204941c48150f415d134",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.1.1/secret-sources-1.1.1.tmxplug",
|
||||
"sha256": "2211ce2dac6355db49590532dfc339dc44df9cf14008157b420c7d4533d669ee",
|
||||
"tier": "full"
|
||||
},
|
||||
{
|
||||
@@ -232,15 +244,15 @@
|
||||
{
|
||||
"id": "service-manager",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Service-Manager/releases/download/v1.1.0/service-manager-1.1.0.tmxplug",
|
||||
"sha256": "82b1c70abd97ed535f217535c667fe6c48e5e01e2bf8037a89b58b80d35206bf",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Service-Manager/releases/download/v1.1.1/service-manager-1.1.1.tmxplug",
|
||||
"sha256": "5731a04336d341a330416cba7b7999f0d8e6215752eabbf978ed58c4543c5036",
|
||||
"tier": "full"
|
||||
},
|
||||
{
|
||||
"id": "session-recording",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.1.0/session-recording-1.1.0.tmxplug",
|
||||
"sha256": "31cd78d67e061242f8776849a2fae5f84ab48c7c765d487b32c5e1693c54b46a",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.1.1/session-recording-1.1.1.tmxplug",
|
||||
"sha256": "8e77087f98227ac37a8610dcc5380ad65162a58aebd9174a6c966d5a16b16c9e",
|
||||
"tier": "full"
|
||||
},
|
||||
{
|
||||
@@ -260,8 +272,8 @@
|
||||
{
|
||||
"id": "snippets",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.1.0/snippets-1.1.0.tmxplug",
|
||||
"sha256": "e2d7d1ebe5bdf2986ffb4961ac6f6d32b392b02c30a64a333a7eeb2ce6a46bb3",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.2.0/snippets-1.2.0.tmxplug",
|
||||
"sha256": "a72c0e1f38e49cf86ed8993b6913a6fbbdad7814ae132d5186c097ee3ea09bc3",
|
||||
"tier": "standard",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
@@ -271,8 +283,8 @@
|
||||
{
|
||||
"id": "ssh-terminal",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.1.1/ssh-terminal-1.1.1.tmxplug",
|
||||
"sha256": "5cfb381a2de10b7a6fab108d79645bb1e6e7316fb5e4150c4529a3e76a2193d8",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.3.0/ssh-terminal-1.3.0.tmxplug",
|
||||
"sha256": "ad39449f1e60724a9f84451e1190982e5d3ecd17e1751ec09a3b21c228e6dd5e",
|
||||
"tier": "standard",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
@@ -282,8 +294,8 @@
|
||||
{
|
||||
"id": "sso",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.4/sso-1.0.4.tmxplug",
|
||||
"sha256": "2aad33177085fbc92fe06cd007facffbfc8f42f9c9fa688ad5f37d68f62093d9",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.5/sso-1.0.5.tmxplug",
|
||||
"sha256": "c1353d17fd25c1f503528c2a64a31ef14a638d9845c1d6cd70bb540e0321d41c",
|
||||
"tier": "standard",
|
||||
"onboarding": {
|
||||
"recommended": false,
|
||||
@@ -301,9 +313,13 @@
|
||||
{
|
||||
"id": "sync",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Sync/releases/download/v1.0.1/sync-1.0.1.tmxplug",
|
||||
"sha256": "66a301fd04fb8d2a3ece18dfb230069cf78e1138ee78941dc511dfbd8ee4f91e",
|
||||
"tier": "standard"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Sync/releases/download/v1.1.0/sync-1.1.0.tmxplug",
|
||||
"sha256": "9023d547f0e0bcef582c6ba7b7a8ab2ef68d8d0b973dde17d9e8c84d92b6429e",
|
||||
"tier": "standard",
|
||||
"onboarding": {
|
||||
"recommended": "desktop",
|
||||
"consent": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "tailscale",
|
||||
@@ -351,8 +367,8 @@
|
||||
{
|
||||
"id": "tunnels",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.2.1/tunnels-1.2.1.tmxplug",
|
||||
"sha256": "2871a8d31854e1a31416c4afe5839099a4c489e34d9d31573659f73b2515f159",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.2.3/tunnels-1.2.3.tmxplug",
|
||||
"sha256": "5a23953ee19b2951bb895e033d2099b8f6d5abc0121253ef3da25328134984ad",
|
||||
"tier": "standard",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
@@ -390,8 +406,8 @@
|
||||
{
|
||||
"id": "webauthn",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.1.0/webauthn-1.1.0.tmxplug",
|
||||
"sha256": "83a61a530db0273f9fef2080d6aa027a82ef8374fb4e0f158a8559a2770299de",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.2.0/webauthn-1.2.0.tmxplug",
|
||||
"sha256": "4e7e06d05a17b6e474fdb0d45c8fc320f2ea89375bda440f824636cc393d4615",
|
||||
"tier": "standard",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
|
||||
@@ -276,6 +276,15 @@ http {
|
||||
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(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@@ -245,6 +245,15 @@ http {
|
||||
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(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@@ -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
@@ -547,6 +547,13 @@
|
||||
"when": 1793304929772,
|
||||
"tag": "0077_shared_folder_auth_overrides",
|
||||
"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
@@ -547,6 +547,13 @@
|
||||
"when": 1793304928399,
|
||||
"tag": "0077_shared_folder_auth_overrides",
|
||||
"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
@@ -512,6 +512,13 @@
|
||||
"when": 1793304926942,
|
||||
"tag": "0072_shared_folder_auth_overrides",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 73,
|
||||
"version": "6",
|
||||
"when": 1793765382283,
|
||||
"tag": "0073_dazzling_amazoness",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+14
-7
@@ -1235,13 +1235,7 @@ function createTray() {
|
||||
// use the unpacked path so the OS sees a real file.
|
||||
const publicRoot = isDev
|
||||
? path.join(appRoot, "public")
|
||||
: path.join(
|
||||
appRoot.replace(
|
||||
/app(-[a-z0-9]+)?\.asar(?!\.unpacked)/,
|
||||
"app.asar.unpacked",
|
||||
),
|
||||
"public",
|
||||
);
|
||||
: path.join(getUnpackedAppRoot(appRoot), "public");
|
||||
|
||||
let trayIcon;
|
||||
if (process.platform === "darwin") {
|
||||
@@ -2027,6 +2021,19 @@ function createMenu() {
|
||||
{ type: "separator" },
|
||||
{ role: "front" },
|
||||
{ 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" },
|
||||
],
|
||||
},
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "termix",
|
||||
"version": "26.10.6",
|
||||
"version": "26.11.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "termix",
|
||||
"version": "26.10.6",
|
||||
"version": "26.11.1",
|
||||
"hasInstallScript": true,
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"version": "26.10.6",
|
||||
"version": "26.11.1",
|
||||
"description": "Self-hosted, plugin-based server management.",
|
||||
"author": "Karmaa",
|
||||
"main": "electron/main.cjs",
|
||||
|
||||
@@ -1,5 +1,52 @@
|
||||
# Changelog
|
||||
|
||||
## 1.1.0
|
||||
|
||||
Plugin API 1.15.0.
|
||||
|
||||
### Added
|
||||
|
||||
- `/ui` exports `TileGrid`, `TileFrame` and `TilePicker`, the 12 column tile grid the dashboard, the host page and Host Metrics share, plus `appendTile`, `flowTiles`, `removeTile`, `sanitizeTiles` and the `TileSlot` type
|
||||
- `registerDashboardCard` takes `entries`, so one card can be placed many times with its own `config`, and `defaultSize` for the grid
|
||||
- Dashboard cards get `config`, `setConfig` and `editMode`
|
||||
- `/ui` exports `TileWall` (a fixed grid of tiles with shared lines), `TileStack` and `TileColumn` (sections stacked with a line between), `TileEditBar`, and `SeamlessTileContext` with `useInSeamlessTile`
|
||||
- `/ui` exports `StatTile`, `InfoGrid`, `StatusDot`, `StatusBadge` and `statusTone`, `LoadingState`, `NoticePanel`, `Pager` with `usePaged` and `pageOf`, `DataTable` with `DataTableRow`, `SideNav` with `SideNavItem`, `PanelFrame` and `RefreshButton`
|
||||
- `InlineView` takes `stacked`, which lays SectionCards out in one column with lines between them
|
||||
- `GroupHeading` takes `plain`, which drops the rule after the title. Inside a tile layout that is the default
|
||||
|
||||
### Changed
|
||||
|
||||
- `Card`, `SectionCard`, `MetricCard` and `DataView` use the tile look: no fill, and inside a tile layout no box either. `DataView` grids sit edge to edge with shared lines
|
||||
|
||||
## 1.0.25
|
||||
|
||||
Plugin API 1.14.0.
|
||||
|
||||
### Added
|
||||
|
||||
- Host click behavior `hostPage` opens a host page when a plugin offers one
|
||||
- `createApiKey` takes scopes, to limit a key to actions
|
||||
|
||||
## 1.0.24
|
||||
|
||||
Plugin API 1.14.0.
|
||||
|
||||
### Added
|
||||
|
||||
- `app.registerHostSection` adds a card to the host page
|
||||
- `/ui` exports `HostTabEmbed`, which draws any tab type inside another view, the host section registry, and `operationsApi` for core's `/operations` routes
|
||||
|
||||
## 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
|
||||
|
||||
Plugin API 1.13.0.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Termix plugin guide
|
||||
|
||||
SDK 1.0.22 | Plugin API 1.13.0
|
||||
SDK 1.1.0 | Plugin API 1.15.0
|
||||
|
||||
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.
|
||||
@@ -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
|
||||
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
|
||||
|
||||
These come from Termix and are never bundled: `@termix-ssh/plugin-sdk`, `express`, `ssh2`, `ws`, `multer`,
|
||||
@@ -702,9 +766,28 @@ exports with props is `ui.api.txt` in the Termix repo
|
||||
- Small type: most text 11 to 13px.
|
||||
- No new modal dialogs. Ask with `useConfirm()`, open views in place with `InlineView`, and use
|
||||
`PanePrompt` for small prompts.
|
||||
- Every tab or panel sits in `PanelShell`. Lists use `PanelList` and `ListRow`, add with `AddButton`,
|
||||
forms end with `FormFooter`, empty lists show `EmptyState`.
|
||||
- Card grids use `DataView` with `ViewToggle`. Pass `columns` and `onColumns` to `ViewToggle` and the same
|
||||
- Every tab or panel sits in `PanelShell` (inside `PanelFrame` when it is a tab). Lists use `PanelList` and
|
||||
`ListRow`, add with `AddButton`, forms end with `FormFooter`, empty lists show `EmptyState`, loading shows
|
||||
`LoadingState`, and "not installed" or "no access" shows `NoticePanel`.
|
||||
- The tile look: page content sits edge to edge on the page background, with one line between sections and
|
||||
no gaps, padding or card fill around them. The body of a `PanelShell` has no padding. Stack sections with
|
||||
`TileStack` (or `TileColumn` when cards sit deeper), put numbers and small cards in a `TileWall` of
|
||||
`StatTile` or `MetricCard`, and use `TileFrame` for a titled section. Cards drop their own box inside
|
||||
these. Forms keep `PANEL.body` padding inside their section. An `InlineView` form made of `SectionCard`s
|
||||
takes `stacked`.
|
||||
- Page views go in `PanelShell`'s `tabs` band as a `TabStrip`. `Segmented` is for filters and view modes.
|
||||
A page with many sections uses `SideNav` on the left. Put the refresh button in the header with
|
||||
`RefreshButton`, and the docs link with the `docs` prop.
|
||||
- States and data: `StatusBadge` and `StatusDot` (pass a state like "running" and the color follows),
|
||||
`InfoGrid` for label and value pairs, `DataTable` for a plain table, `Pager` and `usePaged` under long lists.
|
||||
- Tile pages use `TileGrid` with `TileFrame` around each tile and `TilePicker` beside it while editing. Slots are
|
||||
`{ id, x, y, w, h }` on 12 columns (`h` null for the natural height); save them where your plugin keeps
|
||||
preferences and run saved ones through `sanitizeTiles`. Add with `appendTile`. While editing, show
|
||||
`TileEditBar` over the grid. Needs Termix 26.11.1.
|
||||
- A dashboard card with `entries` can be placed many times: each entry has a title, a group and a `config` the
|
||||
card gets back as `config` (save changes with `setConfig`, keep it small). Operations Center uses this for
|
||||
status and action tiles.
|
||||
- Card grids use `DataView` with `ViewToggle` (the cards share lines like a `TileWall`). Pass `columns` and `onColumns` to `ViewToggle` and the same
|
||||
value as `size` to `DataView`, so users can pick Auto (as many as fit) or a fixed number of cards per
|
||||
row. `usePluginPanelView` keeps the choice as `columns`. Older Termix versions ignore both props.
|
||||
- Forms: `TextField`, `NumberField`, `SelectField`, `SwitchRow`, `SecretField`, `SettingRow` and friends.
|
||||
@@ -962,6 +1045,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/manifest` | `PluginManifest` type and manifest checks |
|
||||
| `@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/testing` | `createMockCtx`, `createTestDb`, `renderWithApp`, `createFakeContext` |
|
||||
| `@termix-ssh/plugin-sdk/vitest-preset` | `pluginVitestConfig` |
|
||||
@@ -993,6 +1077,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 |
|
||||
| `hosts:export` | high | Export hosts with their saved logins |
|
||||
| `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 |
|
||||
| `hosts:write` | medium | Add and edit hosts |
|
||||
| `hosts:status-provide` | medium | Be the source of host status dots |
|
||||
@@ -1008,6 +1094,7 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
|
||||
| `users:read` | medium | Usernames, roles and UI language |
|
||||
| `events:core` | medium | Listen to core events |
|
||||
| `notify:send` | medium | Send alerts |
|
||||
| `operations:provide` | medium | Offer declared actions and events |
|
||||
| `audit:read` | medium | Read the audit log |
|
||||
| `desktop:window` | medium | Open windows in the desktop app |
|
||||
| `hosts:read` | low | Read hosts the user can see |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@termix-ssh/plugin-sdk",
|
||||
"version": "1.0.22",
|
||||
"version": "1.1.0",
|
||||
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@@ -61,6 +61,10 @@
|
||||
"types": "./dist/openapi.d.ts",
|
||||
"default": "./dist/openapi.js"
|
||||
},
|
||||
"./operations": {
|
||||
"types": "./dist/operations.d.ts",
|
||||
"default": "./dist/operations.js"
|
||||
},
|
||||
"./settings": {
|
||||
"types": "./dist/settings.d.ts",
|
||||
"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": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
|
||||
@@ -14,6 +14,11 @@ import type { HostSshOptions } from "./ssh-options.js";
|
||||
export type { HostSshOptions } from "./ssh-options.js";
|
||||
import type { PluginManifest, PluginSettingsField } from "./manifest.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.
|
||||
@@ -3104,6 +3109,136 @@ export interface PluginPlugins {
|
||||
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 {
|
||||
readonly pluginId: string;
|
||||
readonly manifest: PluginManifest;
|
||||
@@ -3144,6 +3279,8 @@ export interface PluginContext {
|
||||
readonly schedule: PluginSchedule;
|
||||
/** Core's notification channels. Needs notify:send. */
|
||||
readonly notify: PluginNotify;
|
||||
/** Declared actions and events, reachable from every surface. */
|
||||
readonly operations: PluginOperations;
|
||||
/** Outbound HTTP through core's SSRF guard. Needs network:outbound. */
|
||||
readonly fetch: PluginFetch;
|
||||
/** Plain reachability checks. Needs network:probe. */
|
||||
|
||||
@@ -52,6 +52,8 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
|
||||
entry("ssh:transport", "high"),
|
||||
entry("device:serial", "high"),
|
||||
entry("notify:hub", "high"),
|
||||
entry("operations:invoke", "high"),
|
||||
entry("operations:approve", "high"),
|
||||
entry("ui:layout", "high"),
|
||||
|
||||
entry("hosts:write", "medium"),
|
||||
@@ -68,6 +70,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
|
||||
entry("users:read", "medium"),
|
||||
entry("events:core", "medium"),
|
||||
entry("notify:send", "medium"),
|
||||
entry("operations:provide", "medium"),
|
||||
entry("audit:read", "medium"),
|
||||
entry("desktop:window", "medium"),
|
||||
entry("desktop:local", "high"),
|
||||
|
||||
@@ -670,6 +670,22 @@ export type HostStatusIndicatorContribution = HostBadgeContribution;
|
||||
/** A control in the host editor's toolbar. */
|
||||
export type HostEditorToolbarItemContribution = HostBadgeContribution;
|
||||
|
||||
export interface HostSectionProps {
|
||||
host: PluginHostRecord;
|
||||
isVisible: boolean;
|
||||
shell: ShellApi;
|
||||
}
|
||||
|
||||
export interface HostSectionContribution {
|
||||
/** Declare it in manifest contributes.hostSections too. */
|
||||
id: string;
|
||||
titleKey: string;
|
||||
/** Lower sorts first. */
|
||||
order?: number;
|
||||
when?: (host: PluginHostRecord) => boolean;
|
||||
component: ComponentType<HostSectionProps>;
|
||||
}
|
||||
|
||||
export interface HostContextMenuItemContribution {
|
||||
id: string;
|
||||
titleKey: string;
|
||||
@@ -783,6 +799,26 @@ export interface KeybindingDefaultContribution {
|
||||
export interface DashboardCardProps {
|
||||
isVisible: boolean;
|
||||
shell: ShellApi;
|
||||
/** This tile's settings, for cards with `entries`. Empty otherwise. Core 26.11.1+. */
|
||||
config?: Record<string, unknown>;
|
||||
/** Saves this tile's settings. Keep it small, it is stored in one line. */
|
||||
setConfig?: (config: Record<string, unknown>) => void;
|
||||
/** True while the user is arranging the dashboard. */
|
||||
editMode?: boolean;
|
||||
}
|
||||
|
||||
/** One tile a card with `entries` offers in the dashboard's add list. */
|
||||
export interface DashboardCardEntry {
|
||||
/** Stable within the card, used to mark entries already placed. */
|
||||
key: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
/** Group heading in the add list. Defaults to the card title. */
|
||||
group?: string;
|
||||
kind?: "card" | "chart" | "signal" | "action" | "resource" | "session";
|
||||
/** Passed back as `config`. */
|
||||
config: Record<string, unknown>;
|
||||
defaultSize?: { w: number; h?: number | null };
|
||||
}
|
||||
|
||||
export interface DashboardCardContribution {
|
||||
@@ -797,6 +833,13 @@ export interface DashboardCardContribution {
|
||||
* edge, for a strip of numbers. Defaults to "framed".
|
||||
*/
|
||||
frame?: "framed" | "bare";
|
||||
/** Size on the 12 column dashboard grid when added. Core 26.11.1+. */
|
||||
defaultSize?: { w: number; h?: number | null };
|
||||
/**
|
||||
* Lets the card be placed many times, once per entry, each with its own
|
||||
* `config`. Titles come back already translated. Core 26.11.1+.
|
||||
*/
|
||||
entries?: (language: string) => Promise<DashboardCardEntry[]>;
|
||||
component: ComponentType<DashboardCardProps>;
|
||||
}
|
||||
|
||||
@@ -1172,6 +1215,11 @@ export interface TermixApp extends TermixAppInfo {
|
||||
registerHostContextMenuItem: (
|
||||
item: HostContextMenuItemContribution,
|
||||
) => Disposer;
|
||||
/**
|
||||
* A card on the host page. Core 26.11.1 and newer; call it as
|
||||
* app.registerHostSection?.(...) if your plugin allows older cores.
|
||||
*/
|
||||
registerHostSection: (section: HostSectionContribution) => Disposer;
|
||||
registerPaletteEntry: (entry: PaletteEntryContribution) => Disposer;
|
||||
registerPaletteGroup: (group: PaletteGroupContribution) => Disposer;
|
||||
registerKeybindingAction: (action: KeybindingActionContribution) => Disposer;
|
||||
|
||||
@@ -12,6 +12,11 @@
|
||||
|
||||
import semver from "semver";
|
||||
import { isKnownCapability } from "./capabilities.js";
|
||||
import {
|
||||
checkOperationReferences,
|
||||
validateOperations,
|
||||
type PluginOperationsContribution,
|
||||
} from "./operations.js";
|
||||
import {
|
||||
ENV_NAME_PATTERN,
|
||||
MAX_ENV_DESCRIPTION_LENGTH,
|
||||
@@ -37,7 +42,7 @@ export {
|
||||
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
|
||||
* version by the server.
|
||||
*/
|
||||
export const PLUGIN_API_VERSION = "1.13.0";
|
||||
export const PLUGIN_API_VERSION = "1.15.0";
|
||||
|
||||
/** The API major, the default engine.api for a new plugin. */
|
||||
export const SUPPORTED_PLUGIN_API_VERSION = String(
|
||||
@@ -465,6 +470,10 @@ export interface PluginContributions {
|
||||
tabs?: PluginTabContribution[];
|
||||
panels?: 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
|
||||
* collab links), where it is activated with app.guest set.
|
||||
@@ -779,6 +788,8 @@ const ALLOWED_CONTRIBUTES = new Set([
|
||||
"tabs",
|
||||
"panels",
|
||||
"dashboardCards",
|
||||
"hostSections",
|
||||
"operations",
|
||||
"guest",
|
||||
"guestViews",
|
||||
"actions",
|
||||
@@ -1500,6 +1511,8 @@ function validateContributes(
|
||||
validateTabs(contributes.tabs, errors);
|
||||
validateViews(contributes.panels, "panels", errors);
|
||||
validateViews(contributes.dashboardCards, "dashboardCards", errors);
|
||||
validateViews(contributes.hostSections, "hostSections", errors);
|
||||
validateOperations(contributes.operations, pluginId, errors);
|
||||
if (
|
||||
contributes.guest !== undefined &&
|
||||
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 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.
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -68,7 +68,11 @@ import type {
|
||||
PluginSshKeyCredential,
|
||||
PluginSshKeyCredentialInput,
|
||||
} 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 {
|
||||
PluginExternalClientRequest,
|
||||
@@ -355,6 +359,10 @@ export interface FakePluginContext {
|
||||
}>;
|
||||
/** The hub the plugin registered with ctx.notify.serve, if any. */
|
||||
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>". */
|
||||
secretStore: Map<string, string>;
|
||||
/** Every ctx.fetch call, in order. */
|
||||
@@ -566,6 +574,8 @@ export function createFakeContext(
|
||||
const createdSshKeys: FakePluginContext["createdSshKeys"] = [];
|
||||
const notifications: FakePluginContext["notifications"] = [];
|
||||
const notifyHub: FakePluginContext["notifyHub"] = { current: null };
|
||||
const operationHandlers: FakePluginContext["operationHandlers"] = new Map();
|
||||
const operationEvents: FakePluginContext["operationEvents"] = [];
|
||||
const fetches: FakePluginContext["fetches"] = [];
|
||||
const processRuns: FakePluginContext["processRuns"] = [];
|
||||
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: {
|
||||
channels: async () => {
|
||||
if (!actor) throw new Error("ctx.notify needs an acting user");
|
||||
@@ -1554,6 +1604,8 @@ export function createFakeContext(
|
||||
createdSshKeys,
|
||||
notifications,
|
||||
notifyHub,
|
||||
operationHandlers,
|
||||
operationEvents,
|
||||
fetches,
|
||||
processRuns,
|
||||
binaries,
|
||||
|
||||
@@ -80,6 +80,8 @@ DEFAULT_FOLDER_COLOR (value): "#f59145"
|
||||
DEFAULT_FOLDER_ICON (value): "folder"
|
||||
DashboardCardId (type): DashboardCardId
|
||||
DataColumn (type): DataColumn<T>
|
||||
DataTable (value): ({ className, containerClassName, children, ...props }: ClassAttributes<HTMLTableElement> & TableHTMLAttributes<HTMLTableElement> & { containerClassName?: string; }) => Element
|
||||
DataTableRow (value): ({ active, className, onClick, ...props }: ClassAttributes<HTMLTableRowElement> & HTMLAttributes<HTMLTableRowElement> & { active?: boolean; }) => Element
|
||||
DataView (value): <T>({ items, view, density, getKey, renderCard, columns, size, minCardWidth, listColumns, onRowClick, isRowActive, empty, className, }: { items: T[]; view: PanelViewMode; density?: PanelDensity; getKey: (item: T) => string; renderCard: (item: T) => ReactNode; columns: GridColumns; size?: GridSize; minCardWidth?: number; listColumns: DataColumn<T>[]; onRowClick?: (item: T) => void; isRowActive?: (item: T) => boolean; empty: ReactNode; className?: string; }) => Element
|
||||
DefaultOverrides (type): DefaultOverrides
|
||||
DefaultsOnly (value): ({ settingKey, children, }: { settingKey: string; children: ReactNode; }) => Element
|
||||
@@ -133,7 +135,7 @@ GridColumns (type): GridColumns
|
||||
GridLayout (type): GridLayout
|
||||
GridSize (type): GridSize
|
||||
GridSlot (type): GridSlot
|
||||
GroupHeading (value): ({ title, count, action, className, }: { title: string; count?: number; action?: ReactNode; className?: string; }) => Element
|
||||
GroupHeading (value): ({ title, count, action, plain, className, }: { title: string; count?: number; action?: ReactNode; plain?: boolean; className?: string; }) => Element
|
||||
HexColorPicker (value): ({ value, onChange, }: { value: string; onChange: (hex: string) => void; }) => Element
|
||||
Host (type): Host
|
||||
HostActionDef (type): HostActionDef
|
||||
@@ -159,12 +161,16 @@ HostProtocolAuthInput (type): HostProtocolAuthInput
|
||||
HostProtocolDef (type): HostProtocolDef
|
||||
HostProtocols (type): HostProtocols
|
||||
HostRowFields (type): HostRowFields
|
||||
HostSectionDef (type): HostSectionDef
|
||||
HostSidebarFilterState (type): HostSidebarFilterState
|
||||
HostSidebarPreferences (type): HostSidebarPreferences
|
||||
HostTabEmbed (value): ({ host, type, label, data, isVisible, shell, }: { host: Host; type: string; label?: string; data?: Record<string, unknown>; isVisible?: boolean; shell: TabShellCallbacks; }) => Element
|
||||
HostTrayTrigger (type): HostTrayTrigger
|
||||
HttpsSettings (type): HttpsSettings
|
||||
IconPicker (value): ({ value, color, onChange, }: { value: string; color?: string; onChange: (icon: string) => void; }) => Element
|
||||
InlineView (value): ({ open, onOpenChange, title, icon, status, actions, toolbar, footer, width, onBeforeClose, scroll, bare, className, children, }: { open: boolean; onOpenChange: (open: boolean) => void; title: ReactNode; icon?: ReactNode; status?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; footer?: ReactNode; width?: EditingWidth; onBeforeClose?: () => boolean | Promise<boolean>; scroll?: boolean; bare?: boolean; className?: string; children: ReactNode; }) => ReactPortal
|
||||
InfoGrid (value): ({ items, className, }: { items: InfoItem[]; className?: string; }) => Element
|
||||
InfoItem (type): InfoItem
|
||||
InlineView (value): ({ open, onOpenChange, title, icon, status, actions, toolbar, footer, width, onBeforeClose, scroll, bare, stacked, className, children, }: { open: boolean; onOpenChange: (open: boolean) => void; title: ReactNode; icon?: ReactNode; status?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; footer?: ReactNode; width?: EditingWidth; onBeforeClose?: () => boolean | Promise<boolean>; scroll?: boolean; bare?: boolean; stacked?: boolean; className?: string; children: ReactNode; }) => ReactPortal
|
||||
Input (value): ({ className, type, ...props }: DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>) => Element
|
||||
Kbd (value): ForwardRefExoticComponent<KbdProps & RefAttributes<HTMLElement>>
|
||||
KbdKey (value): { ({ className, ...props }: HTMLAttributes<HTMLSpanElement>): Element; displayName: string; }
|
||||
@@ -178,11 +184,12 @@ Label (value): ({ className, ...props }: LabelProps & RefAttributes<HTMLLabelEle
|
||||
LineChart (value): ({ series, timestamps, domain, yFormatter, height, className, }: LineChartProps) => Element
|
||||
LineChartSeries (type): LineChartSeries
|
||||
LinkedAccountInfo (type): LinkedAccountInfo
|
||||
ListBadge (value): ({ tone, className, children, }: { tone?: "destructive" | "muted" | "brand" | "warning" | "success"; className?: string; children: ReactNode; }) => Element
|
||||
ListBadge (value): ({ tone, className, children, }: { tone?: "destructive" | "warning" | "muted" | "brand" | "success"; className?: string; children: ReactNode; }) => Element
|
||||
ListRow (value): ({ title, icon, badges, meta, stripe, tone, color, selected, active, dimmed, leading, trailing, actions, onClick, className, children, ...rest }: { title: ReactNode; icon?: ReactNode; badges?: ReactNode; meta?: ReactNode; stripe?: number; tone?: ListRowTone; color?: string; selected?: boolean; active?: boolean; dimmed?: boolean; leading?: ReactNode; trailing?: ReactNode; actions?: ReactNode; onClick?: () => void; className?: string; children?: ReactNode; } & Omit<HTMLAttributes<HTMLDivElement>, "title" | "children" | "onClick">) => Element
|
||||
ListRowAction (value): ({ label, tone, className, children, ...rest }: { label: string; tone?: "default" | "destructive" | "brand"; className?: string; children: ReactNode; } & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & { ref?: Ref<HTMLButtonElement>; }) => Element
|
||||
ListRowFolder (value): ({ name, icon, count, open, onToggle, stripe, actions, dropActive, onDropItem, emptyText, className, children, }: { name: ReactNode; icon?: ReactNode; count?: number; open: boolean; onToggle: () => void; stripe?: number; actions?: ReactNode; dropActive?: boolean; onDropItem?: () => void; emptyText?: ReactNode; className?: string; children?: ReactNode; }) => Element
|
||||
ListRowTone (type): ListRowTone
|
||||
LoadingState (value): ({ label, compact, className, }: { label?: string; compact?: boolean; className?: string; }) => Element
|
||||
LogContext (type): LogContext
|
||||
LogEntry (type): LogEntry
|
||||
LookSettings (value): ({ isAdmin }: { isAdmin: boolean; }) => Element
|
||||
@@ -199,6 +206,7 @@ MetricCard (value): ({ title, icon, action, children, bodyClassName, scroll, scr
|
||||
MiniStat (value): ({ caption, value, valueClassName, className, }: { caption: ReactNode; value: ReactNode; valueClassName?: string; className?: string; }) => Element
|
||||
NativeView (value): ({ url, ignoreCert, hidden, fallback, className, style, }: NativeViewProps) => Element
|
||||
NativeViewProps (type): NativeViewProps
|
||||
NoticePanel (value): ({ icon: Icon, title, children, action, tone, className, }: { icon?: LucideIcon; title: ReactNode; children?: ReactNode; action?: ReactNode; tone?: "default" | "destructive" | "warning"; className?: string; }) => Element
|
||||
NumberField (value): ({ label, value, onChange, placeholder, hint, error, min, max, disabled, className, }: { label: string; value: number; onChange: (value: number) => void; placeholder?: string; hint?: ReactNode; error?: string; min?: number; max?: number; disabled?: boolean; className?: string; }) => Element
|
||||
OPEN_PLUGINS_EVENT (value): "termix:open-plugins"
|
||||
OpenTabRecord (type): OpenTabRecord
|
||||
@@ -207,9 +215,11 @@ PRESETS (value): Record<"simple" | "balanced" | "advanced", UiAreaPreferences>
|
||||
PROMPT_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 border border-border px-3 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
||||
PROMPT_DESTRUCTIVE_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 bg-destructive px-3 text-xs font-medium text-white transition-colors hover:bg-destructive/90 focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
||||
PROMPT_PRIMARY_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 border border-accent-brand/40 px-3 text-xs font-medium text-accent-brand transition-colors hover:bg-accent-brand/10 focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
||||
Pager (value): ({ page, pages, hasNext, onPage, className, }: { page: number; pages?: number; hasNext?: boolean; onPage: (next: number) => void; className?: string; }) => Element
|
||||
PaletteItemDef (type): PaletteItemDef
|
||||
PanePrompt (value): ({ open, title, titleId, description, icon, tone, actions, onCancel, role, labelledBy, layer, backgroundColor, className, children, }: { open: boolean; title: ReactNode; titleId?: string; description?: ReactNode; icon?: ReactNode; tone?: "default" | "destructive"; actions?: ReactNode; onCancel?: () => void; role?: "dialog" | "alertdialog"; labelledBy?: string; layer?: "connection" | "pane"; backgroundColor?: string; className?: string; children?: ReactNode; }) => Element
|
||||
PanelDensity (type): PanelDensity
|
||||
PanelFrame (value): ({ className, children, }: { className?: string; children: ReactNode; }) => Element
|
||||
PanelList (value): ({ empty, className, children, }: { empty?: ReactNode; className?: string; children?: ReactNode; }) => Element
|
||||
PanelSearch (value): ({ value, onChange, placeholder, fill, autoFocus, disabled, title, onEnter, inputRef, className, }: { value: string; onChange: (next: string) => void; placeholder?: string; fill?: boolean; autoFocus?: boolean; disabled?: boolean; title?: string; onEnter?: () => void; inputRef?: Ref<HTMLInputElement>; className?: string; }) => Element
|
||||
PanelShell (value): ({ chrome, icon, title, status, leading, actions, toolbar, tabs, footer, scroll, docs, className, children, }: { chrome?: boolean; icon?: ReactNode; title?: ReactNode; status?: ReactNode; leading?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; tabs?: ReactNode; footer?: ReactNode; scroll?: boolean; docs?: string; className?: string; children: ReactNode; }) => Element
|
||||
@@ -234,6 +244,7 @@ RadialGaugeProps (type): RadialGaugeProps
|
||||
RailGroup (type): RailGroup
|
||||
RailItemDef (type): RailItemDef
|
||||
RecentActivityItem (type): RecentActivityItem
|
||||
RefreshButton (value): ({ onClick, loading, disabled, label, className, }: { onClick: () => void; loading?: boolean; disabled?: boolean; label?: string; className?: string; }) => Element
|
||||
ReleaseInfo (type): ReleaseInfo
|
||||
Repeater (value): <T>({ label, items, onChange, makeItem, empty, addLabel, hint, renderItem, }: { label: string; items: T[]; onChange: (items: T[]) => void; makeItem: () => T; empty: string; addLabel: string; hint?: ReactNode; renderItem: (item: T, update: (next: T) => void, index: number) => ReactNode; }) => Element
|
||||
ResolvedHostDefault (type): ResolvedHostDefault
|
||||
@@ -246,6 +257,7 @@ SSHHostWithStatus (type): SSHHostWithStatus
|
||||
SSH_AUTH_PROTOCOL (value): "ssh"
|
||||
ScrollArea (value): ({ className, children, ...props }: ScrollAreaProps & RefAttributes<HTMLDivElement>) => Element
|
||||
ScrollBar (value): ({ className, orientation, ...props }: ScrollAreaScrollbarProps & RefAttributes<HTMLDivElement>) => Element
|
||||
SeamlessTileContext (value): Context<boolean>
|
||||
SecretField (value): ({ label, value, onChange, placeholder, hint, error, disabled, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; hint?: ReactNode; error?: string; disabled?: boolean; className?: string; }) => Element
|
||||
SectionCard (value): ({ title, icon, action, children, className, rowId, }: { title: string; icon: ReactNode; action?: ReactNode; children: ReactNode; className?: string; rowId?: string; }) => Element
|
||||
Segmented (value): <T extends string>({ value, onChange, options, className, }: { value: T; onChange: (next: T) => void; options: readonly { value: NoInfer<T>; label: string; icon?: ReactNode; count?: number; title?: string; }[]; className?: string; }) => Element
|
||||
@@ -275,6 +287,9 @@ SheetHeader (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<
|
||||
SheetTitle (value): ({ className, ...props }: DialogTitleProps & RefAttributes<HTMLHeadingElement>) => Element
|
||||
SheetTrigger (value): ({ ...props }: DialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
ShellConfirmCapture (value): () => null
|
||||
SideNav (value): ({ entries, active, onSelect, footer, label, className, }: { entries: SideNavEntry[]; label?: string; active: string; onSelect: (id: string) => void; footer?: ReactNode; className?: string; }) => Element
|
||||
SideNavEntry (type): SideNavEntry
|
||||
SideNavItem (value): ({ entry, active, onClick, }: { entry: SideNavEntry; active: boolean; onClick: () => void; }) => Element
|
||||
Skeleton (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||
Slider (value): ({ className, defaultValue, value, min, max, ...props }: SliderProps & RefAttributes<HTMLSpanElement>) => Element
|
||||
SortKey (type): SortKey
|
||||
@@ -284,11 +299,16 @@ SplitLayoutMenu (value): ({ activeIsSplit, canAddPane, onSplit, onPreset, }: { a
|
||||
SplitOpenTarget (type): SplitOpenTarget
|
||||
SplitSummary (type): SplitSummary
|
||||
StatRow (value): ({ label, value, mono, className, }: { label: ReactNode; value: ReactNode; mono?: boolean; className?: string; }) => Element
|
||||
StatTile (value): ({ label, icon, value, unit, percent, hint, tone, actions, onClick, children, className, }: { label: ReactNode; icon?: ReactNode; value: ReactNode; unit?: ReactNode; percent?: number; hint?: ReactNode; tone?: "default" | "ok" | "warn" | "bad"; actions?: ReactNode; onClick?: () => void; children?: ReactNode; className?: string; }) => Element
|
||||
StatusBadge (value): ({ tone, state, className, children, }: { tone?: StatusTone; state?: string; className?: string; children?: ReactNode; }) => Element
|
||||
StatusDot (value): ({ tone, state, pulse, className, title, }: { tone?: StatusTone; state?: string; pulse?: boolean; className?: string; title?: string; }) => Element
|
||||
StatusTone (type): StatusTone
|
||||
StatusValue (type): StatusValue
|
||||
SurfaceKind (type): SurfaceKind
|
||||
SurfaceScope (value): ({ kind, onEditingChange, onClose, className, children, }: { kind?: SurfaceKind; onClose?: () => void; onEditingChange?: (editing: EditingWidth) => void; className?: string; children: ReactNode; }) => Element
|
||||
Switch (value): ({ className, ...props }: SwitchProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
SwitchRow (value): ({ label, description, badge, checked, onChange, disabled, defaultKey, rowId, }: { label: string; description?: ReactNode; badge?: string; checked: boolean; onChange: (value: boolean) => void; disabled?: boolean; defaultKey?: string; rowId?: string; }) => Element
|
||||
TILE_COLUMNS (value): 12
|
||||
TOTPDialog (value): ({ isOpen, prompt, mode, waiting, onSubmit, onCancel, backgroundColor, }: TOTPDialogProps) => Element
|
||||
Tab (type): Tab
|
||||
TabShellCallbacks (type): TabShellCallbacks
|
||||
@@ -303,6 +323,17 @@ TextField (value): ({ label, value, onChange, placeholder, hint, error, aside, d
|
||||
Textarea (value): ForwardRefExoticComponent<TextareaProps & RefAttributes<HTMLTextAreaElement>>
|
||||
TextareaProps (type): TextareaProps
|
||||
ThemeId (type): string
|
||||
TileColumn (value): ({ className, children, }: { className?: string; children: ReactNode; }) => Element
|
||||
TileEditBar (value): ({ children, onReset, onAdd, onDone, className, }: { children?: ReactNode; onReset?: () => void; onAdd?: () => void; onDone: () => void; className?: string; }) => Element
|
||||
TileFrame (value): ({ title, icon, actions, bare, onTitleClick, className, bodyClassName, children, }: { title?: ReactNode; icon?: ReactNode; actions?: ReactNode; bare?: boolean; onTitleClick?: () => void; className?: string; bodyClassName?: string; children: ReactNode; }) => Element
|
||||
TileGrid (value): ({ slots, editMode, renderTile, tileLabel, onChange, onSettings, gap, stackBelow, minColumns, minHeight, empty, className, }: TileGridProps) => Element
|
||||
TileGridProps (type): TileGridProps
|
||||
TileKind (type): TileKind
|
||||
TilePicker (value): ({ entries, onAdd, onClose, className, }: { entries: TilePickerEntry[]; onAdd: (entry: TilePickerEntry) => void; onClose?: () => void; className?: string; }) => Element
|
||||
TilePickerEntry (type): TilePickerEntry
|
||||
TileSlot (type): TileSlot
|
||||
TileStack (value): ({ className, children, }: { className?: string; children: ReactNode; }) => Element
|
||||
TileWall (value): ({ size, minWidth, gridClassName, className, children, }: { size?: GridSize; minWidth?: number; gridClassName?: string; className?: string; children: ReactNode; }) => Element
|
||||
TlsStatus (type): TlsStatus
|
||||
ToolsTab (type): ToolsTab
|
||||
Tooltip (value): ({ ...props }: TooltipProps) => Element
|
||||
@@ -339,6 +370,7 @@ adminGetUserHosts (value): (targetUserId: string) => Promise<Host[]>
|
||||
adminResetUserPassword (value): (userId: string, newPassword: string, confirmDataWipe?: boolean) => Promise<{ message: string; dataWiped?: boolean; }>
|
||||
adminUpdateUserCredential (value): (targetUserId: string, credentialId: number, credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
|
||||
adminUpdateUserHost (value): (targetUserId: string, hostId: number, hostData: HostData) => Promise<Host>
|
||||
appendTile (value): (slots: TileSlot[], tile: { id: string; w?: number; h?: number; }) => TileSlot[]
|
||||
applyAccentColor (value): (colorValue: string) => void
|
||||
applyFontSize (value): (id: FontSizeId) => void
|
||||
applyPresetSideEffects (value): (preset: "simple" | "balanced" | "advanced") => Promise<void>
|
||||
@@ -354,7 +386,7 @@ changePassword (value): (oldPassword: string, newPassword: string) => Promise<an
|
||||
clearLocalAdaptivePreferences (value): () => void
|
||||
cn (value): (...inputs: ClassValue[]) => string
|
||||
copyToClipboard (value): (text: string) => Promise<boolean>
|
||||
createApiKey (value): (name: string, userId: string, expiresAt?: string) => Promise<CreatedApiKey>
|
||||
createApiKey (value): (name: string, userId: string, expiresAt?: string, scopes?: string[]) => Promise<CreatedApiKey>
|
||||
createCredential (value): (credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
|
||||
createFrontendLogger (value): (serviceName: string, serviceIcon?: string, serviceColor?: string) => FrontendLogger
|
||||
createQuickConnectHost (value): (input: QuickConnectInput) => Host
|
||||
@@ -382,6 +414,7 @@ endSplitDrag (value): (commit: boolean) => void
|
||||
filterPaletteItems (value): (items: PaletteItemDef[], query: string, showWhenEmpty?: boolean) => PaletteItemDef[]
|
||||
findCoreHostDefault (value): (key: string) => CoreHostDefault
|
||||
findMatchingKeybinding (value): (e: KeyboardEvent, bindings: CustomKeybinding[]) => CustomKeybinding
|
||||
flowTiles (value): (tiles: { id: string; w: number; h?: number; }[], rowHeight?: number) => TileSlot[]
|
||||
getActiveSessions (value): () => Promise<ActiveSessionInfo[]>
|
||||
getAdaptiveResourceBudget (value): (kind: AdaptiveTaskKind) => AdaptiveResourceBudget
|
||||
getApiKeys (value): () => Promise<{ apiKeys: ApiKey[]; }>
|
||||
@@ -457,6 +490,7 @@ hostBadgesFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[]
|
||||
hostEditorToolbarItemsFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[]
|
||||
hostMenuItemsFor (value): (all: HostContextMenuItemDef[], host: Host) => HostContextMenuItemDef[]
|
||||
hostProtocolFlags (value): (host: { enableSsh?: boolean; pluginSettings?: Record<string, Record<string, unknown>>; }, list?: HostProtocolDef[]) => HostProtocols
|
||||
hostSectionsFor (value): (all: HostSectionDef[], host: Host) => HostSectionDef[]
|
||||
hostStatusIndicatorsFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[]
|
||||
hydrateLocalSharedHostAuth (value): <T>(host: T) => Promise<T>
|
||||
invalidateHostsAndStatusCaches (value): () => void
|
||||
@@ -475,6 +509,7 @@ linkExternalToPasswordAccount (value): (externalUserId: string, targetUsername:
|
||||
linkedServerUrl (value): () => Promise<string>
|
||||
listHostActions (value): () => HostActionDef[]
|
||||
listHostProtocols (value): () => HostProtocolDef[]
|
||||
listHostSections (value): () => HostSectionDef[]
|
||||
loadPaletteGroup (value): (group: PaletteGroupDef) => Promise<PaletteItemDef[]>
|
||||
logoutUser (value): () => Promise<{ success: boolean; message: string; }>
|
||||
makeHostSshSubTabs (value): (t: (key: string, options?: { defaultValue?: string; }) => string, protocols?: Record<string, boolean>, defaultsMode?: boolean) => HostTab[]
|
||||
@@ -488,6 +523,8 @@ moveSplitDrag (value): (x: number, y: number) => void
|
||||
normalizeLanguageCode (value): (language?: string) => string
|
||||
notifySyncChanged (value): () => void
|
||||
openInSplit (value): (target: SplitOpenTarget, open: () => void) => void
|
||||
operationsApi (value): AxiosInstance
|
||||
pageOf (value): <T>(items: T[], page: number, size: number) => { rows: T[]; page: number; pages: number; }
|
||||
paletteEntriesFor (value): (all: PaletteEntryDef[], scope: "global" | "host", host?: Host) => PaletteEntryDef[]
|
||||
parseCustomKeybindings (value): (raw?: string) => CustomKeybinding[]
|
||||
parseServerTime (value): (ts: string) => Date
|
||||
@@ -509,6 +546,7 @@ releaseUrlFrom (value): (info: VersionInfo) => string
|
||||
rem (value): (px: number) => string
|
||||
removeAdminStatus (value): (userId: string) => Promise<Record<string, unknown>>
|
||||
removeRoleFromUser (value): (userId: string, roleId: number) => Promise<{ success: boolean; }>
|
||||
removeTile (value): (slots: TileSlot[], id: string) => TileSlot[]
|
||||
renameCredentialFolder (value): (oldName: string, newName: string) => Promise<Record<string, unknown>>
|
||||
renameFolder (value): (oldName: string, newName: string) => Promise<Record<string, unknown>>
|
||||
reorderCredentials (value): (positions: { id: number; sortOrder: number; }[]) => Promise<{ updated: number; }>
|
||||
@@ -538,6 +576,7 @@ runHostAction (value): (action: HostActionDef, host: Host, shell: TabShellCallba
|
||||
runVisibleInterval (value): (tick: () => void, intervalMs: number) => () => void
|
||||
sanitizeCredentialSidebarPreferences (value): (input: unknown) => CredentialSidebarPreferences
|
||||
sanitizeHostSidebarPreferences (value): (input: unknown) => HostSidebarPreferences
|
||||
sanitizeTiles (value): (value: unknown, min?: number) => TileSlot[]
|
||||
saveCredentialSidebarPreferences (value): (preferences: CredentialSidebarPreferences) => Promise<void>
|
||||
saveHostDefaultsLevel (value): (target: HostDefaultsTarget, change: HostDefaultsChange) => Promise<{ changedHosts?: number; jobId?: string; }>
|
||||
saveHostSidebarPreferences (value): (preferences: HostSidebarPreferences) => Promise<void>
|
||||
@@ -556,6 +595,7 @@ shareHost (value): (hostId: number, shareData: { targets: ShareTarget[]; permiss
|
||||
shell (value): TabShellCallbacks
|
||||
splitDefaultKey (value): (fullKey: string) => [string, string]
|
||||
sshHostToHost (value): (h: SSHHostWithStatus) => Host
|
||||
statusTone (value): (state: string) => StatusTone
|
||||
tabIcon (value): (type: TabType) => Element
|
||||
tabJumpHotkeyKeys (value): () => string[]
|
||||
takePendingManageRequest (value): () => ManageRequest
|
||||
@@ -598,8 +638,10 @@ useHostContextMenuItems (value): () => HostContextMenuItemDef[]
|
||||
useHostEditorSections (value): () => HostEditorSectionDef[]
|
||||
useHostEditorToolbarItems (value): () => HostBadgeDef[]
|
||||
useHostProtocols (value): () => HostProtocolDef[]
|
||||
useHostSections (value): () => HostSectionDef[]
|
||||
useHostStatus (value): (hostId: number, statusCheckEnabled?: boolean) => StatusValue
|
||||
useHostStatusIndicators (value): () => HostBadgeDef[]
|
||||
useInSeamlessTile (value): () => boolean
|
||||
useInstalledPlugins (value): () => PluginSummary[]
|
||||
useIsDefaultsEditor (value): () => boolean
|
||||
useIsMobile (value): () => boolean
|
||||
@@ -607,6 +649,7 @@ useKeybindingActions (value): () => KeybindingActionDef[]
|
||||
useKeybindingDefaults (value): () => KeybindingDefaultDef[]
|
||||
useOptionalConnectionLog (value): () => ConnectionLogContextType
|
||||
usePageVisibleInterval (value): (callback: () => void, intervalMs: number, enabled?: boolean, options?: Options) => void
|
||||
usePaged (value): <T>(items: T[], size: number, reset?: unknown) => { setPage: Dispatch<SetStateAction<number>>; rows: T[]; page: number; pages: number; }
|
||||
usePaletteEntries (value): () => PaletteEntryDef[]
|
||||
usePaletteGroups (value): () => PaletteGroupDef[]
|
||||
usePanels (value): () => PanelDef[]
|
||||
|
||||
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 |
@@ -74,7 +74,7 @@ async function main() {
|
||||
const staged = [...pinned, ...extra];
|
||||
fs.writeFileSync(
|
||||
path.join(destination, "bundled-index.json"),
|
||||
`${JSON.stringify(buildBundledIndex(plugins, staged), null, 2)}
|
||||
`${JSON.stringify(buildBundledIndex(plugins, staged, profile), null, 2)}
|
||||
`,
|
||||
);
|
||||
|
||||
|
||||
@@ -221,7 +221,34 @@ describe("buildBundledIndex", () => {
|
||||
];
|
||||
expect(buildBundledIndex(plugins, ["a", "c", "local"])).toEqual({
|
||||
version: 1,
|
||||
profile: "standard",
|
||||
plugins: { a: { onboarding: { recommended: true, consent: false } } },
|
||||
catalog: {
|
||||
a: { tier: "full" },
|
||||
b: { tier: "full" },
|
||||
c: { tier: "full" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("lists every pin with its tier and version", () => {
|
||||
const plugins = [
|
||||
{
|
||||
id: "a",
|
||||
tier: "standard",
|
||||
url: "https://example.com/v1.2.0/a-1.2.0.tmxplug",
|
||||
onboarding: null,
|
||||
},
|
||||
{
|
||||
id: "b",
|
||||
tier: "minimal",
|
||||
path: "x/b-2.0.0-beta.1.tmxplug",
|
||||
onboarding: null,
|
||||
},
|
||||
];
|
||||
expect(buildBundledIndex(plugins, [], "minimal").catalog).toEqual({
|
||||
a: { tier: "standard", version: "1.2.0" },
|
||||
b: { tier: "minimal", version: "2.0.0-beta.1" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -520,7 +547,7 @@ describe("default look", () => {
|
||||
],
|
||||
["look", "plain"],
|
||||
),
|
||||
).toEqual({ version: 1, plugins: { look: { defaultLook: true } } });
|
||||
).toMatchObject({ version: 1, plugins: { look: { defaultLook: true } } });
|
||||
|
||||
const index = {
|
||||
plugins: [
|
||||
@@ -537,6 +564,38 @@ describe("default look", () => {
|
||||
).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", () => {
|
||||
const repo = tempDir();
|
||||
fs.mkdirSync(path.join(repo, "dist"), { recursive: true });
|
||||
|
||||
@@ -19,6 +19,49 @@ const SDK_UI = path.join(ROOT, "src", "ui", "plugin-host", "sdk-ui.ts");
|
||||
const TSCONFIG = path.join(ROOT, "tsconfig.app.json");
|
||||
|
||||
const ALLOWED = [
|
||||
"SeamlessTileContext",
|
||||
"TileEditBar",
|
||||
"TileStack",
|
||||
"TileColumn",
|
||||
"TileWall",
|
||||
"useInSeamlessTile",
|
||||
"NoticePanel",
|
||||
"LoadingState",
|
||||
"StatusBadge",
|
||||
"StatusDot",
|
||||
"statusTone",
|
||||
"StatusTone",
|
||||
"StatTile",
|
||||
"InfoGrid",
|
||||
"InfoItem",
|
||||
"Pager",
|
||||
"pageOf",
|
||||
"usePaged",
|
||||
"DataTable",
|
||||
"DataTableRow",
|
||||
"SideNav",
|
||||
"SideNavItem",
|
||||
"SideNavEntry",
|
||||
"PanelFrame",
|
||||
"RefreshButton",
|
||||
"TileFrame",
|
||||
"TileGrid",
|
||||
"TilePicker",
|
||||
"appendTile",
|
||||
"flowTiles",
|
||||
"removeTile",
|
||||
"sanitizeTiles",
|
||||
"TILE_COLUMNS",
|
||||
"TileGridProps",
|
||||
"TileKind",
|
||||
"TilePickerEntry",
|
||||
"TileSlot",
|
||||
"useHostSections",
|
||||
"operationsApi",
|
||||
"listHostSections",
|
||||
"hostSectionsFor",
|
||||
"HostTabEmbed",
|
||||
"HostSectionDef",
|
||||
"ACCENT_PRESET_COLORS",
|
||||
"APP_VERSION",
|
||||
"AccessRecord",
|
||||
|
||||
@@ -45,6 +45,18 @@ function buildTable(version, mobileVersion, mobileTag) {
|
||||
].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 }) {
|
||||
const notes = changelogSection(changelog, version);
|
||||
if (!notes) fail(`CHANGELOG.md has no notes for ${version}`);
|
||||
@@ -62,6 +74,8 @@ function buildBody({ version, mobileVersion, mobileTag, changelog }) {
|
||||
"### Downloads",
|
||||
"",
|
||||
buildTable(version, mobileVersion, mobileTag),
|
||||
"",
|
||||
buildFullBuilds(version),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
const fs = require("fs");
|
||||
const body = buildBody({
|
||||
|
||||
@@ -236,12 +236,22 @@ async function extractArtifact(buffer, outDir, id) {
|
||||
|
||||
/**
|
||||
* What dist/plugins/bundled-index.json holds: the onboarding defaults of each
|
||||
* staged plugin, read by the backend when it seeds a fresh install.
|
||||
* staged plugin, read by the backend when it seeds a fresh install, plus the
|
||||
* profile and every pin it ships, so an existing install can fetch a shipped
|
||||
* plugin it finds missing.
|
||||
*/
|
||||
function buildBundledIndex(plugins, stagedIds) {
|
||||
function buildBundledIndex(plugins, stagedIds, profile = DEFAULT_PROFILE) {
|
||||
const staged = new Set(stagedIds);
|
||||
const out = {};
|
||||
const catalog = {};
|
||||
for (const plugin of plugins) {
|
||||
const version = /-(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\.tmxplug$/.exec(
|
||||
plugin.url ?? plugin.path ?? "",
|
||||
)?.[1];
|
||||
catalog[plugin.id] = {
|
||||
tier: plugin.tier ?? "full",
|
||||
...(version ? { version } : {}),
|
||||
};
|
||||
if (!staged.has(plugin.id)) continue;
|
||||
if (!plugin.onboarding && !plugin.defaultLook) continue;
|
||||
out[plugin.id] = {
|
||||
@@ -249,7 +259,7 @@ function buildBundledIndex(plugins, stagedIds) {
|
||||
...(plugin.defaultLook ? { defaultLook: true } : {}),
|
||||
};
|
||||
}
|
||||
return { version: 1, plugins: out };
|
||||
return { version: 1, profile, plugins: out, catalog };
|
||||
}
|
||||
|
||||
/** The profile to build: --profile, then TERMIX_PLUGIN_PROFILE, then standard. */
|
||||
@@ -278,14 +288,15 @@ function pluginsForProfile(plugins, profile) {
|
||||
* 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,
|
||||
* 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) {
|
||||
const plugins = Array.isArray(index?.plugins) ? index.plugins : [];
|
||||
const byId = new Map(plugins.map((plugin) => [plugin.id, plugin]));
|
||||
const currentById = new Map(current.map((entry) => [entry.id, entry]));
|
||||
const wanted = ids ?? plugins.map((plugin) => plugin.id);
|
||||
return [...wanted].sort().map((id) => {
|
||||
const pins = [...wanted].sort().map((id) => {
|
||||
const listed = byId.get(id);
|
||||
// gated holds releases that need a newer Termix than old builds check for.
|
||||
const latest = [...(listed?.versions ?? []), ...(listed?.gated ?? [])]
|
||||
@@ -300,7 +311,10 @@ function pinsFromIndex(index, ids, current = [], coreVersion = null) {
|
||||
}),
|
||||
)
|
||||
.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 defaultLook = currentById.get(id)?.defaultLook === true;
|
||||
const tier = currentById.get(id)?.tier ?? "full";
|
||||
@@ -314,6 +328,7 @@ function pinsFromIndex(index, ids, current = [], coreVersion = null) {
|
||||
...(defaultLook ? { defaultLook } : {}),
|
||||
};
|
||||
});
|
||||
return pins.filter(Boolean);
|
||||
}
|
||||
|
||||
const LOCAL_FILES = [
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* re-released.
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* The release workflow runs it with --all and the version being released.
|
||||
|
||||
@@ -30,6 +30,7 @@ import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
|
||||
import { syncForwarder } from "../sync/provider.js";
|
||||
import { syncChangeWatcher } from "../sync/server/change-watcher.js";
|
||||
import dashboardRoutes from "./routes/dashboard-routes.js";
|
||||
import operationsRoutes from "./routes/operations-routes.js";
|
||||
import {
|
||||
mountPluginApi,
|
||||
mountPluginLegacyPaths,
|
||||
@@ -1627,6 +1628,7 @@ app.use("/ui-preferences", uiPreferencesRoutes);
|
||||
registerAuditLogRoutes(app, authenticateJWT);
|
||||
app.use("/sync", syncForwarder);
|
||||
app.use("/dashboard", dashboardRoutes);
|
||||
app.use("/operations", operationsRoutes);
|
||||
app.use("/plugins", pluginRoutes);
|
||||
app.use(
|
||||
"/plugin-assets",
|
||||
|
||||
@@ -599,6 +599,70 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
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 (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
@@ -961,6 +1025,7 @@ const enforceUniqueUsernames = () => {
|
||||
|
||||
const migrateSchema = () => {
|
||||
repairSnippetsColumns(sqlite);
|
||||
addColumnIfNotExists("api_keys", "scopes", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "theme", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "font_size", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "accent_color", "TEXT");
|
||||
@@ -1812,6 +1877,8 @@ async function saveMemoryDatabaseToFile(): Promise<void> {
|
||||
if (!memoryDatabase) return;
|
||||
|
||||
try {
|
||||
// Before the copy, so a write during the slow part below stays dirty.
|
||||
DatabaseSaveTrigger.markClean();
|
||||
const buffer = memoryDatabase.serialize();
|
||||
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
@@ -1832,11 +1899,10 @@ async function saveMemoryDatabaseToFile(): Promise<void> {
|
||||
encryptedDbPath,
|
||||
);
|
||||
} else {
|
||||
fs.writeFileSync(dbPath, buffer);
|
||||
await fs.promises.writeFile(dbPath, buffer);
|
||||
}
|
||||
|
||||
DatabaseSaveTrigger.markClean();
|
||||
} catch (error) {
|
||||
DatabaseSaveTrigger.markDirty();
|
||||
databaseLogger.error("Failed to save in-memory database", error, {
|
||||
operation: "memory_db_save_failed",
|
||||
enableFileEncryption,
|
||||
@@ -1855,7 +1921,8 @@ async function handlePostInitFileEncryption() {
|
||||
|
||||
setInterval(() => {
|
||||
if (DatabaseSaveTrigger.isDirty) {
|
||||
saveMemoryDatabaseToFile();
|
||||
// Through the trigger so it waits for any save already running.
|
||||
void DatabaseSaveTrigger.forceSave("periodic_save").catch(() => {});
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
}
|
||||
|
||||
@@ -673,10 +673,102 @@ export const apiKeys = mysqlTable(
|
||||
expiresAt: text("expires_at"),
|
||||
lastUsedAt: text("last_used_at"),
|
||||
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)],
|
||||
);
|
||||
|
||||
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(
|
||||
"user_open_tabs",
|
||||
{
|
||||
|
||||
@@ -673,10 +673,102 @@ export const apiKeys = pgTable(
|
||||
expiresAt: text("expires_at"),
|
||||
lastUsedAt: text("last_used_at"),
|
||||
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)],
|
||||
);
|
||||
|
||||
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(
|
||||
"user_open_tabs",
|
||||
{
|
||||
|
||||
@@ -663,10 +663,102 @@ export const apiKeys = sqliteTable(
|
||||
expiresAt: text("expires_at"),
|
||||
lastUsedAt: text("last_used_at"),
|
||||
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)],
|
||||
);
|
||||
|
||||
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(
|
||||
"user_open_tabs",
|
||||
{
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface ApiKeyListRecord {
|
||||
expiresAt: string | null;
|
||||
lastUsedAt: string | null;
|
||||
isActive: boolean;
|
||||
/** JSON list, or null for full access. */
|
||||
scopes: string | null;
|
||||
}
|
||||
|
||||
export class ApiKeyRepository {
|
||||
@@ -43,6 +45,7 @@ export class ApiKeyRepository {
|
||||
expiresAt: apiKeys.expiresAt,
|
||||
lastUsedAt: apiKeys.lastUsedAt,
|
||||
isActive: apiKeys.isActive,
|
||||
scopes: apiKeys.scopes,
|
||||
})
|
||||
.from(apiKeys)
|
||||
.leftJoin(users, eq(apiKeys.userId, users.id))
|
||||
|
||||
@@ -13,6 +13,7 @@ import { HostSidebarPreferenceRepository } from "./host-sidebar-preference-repos
|
||||
import { CredentialSidebarPreferenceRepository } from "./credential-sidebar-preference-repository.js";
|
||||
import { UiPreferenceRepository } from "./ui-preference-repository.js";
|
||||
import { OpenTabRepository } from "./open-tab-repository.js";
|
||||
import { OperationRepository } from "./operation-repository.js";
|
||||
import { PluginRepository } from "./plugin-repository.js";
|
||||
import { PluginStorageRepository } from "./plugin-storage-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 {
|
||||
return new CredentialRepository(
|
||||
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 {
|
||||
createCurrentApiKeyRepository,
|
||||
createCurrentAuditLogRepository,
|
||||
createCurrentOperationRepository,
|
||||
createCurrentCredentialRepository,
|
||||
createCurrentHostFolderRepository,
|
||||
createCurrentHostRepository,
|
||||
@@ -61,6 +62,7 @@ export async function deleteUserAndRelatedData(
|
||||
|
||||
await createCurrentRoleRepository().removeAllRolesFromUser(userId);
|
||||
await createCurrentAuditLogRepository().anonymizeByUserId(userId);
|
||||
await createCurrentOperationRepository().anonymizeUser(userId);
|
||||
|
||||
await createCurrentSshCredentialUsageRepository().deleteByUserId(userId);
|
||||
|
||||
|
||||
@@ -0,0 +1,761 @@
|
||||
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,
|
||||
freshMs: signal.refreshSec ? signal.refreshSec * 1000 : undefined,
|
||||
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;
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { RequestHandler, Router } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { API_KEY_SCOPES } from "../../operations/key-scopes.js";
|
||||
import crypto from "crypto";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { nanoid } from "nanoid";
|
||||
@@ -42,6 +43,12 @@ export function registerUserApiKeyRoutes(
|
||||
* type: string
|
||||
* format: date-time
|
||||
* description: Optional expiration date. Null means the key never expires.
|
||||
* scopes:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* enum: [operations:read, operations:run]
|
||||
* description: Limits the key to actions only. Leave out for full access.
|
||||
* responses:
|
||||
* 201:
|
||||
* description: API key created. Contains the full token (shown only once).
|
||||
@@ -56,7 +63,7 @@ export function registerUserApiKeyRoutes(
|
||||
*/
|
||||
router.post("/api-keys", requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { name, userId: targetUserId, expiresAt } = req.body;
|
||||
const { name, userId: targetUserId, expiresAt, scopes } = req.body;
|
||||
const apiKeyRepository = createCurrentApiKeyRepository();
|
||||
const userRepository = createCurrentUserRepository();
|
||||
|
||||
@@ -86,6 +93,21 @@ export function registerUserApiKeyRoutes(
|
||||
expiresAtValue = parsed.toISOString();
|
||||
}
|
||||
|
||||
let scopesValue: string[] | null = null;
|
||||
if (scopes !== undefined && scopes !== null) {
|
||||
if (
|
||||
!Array.isArray(scopes) ||
|
||||
scopes.some(
|
||||
(scope) =>
|
||||
!(API_KEY_SCOPES as readonly string[]).includes(String(scope)),
|
||||
)
|
||||
) {
|
||||
return res.status(400).json({ error: "Unknown API key scope" });
|
||||
}
|
||||
scopesValue =
|
||||
scopes.length > 0 ? [...new Set(scopes as string[])] : null;
|
||||
}
|
||||
|
||||
const rawToken = "tmx_" + crypto.randomBytes(32).toString("hex");
|
||||
const tokenPrefix = rawToken.substring(0, 12);
|
||||
const tokenHash = await bcrypt.hash(rawToken, 10);
|
||||
@@ -102,6 +124,7 @@ export function registerUserApiKeyRoutes(
|
||||
expiresAt: expiresAtValue,
|
||||
lastUsedAt: null,
|
||||
isActive: true,
|
||||
scopes: scopesValue ? JSON.stringify(scopesValue) : null,
|
||||
});
|
||||
|
||||
const actorId = (req as AuthenticatedRequest).userId;
|
||||
@@ -119,6 +142,7 @@ export function registerUserApiKeyRoutes(
|
||||
details: JSON.stringify({
|
||||
targetUserId,
|
||||
targetUsername: targetUser.username,
|
||||
scopes: scopesValue,
|
||||
}),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
@@ -133,6 +157,7 @@ export function registerUserApiKeyRoutes(
|
||||
tokenPrefix,
|
||||
createdAt: now,
|
||||
expiresAt: expiresAtValue,
|
||||
scopes: scopesValue,
|
||||
token: rawToken,
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -99,7 +99,12 @@ async function prepareKey(
|
||||
|
||||
if (passphrase) config.passphrase = passphrase;
|
||||
// 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;
|
||||
if (certPublicKey && certPublicKey.trim()) {
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* 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;
|
||||
// approved: a person approved this exact request in the plugin's own UI.
|
||||
const TRUSTED_VIA = new Set(["automation", "rollout", "fleet", "approved"]);
|
||||
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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* 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;
|
||||
/** How long a cached answer stays fresh. Defaults to RESOURCE_FRESH_MS. */
|
||||
freshMs?: number;
|
||||
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 < (options.freshMs ?? 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();
|
||||
}
|
||||
@@ -20,7 +20,28 @@
|
||||
|
||||
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;
|
||||
/** Where the identity came from, for the audit line. */
|
||||
source: "request" | "asUser" | "service";
|
||||
@@ -35,8 +56,34 @@ export function runAsActor<T>(
|
||||
source: ActorStore["source"],
|
||||
fn: () => T,
|
||||
sessionId?: string,
|
||||
trail?: ActorTrail,
|
||||
): 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 {
|
||||
|
||||
@@ -35,3 +35,20 @@ export function missingShippedPluginIds(
|
||||
.map((record) => record.id)
|
||||
.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugins this image's profile ships that the install has never seen and
|
||||
* that are not on disk. They get fetched from the registry once, the first
|
||||
* boot that can reach it.
|
||||
*/
|
||||
export function newShippedPluginIds(
|
||||
shipped: Iterable<string>,
|
||||
records: { id: string }[],
|
||||
presentIds: ReadonlySet<string>,
|
||||
ignored: ReadonlySet<string>,
|
||||
): string[] {
|
||||
const known = new Set(records.map((record) => record.id));
|
||||
return [...shipped]
|
||||
.filter((id) => !known.has(id) && !presentIds.has(id) && !ignored.has(id))
|
||||
.sort();
|
||||
}
|
||||
@@ -18,7 +18,14 @@ export interface OnboardingDefaults {
|
||||
|
||||
type Recommended = boolean | "desktop";
|
||||
|
||||
export interface BundledCatalogEntry {
|
||||
tier: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
interface BundledIndex {
|
||||
profile?: string;
|
||||
catalog?: Record<string, BundledCatalogEntry>;
|
||||
plugins: Record<
|
||||
string,
|
||||
{
|
||||
@@ -45,7 +52,14 @@ export function readBundledIndex(): BundledIndex | null {
|
||||
);
|
||||
cached =
|
||||
raw && typeof raw.plugins === "object" && raw.plugins
|
||||
? { plugins: raw.plugins }
|
||||
? {
|
||||
plugins: raw.plugins,
|
||||
profile: typeof raw.profile === "string" ? raw.profile : undefined,
|
||||
catalog:
|
||||
raw.catalog && typeof raw.catalog === "object"
|
||||
? raw.catalog
|
||||
: undefined,
|
||||
}
|
||||
: null;
|
||||
} catch {
|
||||
cached = null;
|
||||
@@ -101,21 +115,45 @@ export function onboardingDefaults(pluginId: string): OnboardingDefaults {
|
||||
};
|
||||
}
|
||||
|
||||
const PROFILES = ["minimal", "standard", "full"];
|
||||
|
||||
/**
|
||||
* The state a newly seen plugin's row starts in. Bundled plugins start
|
||||
* enabled, except on a fresh install, where only recommended ones do and
|
||||
* consent plugins wait for the admin. A plugin dropped into the data
|
||||
* directory always starts disabled.
|
||||
* Plugins the image's profile ships (its tier and every smaller one), with
|
||||
* the version it pins. Read from the bundled index, so core never names them.
|
||||
*/
|
||||
export function shippedCatalog(): Map<string, BundledCatalogEntry> {
|
||||
const index = readBundledIndex();
|
||||
const out = new Map<string, BundledCatalogEntry>();
|
||||
if (!index?.catalog) return out;
|
||||
const rank = PROFILES.indexOf(index.profile ?? "standard");
|
||||
for (const [id, entry] of Object.entries(index.catalog)) {
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
const tier = PROFILES.indexOf(entry.tier);
|
||||
if (tier === -1 || rank === -1 || tier > rank) continue;
|
||||
out.set(id, entry);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Whether a shipped plugin should be on when an install first sees it. */
|
||||
export function startsEnabled(pluginId: string): boolean {
|
||||
// The default look is what the app is drawn with.
|
||||
if (isDefaultLookPlugin(pluginId)) return true;
|
||||
const { recommended, consent } = onboardingDefaults(pluginId);
|
||||
return recommended && !consent;
|
||||
}
|
||||
|
||||
/**
|
||||
* The state a newly seen plugin's row starts in. A bundled plugin starts on
|
||||
* only when it is recommended, so a plugin a later release adds to the image
|
||||
* does not switch itself on everywhere. Consent plugins wait for the admin.
|
||||
* A plugin dropped into the data directory always starts disabled.
|
||||
*/
|
||||
export function initialPluginState(
|
||||
pluginId: string,
|
||||
source: "bundled" | "user",
|
||||
freshInstall: boolean,
|
||||
_freshInstall: boolean,
|
||||
): "enabled" | "disabled" {
|
||||
if (source !== "bundled") return "disabled";
|
||||
if (!freshInstall) return "enabled";
|
||||
// The default look is what the app is drawn with.
|
||||
if (isDefaultLookPlugin(pluginId)) return "enabled";
|
||||
const { recommended, consent } = onboardingDefaults(pluginId);
|
||||
return recommended && !consent ? "enabled" : "disabled";
|
||||
return startsEnabled(pluginId) ? "enabled" : "disabled";
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -68,6 +68,7 @@ import { createPluginAuth, createPluginSsh } from "./ctx-ssh-auth.js";
|
||||
import { createPluginHosts } from "./ctx-hosts.js";
|
||||
import { createPluginSchedule } from "./schedule.js";
|
||||
import { createPluginFetch, createPluginNotify } from "./ctx-notify.js";
|
||||
import { createPluginOperations } from "./ctx-operations.js";
|
||||
import { createPluginProcess } from "./ctx-process.js";
|
||||
import { createPluginSystem } from "./ctx-system.js";
|
||||
import { createPluginPlugins } from "./ctx-plugins.js";
|
||||
@@ -976,6 +977,8 @@ export function createPluginContext(
|
||||
void reportRuntimeError(pluginId, error);
|
||||
}),
|
||||
ssh: createPluginSsh({ manifest, bag: handle.bag, audit: auditCall }),
|
||||
operations: createPluginOperations({ manifest, bag: handle.bag }),
|
||||
|
||||
notify: createPluginNotify({
|
||||
manifest,
|
||||
audit: auditCall,
|
||||
|
||||
@@ -560,7 +560,13 @@ export async function openDownload(
|
||||
}
|
||||
},
|
||||
abort: async () => {
|
||||
stream.destroy();
|
||||
// The file opens in the background, so wait for the stream to close
|
||||
// or a late open would leave the partial file behind.
|
||||
await new Promise<void>((resolve) => {
|
||||
if (stream.closed) return resolve();
|
||||
stream.once("close", () => resolve());
|
||||
stream.destroy();
|
||||
});
|
||||
await fsp.rm(partialPath, { force: true }).catch(() => {});
|
||||
release();
|
||||
},
|
||||
|
||||
@@ -354,15 +354,21 @@ export function createPluginRouter({
|
||||
// After auth, so the actor is the user core authenticated rather than
|
||||
// whatever the request claimed.
|
||||
outer.use((req: Request, _res: Response, next: NextFunction) => {
|
||||
const { userId, sessionId } = req as Request & {
|
||||
const { userId, sessionId, apiKeyId, apiKeyScopes } = req as Request & {
|
||||
userId?: string;
|
||||
sessionId?: string;
|
||||
apiKeyId?: string;
|
||||
apiKeyScopes?: string[] | null;
|
||||
};
|
||||
if (!userId) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
runAsActor(userId, "request", () => next(), sessionId);
|
||||
runAsActor(userId, "request", () => next(), sessionId, {
|
||||
origin: { kind: apiKeyId ? "api" : "ui" },
|
||||
apiKeyId,
|
||||
apiKeyScopes,
|
||||
});
|
||||
});
|
||||
|
||||
outer.use(inner);
|
||||
|
||||
@@ -243,8 +243,10 @@ export async function initializePlugins(
|
||||
|
||||
const fresh = await seedPlugins(loaded, options.freshInstall === true);
|
||||
if (!fresh) {
|
||||
const { restoreMissingPlugins } = await import("./manage.js");
|
||||
const { installNewShippedPlugins, restoreMissingPlugins } =
|
||||
await import("./manage.js");
|
||||
loaded.push(...(await restoreMissingPlugins()));
|
||||
loaded.push(...(await installNewShippedPlugins()));
|
||||
}
|
||||
await syncCapabilityGrants(loaded);
|
||||
if (loaded.some((plugin) => plugin.source === "bundled")) {
|
||||
|
||||
@@ -409,6 +409,15 @@ export class PluginLoader {
|
||||
}
|
||||
loaded.push(plugin);
|
||||
} catch (error) {
|
||||
// A newer image ships a newer copy, so the old update can go.
|
||||
if (isArtifact && error instanceof SupersededUpdateError) {
|
||||
await fs.promises.rm(dir, { force: true }).catch(() => {});
|
||||
await fs.promises.rm(`${dir}.sig`, { force: true }).catch(() => {});
|
||||
pluginLogger.info(`Removed ${entry.name}: ${error.message}`, {
|
||||
operation: "plugin_load",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
this.skippedPlugins.push(
|
||||
describeSkipped(dir, entry.name, isArtifact, error),
|
||||
);
|
||||
@@ -948,6 +957,16 @@ function withTimeout<T>(
|
||||
|
||||
const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
||||
|
||||
/** An installed update that the bundled copy has caught up with. */
|
||||
export class SupersededUpdateError extends Error {
|
||||
constructor(id: string, version: string, bundledVersion: string) {
|
||||
super(
|
||||
`update for "${id}" is ${version}, which is not newer than the bundled ${bundledVersion}`,
|
||||
);
|
||||
this.name = "SupersededUpdateError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws unless an artifact may replace the bundled plugin with the same id:
|
||||
* a trusted key signed it and its version is strictly higher, so an old
|
||||
@@ -971,9 +990,7 @@ export function assertBundledUpdate(
|
||||
// An admin pinning an exact older release is the one allowed downgrade.
|
||||
if (pinnedVersion && pinnedVersion === version) return;
|
||||
if (bundledVersion && !semver.gt(version, bundledVersion)) {
|
||||
throw new Error(
|
||||
`update for "${id}" is ${version}, which is not newer than the bundled ${bundledVersion}`,
|
||||
);
|
||||
throw new SupersededUpdateError(id, version, bundledVersion);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,11 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import semver from "semver";
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import { isDefaultLookPlugin } from "./bundled-index.js";
|
||||
import {
|
||||
isDefaultLookPlugin,
|
||||
shippedCatalog,
|
||||
startsEnabled,
|
||||
} from "./bundled-index.js";
|
||||
import { mayRunInSafeMode } from "./safe-mode.js";
|
||||
import {
|
||||
activatePlugin,
|
||||
@@ -26,7 +30,7 @@ import {
|
||||
registerLoadedPlugins,
|
||||
unloadPlugin,
|
||||
} from "./index.js";
|
||||
import { missingShippedPluginIds } from "./boot-state.js";
|
||||
import { missingShippedPluginIds, newShippedPluginIds } from "./boot-state.js";
|
||||
import type { LoadedPlugin, PluginLoader } from "./loader.js";
|
||||
import {
|
||||
getBundledPluginsDir,
|
||||
@@ -1099,6 +1103,67 @@ export function restoreMissingPlugins(): Promise<LoadedPlugin[]> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Called at boot on an existing install. A plugin this image's profile ships
|
||||
* that the install never had and that is not on disk is fetched from the
|
||||
* official registry and starts on only if it is recommended. Offline, nothing
|
||||
* is recorded, so the next start tries again.
|
||||
*/
|
||||
export function installNewShippedPlugins(): Promise<LoadedPlugin[]> {
|
||||
return serialized(async () => {
|
||||
if (await isLinkedDesktop()) return [];
|
||||
const catalog = shippedCatalog();
|
||||
if (catalog.size === 0) return [];
|
||||
const { createCurrentPluginRepository } = await repos();
|
||||
const repository = createCurrentPluginRepository();
|
||||
const { loader } = getPluginRuntime();
|
||||
const ids = newShippedPluginIds(
|
||||
catalog.keys(),
|
||||
await repository.listAll(),
|
||||
new Set(loader.list().map((plugin) => plugin.id)),
|
||||
await readUninstalled(),
|
||||
);
|
||||
const added: LoadedPlugin[] = [];
|
||||
for (const id of ids) {
|
||||
try {
|
||||
const { source, release } = await requireRelease(
|
||||
id,
|
||||
catalog.get(id)?.version,
|
||||
"stable",
|
||||
OFFICIAL_REGISTRY_ID,
|
||||
);
|
||||
const plugin = await swapIn(id, { release, source });
|
||||
const enabled = startsEnabled(id);
|
||||
await repository.update(id, {
|
||||
registryId: source.id,
|
||||
tier: "bundled",
|
||||
state: enabled ? "enabled" : "disabled",
|
||||
lastError: null,
|
||||
});
|
||||
await grantDeclared(plugin, null);
|
||||
added.push(plugin);
|
||||
pluginLogger.info(
|
||||
`Added ${id}@${release.version} from the registry (${enabled ? "on" : "off"})`,
|
||||
{ operation: "plugin_install_shipped" },
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
pluginLogger.warn(`Could not add ${id}: ${message}`, {
|
||||
operation: "plugin_install_shipped",
|
||||
});
|
||||
// A half made row would hide the plugin from the next try.
|
||||
if (!loader.get(id)) await repository.delete(id).catch(() => false);
|
||||
if (
|
||||
error instanceof PluginManageError &&
|
||||
error.code === "REGISTRY_UNREACHABLE"
|
||||
)
|
||||
break;
|
||||
}
|
||||
}
|
||||
return added;
|
||||
});
|
||||
}
|
||||
|
||||
export function updatePlugin(
|
||||
pluginId: string,
|
||||
options: InstallOptions,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import { getPluginRuntime } from "./index.js";
|
||||
import { onboardingDefaults } from "./bundled-index.js";
|
||||
import { isDefaultLookPlugin, onboardingDefaults } from "./bundled-index.js";
|
||||
import {
|
||||
isLinkedDesktop,
|
||||
serialized,
|
||||
@@ -99,6 +99,11 @@ export async function offerPluginSetup(fresh: boolean): Promise<void> {
|
||||
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[] {
|
||||
return getPluginRuntime()
|
||||
.loader.list()
|
||||
@@ -118,6 +123,7 @@ export async function describeOnboardingPlugins(): Promise<OnboardingPluginList>
|
||||
);
|
||||
const plugins = getPluginRuntime()
|
||||
.loader.list()
|
||||
.filter(inPicker)
|
||||
.map((plugin): OnboardingPluginInfo => {
|
||||
const defaults =
|
||||
plugin.source === "bundled"
|
||||
@@ -158,7 +164,12 @@ export function applyOnboardingChoices(
|
||||
): Promise<OnboardingApplyResult> {
|
||||
return serialized(async () => {
|
||||
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> = {};
|
||||
for (const [id, choice] of Object.entries(choices)) {
|
||||
if (installed.has(id)) wanted[id] = choice;
|
||||
|
||||
@@ -243,6 +243,9 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
||||
// 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.
|
||||
try {
|
||||
const { startOperationsMaintenance } =
|
||||
await import("./operations/maintenance.js");
|
||||
await startOperationsMaintenance();
|
||||
const { initializePlugins } = await import("./plugins/index.js");
|
||||
const loaded = await initializePlugins({ freshInstall });
|
||||
if (loaded.length > 0) {
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
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" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /operations/signals", () => {
|
||||
it("keeps a signal's answer for its refreshSec", async () => {
|
||||
const { buildCatalog } = await import("../../../operations/catalog.js");
|
||||
const { readAcrossHosts } =
|
||||
await import("../../../operations/resources.js");
|
||||
vi.mocked(buildCatalog).mockResolvedValueOnce({
|
||||
actions: [],
|
||||
events: [],
|
||||
resources: [],
|
||||
signals: [
|
||||
{ id: "a", read: "demo.a", scope: "host", refreshSec: 600 },
|
||||
{ id: "b", read: "demo.b", scope: "host" },
|
||||
],
|
||||
} as never);
|
||||
await request(app).get("/operations/signals").expect(200);
|
||||
expect(vi.mocked(readAcrossHosts)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ actionId: "demo.a", freshMs: 600_000 }),
|
||||
);
|
||||
expect(vi.mocked(readAcrossHosts)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ actionId: "demo.b", freshMs: undefined }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -148,8 +148,23 @@ async function build(
|
||||
});
|
||||
}
|
||||
|
||||
// Building a hop's config parses keys, which is slow while the whole suite
|
||||
// runs, so give the multi hop steps more than vi.waitFor's 1 s.
|
||||
const settle = (check: () => void) => vi.waitFor(check, { timeout: 10_000 });
|
||||
vi.setConfig({ testTimeout: 30_000 });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// clearAllMocks keeps queued Once values, so a test that stopped early
|
||||
// would hand its leftovers to the next one.
|
||||
for (const mock of [
|
||||
mocks.resolveHostById,
|
||||
mocks.createJumpHostChain,
|
||||
mocks.createSocks5Connection,
|
||||
mocks.applyCertificateAuth,
|
||||
mocks.applyAgentAuth,
|
||||
])
|
||||
mock.mockReset();
|
||||
FakeSshClient.nextBehaviour = [];
|
||||
FakeSshClient.instances = [];
|
||||
mocks.createHostVerifier.mockResolvedValue(mocks.verifier);
|
||||
@@ -205,6 +220,11 @@ describe("buildConnectConfig per auth type", () => {
|
||||
expect(Buffer.isBuffer(config.privateKey)).toBe(true);
|
||||
expect(config.passphrase).toBe("secret");
|
||||
expect(config.password).toBe("fallback");
|
||||
expect(config.authHandler).toEqual([
|
||||
"publickey",
|
||||
"password",
|
||||
"keyboard-interactive",
|
||||
]);
|
||||
expect(mocks.applyCertificateAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -527,7 +547,7 @@ describe("connectHost", () => {
|
||||
"user-1",
|
||||
prompt,
|
||||
);
|
||||
await vi.waitFor(() =>
|
||||
await settle(() =>
|
||||
expect(FakeSshClient.instances[0]?.connectConfig).toBeTruthy(),
|
||||
);
|
||||
const first = FakeSshClient.instances[0];
|
||||
@@ -541,11 +561,9 @@ describe("connectHost", () => {
|
||||
[{ prompt: "Verification code:", echo: false }],
|
||||
firstAnswer,
|
||||
);
|
||||
await vi.waitFor(() =>
|
||||
expect(firstAnswer).toHaveBeenCalledWith(["111111"]),
|
||||
);
|
||||
await settle(() => expect(firstAnswer).toHaveBeenCalledWith(["111111"]));
|
||||
first.emit("ready");
|
||||
await vi.waitFor(() =>
|
||||
await settle(() =>
|
||||
expect(FakeSshClient.instances[1]?.connectConfig).toBeTruthy(),
|
||||
);
|
||||
const second = FakeSshClient.instances[1];
|
||||
@@ -561,7 +579,7 @@ describe("connectHost", () => {
|
||||
],
|
||||
secondAnswer,
|
||||
);
|
||||
await vi.waitFor(() =>
|
||||
await settle(() =>
|
||||
expect(secondAnswer).toHaveBeenCalledWith(["hop-password", "222222"]),
|
||||
);
|
||||
second.emit("ready");
|
||||
@@ -669,7 +687,7 @@ describe("connectHost", () => {
|
||||
it("auto-fills the stored password in keyboard-interactive with nobody to ask", async () => {
|
||||
FakeSshClient.nextBehaviour = ["hang"];
|
||||
const attempt = connectHost(host(), { userId: "user-1", purpose: "fleet" });
|
||||
await vi.waitFor(() => {
|
||||
await settle(() => {
|
||||
const last = FakeSshClient.instances.at(-1)!;
|
||||
expect(last.connectConfig).not.toBeNull();
|
||||
});
|
||||
@@ -699,7 +717,7 @@ describe("connectHost", () => {
|
||||
purpose: "file-manager",
|
||||
prompt: { ask },
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
await settle(() =>
|
||||
expect(FakeSshClient.instances.at(-1)!.connectConfig).not.toBeNull(),
|
||||
);
|
||||
const client = FakeSshClient.instances.at(-1)!;
|
||||
@@ -715,7 +733,7 @@ describe("connectHost", () => {
|
||||
],
|
||||
finish,
|
||||
);
|
||||
await vi.waitFor(() => expect(finish).toHaveBeenCalled());
|
||||
await settle(() => expect(finish).toHaveBeenCalled());
|
||||
expect(ask).toHaveBeenCalledWith({
|
||||
kind: "totp",
|
||||
prompt: "Verification code: ",
|
||||
@@ -746,7 +764,7 @@ describe("connectHost", () => {
|
||||
purpose: "fleet",
|
||||
}),
|
||||
).rejects.toThrow(/All configured authentication methods failed/);
|
||||
await vi.waitFor(() =>
|
||||
await settle(() =>
|
||||
expect(mocks.forgetCert).toHaveBeenCalledWith("user-1", 7),
|
||||
);
|
||||
dispose();
|
||||
|
||||
@@ -0,0 +1,703 @@
|
||||
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("lets a request a person approved in a plugin go by RBAC", async () => {
|
||||
register();
|
||||
const outcome = await runOperation({
|
||||
...base,
|
||||
origin: { kind: "plugin", pluginId: "x", via: "approved" },
|
||||
});
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
missingShippedPluginIds,
|
||||
newShippedPluginIds,
|
||||
startablePluginIds,
|
||||
} from "../../plugins/boot-state.js";
|
||||
|
||||
@@ -38,3 +39,20 @@ describe("missingShippedPluginIds", () => {
|
||||
expect(missingShippedPluginIds(records, present, new Set())).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("newShippedPluginIds", () => {
|
||||
it("lists shipped plugins the install never had and that are not on disk", () => {
|
||||
expect(
|
||||
newShippedPluginIds(
|
||||
["new", "known", "here", "removed", "another"],
|
||||
[{ id: "known" }],
|
||||
new Set(["here"]),
|
||||
new Set(["removed"]),
|
||||
),
|
||||
).toEqual(["another", "new"]);
|
||||
});
|
||||
|
||||
it("finds nothing for an empty catalog", () => {
|
||||
expect(newShippedPluginIds([], [], new Set(), new Set())).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -8,14 +8,18 @@ import {
|
||||
isDefaultLookPlugin,
|
||||
onboardingDefaults,
|
||||
resetBundledIndexCache,
|
||||
shippedCatalog,
|
||||
} from "../../plugins/bundled-index.js";
|
||||
|
||||
let dir: string;
|
||||
|
||||
function writeIndex(plugins: Record<string, unknown>) {
|
||||
function writeIndex(
|
||||
plugins: Record<string, unknown>,
|
||||
extra: Record<string, unknown> = {},
|
||||
) {
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "bundled-index.json"),
|
||||
JSON.stringify({ version: 1, plugins }),
|
||||
JSON.stringify({ version: 1, plugins, ...extra }),
|
||||
);
|
||||
resetBundledIndexCache();
|
||||
}
|
||||
@@ -89,9 +93,10 @@ describe("initialPluginState", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("enables every bundled plugin on an existing install", () => {
|
||||
expect(initialPluginState("other", "bundled", false)).toBe("enabled");
|
||||
expect(initialPluginState("ask", "bundled", false)).toBe("enabled");
|
||||
it("enables only recommended plugins an existing install sees for the first time", () => {
|
||||
expect(initialPluginState("kept", "bundled", false)).toBe("enabled");
|
||||
expect(initialPluginState("other", "bundled", false)).toBe("disabled");
|
||||
expect(initialPluginState("ask", "bundled", false)).toBe("disabled");
|
||||
});
|
||||
|
||||
it("enables only recommended, non-consent plugins on a fresh install", () => {
|
||||
@@ -106,6 +111,40 @@ describe("initialPluginState", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("shippedCatalog", () => {
|
||||
it("is empty for an index without a catalog", () => {
|
||||
writeIndex({});
|
||||
expect(shippedCatalog().size).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps the pins of the image's tier and smaller ones", () => {
|
||||
writeIndex(
|
||||
{},
|
||||
{
|
||||
profile: "standard",
|
||||
catalog: {
|
||||
look: { tier: "minimal", version: "1.0.0" },
|
||||
ops: { tier: "standard", version: "1.0.0" },
|
||||
big: { tier: "full", version: "2.0.0" },
|
||||
odd: { tier: "huge" },
|
||||
},
|
||||
},
|
||||
);
|
||||
expect([...shippedCatalog().keys()].sort()).toEqual(["look", "ops"]);
|
||||
});
|
||||
|
||||
it("follows a minimal image", () => {
|
||||
writeIndex(
|
||||
{},
|
||||
{
|
||||
profile: "minimal",
|
||||
catalog: { look: { tier: "minimal" }, ops: { tier: "standard" } },
|
||||
},
|
||||
);
|
||||
expect([...shippedCatalog().keys()]).toEqual(["look"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("default look", () => {
|
||||
it("lists the plugins flagged as the default look", () => {
|
||||
writeIndex({
|
||||
|
||||
@@ -186,6 +186,21 @@ describe("ctx.desktop.local", () => {
|
||||
expect(fs.readdirSync(dir)).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves no partial file when aborted before the file has opened", async () => {
|
||||
grants.set("demo", ["desktop:local"]);
|
||||
const ctx = contextFor("demo", ["desktop:local"]);
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const download = await ctx.desktop.local.fs.openDownload({
|
||||
rootPath: dir,
|
||||
destPath: path.join(dir, `early-${i}.txt`),
|
||||
transferId: `early-${i}`,
|
||||
});
|
||||
await download.abort();
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(fs.readdirSync(dir)).toEqual([]);
|
||||
});
|
||||
|
||||
it("sends trash and open through Electron main and audits each call", async () => {
|
||||
grants.set("demo", ["desktop:local"]);
|
||||
const ctx = contextFor("demo", ["desktop:local"]);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
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", () => {
|
||||
@@ -263,6 +270,22 @@ describe("applyOnboardingChoices", () => {
|
||||
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 () => {
|
||||
await chain();
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -231,6 +231,16 @@ describe("updating a bundled plugin", () => {
|
||||
expect(loader.get("bundled-one")?.manifest.version).toBe("1.0.0");
|
||||
});
|
||||
|
||||
it("removes an update the bundled copy caught up with", async () => {
|
||||
const { userDir } = setup();
|
||||
const file = await writeArtifact(userDir, "bundled-one", "valid", "0.9.0");
|
||||
const loader = new PluginLoader();
|
||||
await loader.loadAll();
|
||||
expect(fs.existsSync(file)).toBe(false);
|
||||
expect(fs.existsSync(`${file}.sig`)).toBe(false);
|
||||
expect(loader.skipped()).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses a downgrade after the bundled copy was unloaded", async () => {
|
||||
const { userDir } = setup();
|
||||
const loader = new PluginLoader();
|
||||
|
||||
@@ -1,7 +1,33 @@
|
||||
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", () => {
|
||||
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(() => {
|
||||
vi.useRealTimers();
|
||||
DatabaseSaveTrigger.cleanup();
|
||||
|
||||
@@ -17,6 +17,7 @@ import { inArray, isNotNull } from "drizzle-orm";
|
||||
import type { ExternalSessionRef } from "../auth/types.js";
|
||||
import { listSecondFactors } from "../auth/registry.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 { getDb } from "../database/db/index.js";
|
||||
import { sessions } from "../database/db/schema.js";
|
||||
@@ -69,6 +70,7 @@ interface AuthenticatedRequest extends Request {
|
||||
userId?: string;
|
||||
sessionId?: string;
|
||||
apiKeyId?: string;
|
||||
apiKeyScopes?: string[] | null;
|
||||
pendingTOTP?: boolean;
|
||||
dataKey?: Buffer;
|
||||
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.apiKeyId = matchedKey.id;
|
||||
req.apiKeyScopes = scopes;
|
||||
next();
|
||||
} catch (error) {
|
||||
databaseLogger.error("API key authentication failed", error, {
|
||||
|
||||
@@ -16,6 +16,8 @@ interface EncryptedFileMetadata {
|
||||
dataSize?: number;
|
||||
}
|
||||
|
||||
const ENCRYPT_CHUNK_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
class DatabaseFileEncryption {
|
||||
private static readonly VERSION = "v2";
|
||||
private static readonly ALGORITHM = "aes-256-gcm";
|
||||
@@ -27,7 +29,7 @@ class DatabaseFileEncryption {
|
||||
buffer: Buffer,
|
||||
targetPath: 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}`;
|
||||
|
||||
try {
|
||||
@@ -38,7 +40,21 @@ class DatabaseFileEncryption {
|
||||
key,
|
||||
iv,
|
||||
) 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 metadata: EncryptedFileMetadata = {
|
||||
@@ -62,8 +78,8 @@ class DatabaseFileEncryption {
|
||||
encrypted,
|
||||
]);
|
||||
|
||||
fs.writeFileSync(tmpPath, finalBuffer);
|
||||
fs.renameSync(tmpPath, targetPath);
|
||||
await fs.promises.writeFile(tmpPath, finalBuffer);
|
||||
await fs.promises.rename(tmpPath, targetPath);
|
||||
|
||||
try {
|
||||
if (fs.existsSync(metadataPath)) {
|
||||
|
||||
@@ -6,6 +6,17 @@ const DEBOUNCE_MS = 2000;
|
||||
// Every save rewrites the whole database file, so background writes (metrics
|
||||
// samples, audit rows) may only cause one save per this window.
|
||||
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 {
|
||||
private static saveFunction: (() => Promise<void>) | null = null;
|
||||
@@ -14,6 +25,7 @@ export class DatabaseSaveTrigger {
|
||||
private static activeSave: Promise<void> | null = null;
|
||||
private static saveTimeout: NodeJS.Timeout | null = null;
|
||||
private static lastSaveAt = 0;
|
||||
private static lastSaveMs = 0;
|
||||
private static _dirty = false;
|
||||
private static batch = new AsyncLocalStorage<{
|
||||
open: boolean;
|
||||
@@ -56,7 +68,7 @@ export class DatabaseSaveTrigger {
|
||||
|
||||
const delay = Math.max(
|
||||
DEBOUNCE_MS,
|
||||
this.lastSaveAt + MIN_LAZY_SAVE_GAP_MS - Date.now(),
|
||||
this.lastSaveAt + lazySaveGapMs(this.lastSaveMs) - Date.now(),
|
||||
);
|
||||
|
||||
this.saveTimeout = setTimeout(async () => {
|
||||
@@ -64,7 +76,6 @@ export class DatabaseSaveTrigger {
|
||||
|
||||
try {
|
||||
await this.runSave();
|
||||
this._dirty = false;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Database save failed", error, {
|
||||
operation: "db_save_trigger_failed",
|
||||
@@ -122,7 +133,6 @@ export class DatabaseSaveTrigger {
|
||||
|
||||
try {
|
||||
await this.runSave();
|
||||
this._dirty = false;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Database force save failed", error, {
|
||||
operation: "db_save_trigger_force_failed",
|
||||
@@ -152,10 +162,17 @@ export class DatabaseSaveTrigger {
|
||||
this.activeSave = save;
|
||||
this.pendingSave = true;
|
||||
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 {
|
||||
await save;
|
||||
} catch (error) {
|
||||
this._dirty = true;
|
||||
throw error;
|
||||
} finally {
|
||||
this.lastSaveMs = Date.now() - this.lastSaveAt;
|
||||
if (this.activeSave === save) {
|
||||
this.activeSave = null;
|
||||
this.pendingSave = false;
|
||||
@@ -181,6 +198,7 @@ export class DatabaseSaveTrigger {
|
||||
this.saveTimeout = null;
|
||||
}
|
||||
this.lastSaveAt = 0;
|
||||
this.lastSaveMs = 0;
|
||||
|
||||
this.pendingSave = false;
|
||||
this.activeSave = null;
|
||||
|
||||
@@ -54,6 +54,7 @@ export const PERMISSION_CATALOG: PermissionCatalogEntry[] = [
|
||||
"admin.roles.manage",
|
||||
"admin.settings.manage",
|
||||
"admin.sessions.manage",
|
||||
"admin.operations.view",
|
||||
"admin.plugins.manage",
|
||||
],
|
||||
},
|
||||
|
||||
@@ -34,7 +34,7 @@ export type HostTrayTrigger = "always" | "hover" | "click" | "actionsOnly";
|
||||
|
||||
/** What clicking a host does when it already has an open tab. */
|
||||
export type HostClickBehavior =
|
||||
"newTab" | "focusExisting" | "focusExistingDoubleClickNew";
|
||||
"newTab" | "focusExisting" | "focusExistingDoubleClickNew" | "hostPage";
|
||||
|
||||
export interface HostSidebarFilterState {
|
||||
status: ("online" | "offline" | "pinned")[];
|
||||
@@ -136,6 +136,7 @@ const HOST_CLICK_BEHAVIORS: HostClickBehavior[] = [
|
||||
"newTab",
|
||||
"focusExisting",
|
||||
"focusExistingDoubleClickNew",
|
||||
"hostPage",
|
||||
];
|
||||
const STATUS_COLOR_SCHEMES: StatusColorScheme[] = ["accent", "status"];
|
||||
const FILTER_STATUS: HostSidebarFilterState["status"] = [
|
||||
|
||||
@@ -87,6 +87,8 @@ export interface ApiKey {
|
||||
expiresAt: string | null;
|
||||
lastUsedAt: string | null;
|
||||
isActive: boolean;
|
||||
/** Null for full access. */
|
||||
scopes?: string[] | null;
|
||||
}
|
||||
|
||||
export interface CreatedApiKey extends ApiKey {
|
||||
@@ -97,12 +99,14 @@ export async function createApiKey(
|
||||
name: string,
|
||||
userId: string,
|
||||
expiresAt?: string,
|
||||
scopes?: string[] | null,
|
||||
): Promise<CreatedApiKey> {
|
||||
try {
|
||||
const response = await authApi.post("/users/api-keys", {
|
||||
name,
|
||||
userId,
|
||||
expiresAt: expiresAt ?? null,
|
||||
scopes: scopes?.length ? scopes : null,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
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: {
|
||||
variant: {
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useInSeamlessTile } from "@/components/tile-grid/context";
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
// Inside an edge to edge tile layout the layout draws the lines.
|
||||
const tiled = useInSeamlessTile();
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-tile-card=""
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-4 overflow-hidden rounded-none bg-card py-4 text-xs/relaxed text-card-foreground border border-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-2 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-none *:[img:last-child]:rounded-none",
|
||||
"group/card flex flex-col gap-4 overflow-hidden rounded-none py-4 text-xs/relaxed text-card-foreground has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-2 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-none *:[img:last-child]:rounded-none",
|
||||
!tiled && "border border-border",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
Loaded 100 of 208 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user