mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-04 17:45:24 +00:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
@@ -250,6 +250,27 @@ jobs:
|
|||||||
path: termix_windows_arm64_portable.zip
|
path: termix_windows_arm64_portable.zip
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
|
# The same app with every official plugin, for installs without
|
||||||
|
# internet. Built last so nothing above packs the full set, and with
|
||||||
|
# no update files: a full install updates through the normal build and
|
||||||
|
# downloads the plugins it had on.
|
||||||
|
- name: Build Windows x64 full installer
|
||||||
|
if: inputs.artifact_destination != 'none'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TERMIX_PLUGIN_PROFILE: full
|
||||||
|
run: |
|
||||||
|
npm run build:plugins
|
||||||
|
npx electron-builder --win nsis --x64 --publish never '-c.nsis.artifactName=termix_windows_x64_full_nsis.${ext}'
|
||||||
|
|
||||||
|
- name: Upload Windows x64 full installer
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
if: hashFiles('release/termix_windows_x64_full_nsis.exe') != '' && inputs.artifact_destination != 'none'
|
||||||
|
with:
|
||||||
|
name: termix_windows_x64_full_nsis
|
||||||
|
path: release/termix_windows_x64_full_nsis.exe
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
build-linux:
|
build-linux:
|
||||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||||
if: (inputs.build_type == 'all' || inputs.build_type == 'linux' || inputs.build_type == '') && inputs.artifact_destination != 'submit'
|
if: (inputs.build_type == 'all' || inputs.build_type == 'linux' || inputs.build_type == '') && inputs.artifact_destination != 'submit'
|
||||||
@@ -488,6 +509,28 @@ jobs:
|
|||||||
path: release/com.karmaa.termix.flatpakref
|
path: release/com.karmaa.termix.flatpakref
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
|
# Every official plugin, for installs without internet. No update files.
|
||||||
|
- name: Build Linux x64 full packages
|
||||||
|
if: inputs.artifact_destination != 'none'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TERMIX_PLUGIN_PROFILE: full
|
||||||
|
run: |
|
||||||
|
npm run build:plugins
|
||||||
|
npx electron-builder --linux AppImage deb --x64 --publish never \
|
||||||
|
'-c.appImage.artifactName=termix_linux_x64_full_appimage.${ext}' \
|
||||||
|
'-c.deb.artifactName=termix_linux_x64_full_deb.${ext}'
|
||||||
|
|
||||||
|
- name: Upload Linux x64 full packages
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
if: hashFiles('release/termix_linux_x64_full_appimage.AppImage') != '' && inputs.artifact_destination != 'none'
|
||||||
|
with:
|
||||||
|
name: termix_linux_x64_full
|
||||||
|
path: |
|
||||||
|
release/termix_linux_x64_full_appimage.AppImage
|
||||||
|
release/termix_linux_x64_full_deb.deb
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
build-macos:
|
build-macos:
|
||||||
runs-on: blacksmith-6vcpu-macos-latest
|
runs-on: blacksmith-6vcpu-macos-latest
|
||||||
if: (inputs.build_type == 'macos' || inputs.build_type == 'all') && inputs.artifact_destination != 'submit'
|
if: (inputs.build_type == 'macos' || inputs.build_type == 'all') && inputs.artifact_destination != 'submit'
|
||||||
@@ -763,6 +806,32 @@ jobs:
|
|||||||
|
|
||||||
gh release upload "$RELEASE_TAG" homebrew-generated/termix.rb --repo ${{ github.repository }} --clobber
|
gh release upload "$RELEASE_TAG" homebrew-generated/termix.rb --repo ${{ github.repository }} --clobber
|
||||||
|
|
||||||
|
# Every official plugin, for installs without internet. Signed like the
|
||||||
|
# normal DMG, with no update files.
|
||||||
|
- name: Build macOS universal full DMG
|
||||||
|
if: inputs.artifact_destination != 'none'
|
||||||
|
env:
|
||||||
|
ELECTRON_BUILDER_ALLOW_UNRESOLVED_DEPENDENCIES: true
|
||||||
|
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||||
|
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||||
|
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||||
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
|
NODE_OPTIONS: --max-old-space-size=4096
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TERMIX_PLUGIN_PROFILE: full
|
||||||
|
run: |
|
||||||
|
npm run build:plugins
|
||||||
|
npx electron-builder --mac dmg --universal --publish never \
|
||||||
|
'-c.dmg.artifactName=termix_macos_${arch}_full_dmg.${ext}'
|
||||||
|
|
||||||
|
- name: Upload macOS universal full DMG
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
if: hashFiles('release/termix_macos_universal_full_dmg.dmg') != '' && inputs.artifact_destination != 'none'
|
||||||
|
with:
|
||||||
|
name: termix_macos_universal_full_dmg
|
||||||
|
path: release/termix_macos_universal_full_dmg.dmg
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
- name: Clean up keychains
|
- name: Clean up keychains
|
||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
@@ -1247,7 +1316,9 @@ jobs:
|
|||||||
--app_identifier "com.karmaa.termix" \
|
--app_identifier "com.karmaa.termix" \
|
||||||
--skip_metadata false \
|
--skip_metadata false \
|
||||||
--metadata_path "${{ steps.asc_notes.outputs.metadata_path }}" \
|
--metadata_path "${{ steps.asc_notes.outputs.metadata_path }}" \
|
||||||
--skip_screenshots true \
|
--skip_screenshots false \
|
||||||
|
--screenshots_path packaging/appstore/screenshots \
|
||||||
|
--overwrite_screenshots true \
|
||||||
--skip_app_version_update false \
|
--skip_app_version_update false \
|
||||||
--submit_for_review true \
|
--submit_for_review true \
|
||||||
--automatic_release true \
|
--automatic_release true \
|
||||||
|
|||||||
@@ -1,10 +1,70 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 26.11.1
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Termix actions: plugins can declare what they do once, and it works from the API with permission checks, dry runs, run history and approvals
|
||||||
|
- API keys can be limited to reading or running actions, when an admin makes them
|
||||||
|
- A click on a host can open its host page, when a plugin offers one
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Host status, import and export, and credential tools now start on for new installs
|
||||||
|
- Sync now starts on in a new desktop app, so it can link to a server
|
||||||
|
- The first run plugin picker leaves plugins you did not choose off instead of removing them
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- The first run plugin picker no longer sets the default layout and themes to Remove. They always stay on
|
||||||
|
- Alerts with no icon no longer squeeze their text to one word per line
|
||||||
|
- Password fields with the show button are the same height as the other fields next to them
|
||||||
|
- The Secure your account setup step no longer shows up empty when no passkey or authenticator plugin is on
|
||||||
|
|
||||||
|
## 26.11.0
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Saving the database no longer holds up the whole server while it encrypts and writes. A big database is also saved less often by background writes, which fixes slow requests and dropped connections
|
||||||
|
- The macOS menu bar icon shows the logo instead of an empty shape on multi-architecture builds
|
||||||
|
- Disabled plugins no longer show update or review buttons, and no longer count as pending updates
|
||||||
|
- One slow request, like a plugin waiting on an SSH host, no longer shows Server connection lost. The app now checks the server first
|
||||||
|
- Canceled requests no longer show Server connection lost
|
||||||
|
- The macOS Window menu has an item to reopen the main window after it is closed
|
||||||
|
- Hosts with both an SSH key and a password can log in to servers set to AuthenticationMethods publickey,password
|
||||||
|
|
||||||
## 26.10.6
|
## 26.10.6
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- The mobile app can sign in through your browser with any login method, including passkeys and security keys
|
- The mobile app can sign in through your browser with any login method, including passkeys and security keys
|
||||||
|
- Plugins can say how they show in the mobile app
|
||||||
|
- The plugin list tells the mobile app which plugin API each plugin needs, so the app can say when it or a plugin needs an update
|
||||||
|
- Hosts can have a color and an icon, which sync to the desktop and mobile apps
|
||||||
|
- A sidebar setting to turn off alternating row colors
|
||||||
|
- TERMIX_NGINX_TEMPLATE, TERMIX_NGINX_CONF, TERMIX_NGINX_PID and TERMIX_APP_DIR let an install outside Docker, like the Proxmox LXC script, switch nginx to HTTPS when a certificate is added
|
||||||
|
- Removing a user or role from a shared folder can also remove their access from every host in the folder and its subfolders
|
||||||
|
- A personal login set once on a folder is used for every host shared with you in it and its subfolders, unless a host has its own
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- body-parser is updated to 2.4.0 for a security fix
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- The log line for a rejected proxy login now shows the source address, so you can add it to TRUSTED_PROXY_AUTH_TRUSTED_PROXIES
|
||||||
|
- Deleting your own account now removes all of its data, like an admin delete does, instead of leaving some behind
|
||||||
|
- An SSH key with a passphrase is unlocked once and kept unlocked in memory for a short time, so connecting to many hosts with it no longer freezes the server for minutes
|
||||||
|
- A change pushed from one linked desktop or phone now reaches your other linked devices right away, instead of on their next check a few minutes later
|
||||||
|
- The macOS menu bar icon shows the Termix logo instead of an empty square
|
||||||
|
- Cmd+1 (Alt+1 on Windows and Linux) and the other number shortcuts pick the open tabs as numbered in the tab bar, not the home tab
|
||||||
|
- A plugin the app no longer ships and could not download (for example on a locked down network) shows as failed with the reason on the Plugins tab, instead of as installed
|
||||||
|
- The desktop app no longer shows This layout could not be shown when the layout is asked for before its plugin has finished starting; it tries again
|
||||||
|
- On the desktop, a plugin installed from the registry that uses core's packages through require (like ACME SSL and axios) starts instead of failing with Cannot find module
|
||||||
|
- Adding a certificate on an install where Termix cannot manage nginx says what to set instead of only asking for a restart
|
||||||
|
- A login someone shared with you works on a host shared with you, for personal SSH authentication and when the owner shares SSH authentication
|
||||||
|
- Lists in plugins (like the AI Assets table) use their full width on narrow screens, keep cells under the right header and no longer overlap rows
|
||||||
|
- Installing an official plugin from a file works offline: pick the .tmxplug and its .sig from the release together and it installs as official, so plugins like Fleets no longer stop with migration refused. It can also fill in a plugin that was on but could not be downloaded
|
||||||
|
|
||||||
## 26.10.5
|
## 26.10.5
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
cask "termix" do
|
cask "termix" do
|
||||||
version "26.10.5"
|
version "26.11.0"
|
||||||
sha256 "808c467c5c34402193c7950ce61df0fa0445782f276348cf31ef235a6427d21c"
|
sha256 "1b873e1315d769f8e416c1690032142efb152d1265c2f078b33f72cec3e6e03e"
|
||||||
|
|
||||||
url "https://github.com/Termix-SSH/Termix/releases/download/v#{version}/termix_macos_universal_dmg.dmg"
|
url "https://github.com/Termix-SSH/Termix/releases/download/v#{version}/termix_macos_universal_dmg.dmg"
|
||||||
name "Termix"
|
name "Termix"
|
||||||
|
|||||||
@@ -71,8 +71,8 @@ Plugins add everything else. The official ones ship with Termix, and admins can
|
|||||||
| [Databases](https://github.com/Termix-SSH/Plugin-Databases) | Browse and edit MySQL, MariaDB, PostgreSQL and SQLite databases on your hosts from one tab. |
|
| [Databases](https://github.com/Termix-SSH/Plugin-Databases) | Browse and edit MySQL, MariaDB, PostgreSQL and SQLite databases on your hosts from one tab. |
|
||||||
| [Docker](https://github.com/Termix-SSH/Plugin-Docker) | Manage Docker and Podman containers on your hosts over SSH, with logs, stats and a console. |
|
| [Docker](https://github.com/Termix-SSH/Plugin-Docker) | Manage Docker and Podman containers on your hosts over SSH, with logs, stats and a console. |
|
||||||
| [File Manager](https://github.com/Termix-SSH/Plugin-File-Manager) | Browse, edit and move files over SFTP, including straight from one server to another. |
|
| [File Manager](https://github.com/Termix-SSH/Plugin-File-Manager) | Browse, edit and move files over SFTP, including straight from one server to another. |
|
||||||
|
| [Firewall](https://github.com/Termix-SSH/Plugin-Firewall) | Manage UFW, firewalld, nftables and iptables rules on your hosts over SSH, with lockout protection. |
|
||||||
| [Fleets](https://github.com/Termix-SSH/Plugin-Fleets) | Group hosts into fleets and run commands, package actions and file transfers on all of them at once. |
|
| [Fleets](https://github.com/Termix-SSH/Plugin-Fleets) | Group hosts into fleets and run commands, package actions and file transfers on all of them at once. |
|
||||||
| [HashiCorp Vault](https://github.com/Termix-SSH/Plugin-Vault) | Connect to hosts with short-lived SSH certificates signed by HashiCorp Vault. |
|
|
||||||
| [Homepage](https://github.com/Termix-SSH/Plugin-Homepage) | A drag and drop widget page for your servers and services, plus service links for the dashboard. |
|
| [Homepage](https://github.com/Termix-SSH/Plugin-Homepage) | A drag and drop widget page for your servers and services, plus service links for the dashboard. |
|
||||||
| [Host Metrics](https://github.com/Termix-SSH/Plugin-Host-Metrics) | Live CPU, memory, disk, network and GPU stats, plus tools to manage your hosts. |
|
| [Host Metrics](https://github.com/Termix-SSH/Plugin-Host-Metrics) | Live CPU, memory, disk, network and GPU stats, plus tools to manage your hosts. |
|
||||||
| [Host Status](https://github.com/Termix-SSH/Plugin-Host-Status) | Shows if each host is online with a dot in the host list, checked by connecting to its port. |
|
| [Host Status](https://github.com/Termix-SSH/Plugin-Host-Status) | Shows if each host is online with a dot in the host list, checked by connecting to its port. |
|
||||||
@@ -82,6 +82,7 @@ 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. |
|
| [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. |
|
| [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. |
|
| [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. |
|
| [OPKSSH](https://github.com/Termix-SSH/Plugin-OPKSSH) | Connect to hosts with short-lived OpenPubkey SSH certificates after signing in with your identity provider. |
|
||||||
| [Passkeys](https://github.com/Termix-SSH/Plugin-WebAuthn) | Sign in with a passkey or security key instead of a password. |
|
| [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. |
|
| [Proxmox](https://github.com/Termix-SSH/Plugin-Proxmox) | Import Proxmox VE guests as hosts, keep them in sync and watch node stats. |
|
||||||
@@ -96,6 +97,7 @@ Plugins add everything else. The official ones ship with Termix, and admins can
|
|||||||
| [Simple Layout](https://github.com/Termix-SSH/Plugin-Simple-Layout) | A simple Termix layout for small setups with a top bar, a Home screen and only the basics. |
|
| [Simple Layout](https://github.com/Termix-SSH/Plugin-Simple-Layout) | A simple Termix layout for small setups with a top bar, a Home screen and only the basics. |
|
||||||
| [Single sign-on](https://github.com/Termix-SSH/Plugin-SSO) | Sign in with any OpenID Connect provider, GitHub or Google. |
|
| [Single sign-on](https://github.com/Termix-SSH/Plugin-SSO) | Sign in with any OpenID Connect provider, GitHub or Google. |
|
||||||
| [Snippets](https://github.com/Termix-SSH/Plugin-Snippets) | Save the commands you run often and run them in a terminal or on many hosts in one click. |
|
| [Snippets](https://github.com/Termix-SSH/Plugin-Snippets) | Save the commands you run often and run them in a terminal or on many hosts in one click. |
|
||||||
|
| [SSH Certificate Signer](https://github.com/Termix-SSH/Plugin-Vault) | Connect to hosts with short-lived SSH certificates signed by HashiCorp Vault or OpenBao. |
|
||||||
| [SSH Terminal](https://github.com/Termix-SSH/Plugin-SSH-Terminal) | SSH terminal sessions with tabs, split screen, history, macros and a local terminal in the desktop app. |
|
| [SSH Terminal](https://github.com/Termix-SSH/Plugin-SSH-Terminal) | SSH terminal sessions with tabs, split screen, history, macros and a local terminal in the desktop app. |
|
||||||
| [Step CA](https://github.com/Termix-SSH/Plugin-Step-CA) | Connect to hosts with short-lived SSH certificates from a smallstep step-ca server. |
|
| [Step CA](https://github.com/Termix-SSH/Plugin-Step-CA) | Connect to hosts with short-lived SSH certificates from a smallstep step-ca server. |
|
||||||
| [Sync](https://github.com/Termix-SSH/Plugin-Sync) | Sync the desktop app with one or more servers, and manage other Termix servers as remote sites from a master. |
|
| [Sync](https://github.com/Termix-SSH/Plugin-Sync) | Sync the desktop app with one or more servers, and manage other Termix servers as remote sites from a master. |
|
||||||
@@ -105,9 +107,10 @@ Plugins add everything else. The official ones ship with Termix, and admins can
|
|||||||
| [TOTP](https://github.com/Termix-SSH/Plugin-TOTP) | Two-factor sign in with a code from an authenticator app, with backup codes. |
|
| [TOTP](https://github.com/Termix-SSH/Plugin-TOTP) | Two-factor sign in with a code from an authenticator app, with backup codes. |
|
||||||
| [Tunnels](https://github.com/Termix-SSH/Plugin-Tunnels) | SSH port forwarding that reconnects on its own, plus client tunnels from the desktop app. |
|
| [Tunnels](https://github.com/Termix-SSH/Plugin-Tunnels) | SSH port forwarding that reconnects on its own, plus client tunnels from the desktop app. |
|
||||||
| [Usage Statistics](https://github.com/Termix-SSH/Plugin-Telemetry) | Sends a small anonymous daily report so we can see how Termix is used. |
|
| [Usage Statistics](https://github.com/Termix-SSH/Plugin-Telemetry) | Sends a small anonymous daily report so we can see how Termix is used. |
|
||||||
| [Wake-on-LAN](https://github.com/Termix-SSH/Plugin-Wake-On-LAN) | Wake sleeping machines by sending a Wake-on-LAN packet to their MAC address. |
|
| [Wake-on-LAN](https://github.com/Termix-SSH/Plugin-Wake-On-LAN) | Wake, shut down and track your machines from one power dashboard. |
|
||||||
| [Warpgate](https://github.com/Termix-SSH/Plugin-Warpgate) | Connect to hosts through a Warpgate SSH bastion. |
|
| [Warpgate](https://github.com/Termix-SSH/Plugin-Warpgate) | Connect to hosts through a Warpgate SSH bastion. |
|
||||||
| [Web Endpoint](https://github.com/Termix-SSH/Plugin-Web-Endpoint) | Open a host's web UI inside Termix, directly or over an SSH tunnel. |
|
| [Web Endpoint](https://github.com/Termix-SSH/Plugin-Web-Endpoint) | Open a host's web UI inside Termix, directly or over an SSH tunnel. |
|
||||||
|
| [WireGuard](https://github.com/Termix-SSH/Plugin-WireGuard) | Install WireGuard on your hosts and manage interfaces, clients and configs from one tab. |
|
||||||
| [Workspaces](https://github.com/Termix-SSH/Plugin-Workspaces) | Save your open tabs and split layouts and reopen them in one click. |
|
| [Workspaces](https://github.com/Termix-SSH/Plugin-Workspaces) | Save your open tabs and split layouts and reopen them in one click. |
|
||||||
|
|
||||||
Want to build your own? Start from the [plugin template](https://github.com/Termix-SSH/Termix-Plugin-Template). Plugins you can install are listed in the [registry](https://github.com/Termix-SSH/Termix-Registry). Turn on the community registry in Plugins, Registries to install plugins made by other people, or add your own registry. See [submitting a plugin](https://docs.termix.site/develop/community-registry) and [hosting your own registry](https://docs.termix.site/develop/registries#host-your-own-registry).
|
Want to build your own? Start from the [plugin template](https://github.com/Termix-SSH/Termix-Plugin-Template). Plugins you can install are listed in the [registry](https://github.com/Termix-SSH/Termix-Registry). Turn on the community registry in Plugins, Registries to install plugins made by other people, or add your own registry. See [submitting a plugin](https://docs.termix.site/develop/community-registry) and [hosting your own registry](https://docs.termix.site/develop/registries#host-your-own-registry).
|
||||||
@@ -127,15 +130,15 @@ Want to build your own? Start from the [plugin template](https://github.com/Term
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center"><b>Windows</b> <sub>x64/ia32</sub></td>
|
<td align="center"><b>Windows</b> <sub>x64/ia32</sub></td>
|
||||||
<td>Portable | EXE and MSI Installer | Chocolatey</td>
|
<td>Portable | EXE and MSI Installer | Full EXE (all plugins) | Chocolatey</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center"><b>Linux</b> <sub>x64/arm64/armv7l</sub></td>
|
<td align="center"><b>Linux</b> <sub>x64/arm64/armv7l</sub></td>
|
||||||
<td>Portable | AUR | AppImage | Deb | Flatpak</td>
|
<td>Portable | AUR | AppImage | Deb | Full AppImage and Deb (all plugins) | Flatpak</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center"><b>macOS</b> <sub>Universal/x64/arm64, v12.0+</sub></td>
|
<td align="center"><b>macOS</b> <sub>Universal/x64/arm64, v12.0+</sub></td>
|
||||||
<td>Apple App Store | DMG | Homebrew</td>
|
<td>Apple App Store | DMG | Full DMG (all plugins) | Homebrew</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center"><b>iOS/iPadOS</b> <sub>v15.1+</sub></td>
|
<td align="center"><b>iOS/iPadOS</b> <sub>v15.1+</sub></td>
|
||||||
|
|||||||
+62
-46
@@ -10,15 +10,15 @@
|
|||||||
{
|
{
|
||||||
"id": "ai",
|
"id": "ai",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.3.0/ai-1.3.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.3.1/ai-1.3.1.tmxplug",
|
||||||
"sha256": "87ad17324cec6036dc224135fee342250c37413dccb2963f5f6b554d4ab8f5a6",
|
"sha256": "dbee8bc101b8584e9e356bc3539787bb534ffc25809cd92c4f5d45d52cad4ce2",
|
||||||
"tier": "full"
|
"tier": "full"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "alerts",
|
"id": "alerts",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.1.0/alerts-1.1.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.2.0/alerts-1.2.0.tmxplug",
|
||||||
"sha256": "a7ce9e20ce3c15f77c15479b4901563adf3c793e24a3aacd7226950491f0434c",
|
"sha256": "488947482eed0306893c2983f701a0e14c55975ca2a0c4bb0799a40fce9db054",
|
||||||
"tier": "standard",
|
"tier": "standard",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
@@ -42,8 +42,8 @@
|
|||||||
{
|
{
|
||||||
"id": "classic-layout",
|
"id": "classic-layout",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Classic-Layout/releases/download/v1.0.2/classic-layout-1.0.2.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Classic-Layout/releases/download/v1.1.1/classic-layout-1.1.1.tmxplug",
|
||||||
"sha256": "cf54f9b2e81c33b7e58cd3a21e203805f08ba37b3bc78412a08eb21b9fef8f14",
|
"sha256": "2b0cee0c57b21d8017ed833aa923e934b57b908fa68ff395951b4d52e5113e47",
|
||||||
"tier": "minimal",
|
"tier": "minimal",
|
||||||
"defaultLook": true
|
"defaultLook": true
|
||||||
},
|
},
|
||||||
@@ -67,20 +67,24 @@
|
|||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Credential-Tools/releases/download/v1.0.0/credential-tools-1.0.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Credential-Tools/releases/download/v1.0.0/credential-tools-1.0.0.tmxplug",
|
||||||
"sha256": "413eff710b5c832a2575a890bbab1c90d28ea55f10cd7a7b94b851d51dd77dce",
|
"sha256": "413eff710b5c832a2575a890bbab1c90d28ea55f10cd7a7b94b851d51dd77dce",
|
||||||
"tier": "standard"
|
"tier": "standard",
|
||||||
|
"onboarding": {
|
||||||
|
"recommended": true,
|
||||||
|
"consent": false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "databases",
|
"id": "databases",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Databases/releases/download/v1.1.0/databases-1.1.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Databases/releases/download/v1.1.1/databases-1.1.1.tmxplug",
|
||||||
"sha256": "4d9504cde705688a6f1f7706139d4e4c49b2276eccc67538fdee8251f29920b8",
|
"sha256": "316e26c682f5b07b0809e87f8ea94e2b819a9c46fc0e22611b24069c804312d0",
|
||||||
"tier": "full"
|
"tier": "full"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "docker",
|
"id": "docker",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.2.1/docker-1.2.1.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.2.3/docker-1.2.3.tmxplug",
|
||||||
"sha256": "b2ca797465d88cfb549a04c3f0161708669def22a5ca73cb107879847c2db8ff",
|
"sha256": "c997d5d1a751703facef544e901fb52e93fb79d5dc1c59496d4cb624210bbe14",
|
||||||
"tier": "standard",
|
"tier": "standard",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
@@ -90,8 +94,8 @@
|
|||||||
{
|
{
|
||||||
"id": "file-manager",
|
"id": "file-manager",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.1.1/file-manager-1.1.1.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.1.3/file-manager-1.1.3.tmxplug",
|
||||||
"sha256": "ebba0b7d14facb3354f22a00e059e9f5a69e233458cb24985bf4ed5e5f8d2abd",
|
"sha256": "b17315011c06db2223f04dac4606da505249dd8f2fec8aa24f1322c4dfd4d6ca",
|
||||||
"tier": "standard",
|
"tier": "standard",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
@@ -101,8 +105,8 @@
|
|||||||
{
|
{
|
||||||
"id": "fleets",
|
"id": "fleets",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.2/fleets-1.0.2.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.1.0/fleets-1.1.0.tmxplug",
|
||||||
"sha256": "50dab38e1c6c95fdb4d0544ce50cdf84dae84789e64aa34ea9d04fdac3a38c59",
|
"sha256": "5e0218c6145524ae8776b8fcbae043749c893d9b04fb67a9b3fe262ff76c0573",
|
||||||
"tier": "full"
|
"tier": "full"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -115,8 +119,8 @@
|
|||||||
{
|
{
|
||||||
"id": "host-metrics",
|
"id": "host-metrics",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.2.0/host-metrics-1.2.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.3.1/host-metrics-1.3.1.tmxplug",
|
||||||
"sha256": "8522cd23b558c3209821cee6d177daeb80f439b8ff652c577c5e4955315cc7a3",
|
"sha256": "3c14a72b3efa2a8b0010229acba64a6a4098e8c2e35d5a01cbc3cb731df39fb5",
|
||||||
"tier": "standard",
|
"tier": "standard",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
@@ -128,7 +132,11 @@
|
|||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Host-Status/releases/download/v1.0.0/host-status-1.0.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Host-Status/releases/download/v1.0.0/host-status-1.0.0.tmxplug",
|
||||||
"sha256": "1ba62577dc7373a2a2787bab7efdb89b544a30a8003b1de91ce51f372fc4f170",
|
"sha256": "1ba62577dc7373a2a2787bab7efdb89b544a30a8003b1de91ce51f372fc4f170",
|
||||||
"tier": "standard"
|
"tier": "standard",
|
||||||
|
"onboarding": {
|
||||||
|
"recommended": true,
|
||||||
|
"consent": false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "hosts-config",
|
"id": "hosts-config",
|
||||||
@@ -140,9 +148,13 @@
|
|||||||
{
|
{
|
||||||
"id": "import-export",
|
"id": "import-export",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Import-Export/releases/download/v1.0.0/import-export-1.0.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Import-Export/releases/download/v1.0.1/import-export-1.0.1.tmxplug",
|
||||||
"sha256": "6670b6c147e81eed0ddfd249f9a7c9564f1ab50e45f2771161d2e30c47f6fd58",
|
"sha256": "d8f2f865e1e6b226f4fe38501bf69b752f47a643bed1d9d127181083e27da2f0",
|
||||||
"tier": "standard"
|
"tier": "standard",
|
||||||
|
"onboarding": {
|
||||||
|
"recommended": true,
|
||||||
|
"consent": false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "kubernetes",
|
"id": "kubernetes",
|
||||||
@@ -182,15 +194,15 @@
|
|||||||
{
|
{
|
||||||
"id": "proxmox",
|
"id": "proxmox",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.1.0/proxmox-1.1.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.2.1/proxmox-1.2.1.tmxplug",
|
||||||
"sha256": "8937d239ad40033d69fba59085fe8896ee25c61951c21f66ff98531fd4c63cbf",
|
"sha256": "d5a6e9cd50756785ede6aec351e2de08927816f71e54f3162c52331e0a804d74",
|
||||||
"tier": "full"
|
"tier": "full"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "remote-desktop",
|
"id": "remote-desktop",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.2.1/remote-desktop-1.2.1.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.3.1/remote-desktop-1.3.1.tmxplug",
|
||||||
"sha256": "ad244f31016f0ea5df5c303bf6f48af35c6c8e52b12098d381c8296626b73de7",
|
"sha256": "41483f8d64995206e0199cca7d37033e5b1f1194725da3525e1b8afa1b4e09a9",
|
||||||
"tier": "standard",
|
"tier": "standard",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
@@ -200,8 +212,8 @@
|
|||||||
{
|
{
|
||||||
"id": "reverse-proxy",
|
"id": "reverse-proxy",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Reverse-Proxy/releases/download/v1.1.0/reverse-proxy-1.1.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Reverse-Proxy/releases/download/v1.1.1/reverse-proxy-1.1.1.tmxplug",
|
||||||
"sha256": "c8a9851da7ca6291c1f47cf6ab235da5057455a86b4ed0dc1c1a3026afb30aa7",
|
"sha256": "f5e0089ca5eae1254f21ce18b222e70eb2beeae3bd874c2abc4faa1a68d3e47a",
|
||||||
"tier": "full"
|
"tier": "full"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -214,8 +226,8 @@
|
|||||||
{
|
{
|
||||||
"id": "secret-sources",
|
"id": "secret-sources",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.2/secret-sources-1.0.2.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.1.1/secret-sources-1.1.1.tmxplug",
|
||||||
"sha256": "82e2b1e95b0837dd3e5fee277be4eea2d1ce68567b0e204941c48150f415d134",
|
"sha256": "2211ce2dac6355db49590532dfc339dc44df9cf14008157b420c7d4533d669ee",
|
||||||
"tier": "full"
|
"tier": "full"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -232,15 +244,15 @@
|
|||||||
{
|
{
|
||||||
"id": "service-manager",
|
"id": "service-manager",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Service-Manager/releases/download/v1.1.0/service-manager-1.1.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Service-Manager/releases/download/v1.1.1/service-manager-1.1.1.tmxplug",
|
||||||
"sha256": "82b1c70abd97ed535f217535c667fe6c48e5e01e2bf8037a89b58b80d35206bf",
|
"sha256": "5731a04336d341a330416cba7b7999f0d8e6215752eabbf978ed58c4543c5036",
|
||||||
"tier": "full"
|
"tier": "full"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "session-recording",
|
"id": "session-recording",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.1.0/session-recording-1.1.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.1.1/session-recording-1.1.1.tmxplug",
|
||||||
"sha256": "31cd78d67e061242f8776849a2fae5f84ab48c7c765d487b32c5e1693c54b46a",
|
"sha256": "8e77087f98227ac37a8610dcc5380ad65162a58aebd9174a6c966d5a16b16c9e",
|
||||||
"tier": "full"
|
"tier": "full"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -260,8 +272,8 @@
|
|||||||
{
|
{
|
||||||
"id": "snippets",
|
"id": "snippets",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.1.0/snippets-1.1.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.2.0/snippets-1.2.0.tmxplug",
|
||||||
"sha256": "e2d7d1ebe5bdf2986ffb4961ac6f6d32b392b02c30a64a333a7eeb2ce6a46bb3",
|
"sha256": "a72c0e1f38e49cf86ed8993b6913a6fbbdad7814ae132d5186c097ee3ea09bc3",
|
||||||
"tier": "standard",
|
"tier": "standard",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
@@ -271,8 +283,8 @@
|
|||||||
{
|
{
|
||||||
"id": "ssh-terminal",
|
"id": "ssh-terminal",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.1.1/ssh-terminal-1.1.1.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.3.0/ssh-terminal-1.3.0.tmxplug",
|
||||||
"sha256": "5cfb381a2de10b7a6fab108d79645bb1e6e7316fb5e4150c4529a3e76a2193d8",
|
"sha256": "ad39449f1e60724a9f84451e1190982e5d3ecd17e1751ec09a3b21c228e6dd5e",
|
||||||
"tier": "standard",
|
"tier": "standard",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
@@ -282,8 +294,8 @@
|
|||||||
{
|
{
|
||||||
"id": "sso",
|
"id": "sso",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.4/sso-1.0.4.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.5/sso-1.0.5.tmxplug",
|
||||||
"sha256": "2aad33177085fbc92fe06cd007facffbfc8f42f9c9fa688ad5f37d68f62093d9",
|
"sha256": "c1353d17fd25c1f503528c2a64a31ef14a638d9845c1d6cd70bb540e0321d41c",
|
||||||
"tier": "standard",
|
"tier": "standard",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": false,
|
"recommended": false,
|
||||||
@@ -301,9 +313,13 @@
|
|||||||
{
|
{
|
||||||
"id": "sync",
|
"id": "sync",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Sync/releases/download/v1.0.1/sync-1.0.1.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Sync/releases/download/v1.1.0/sync-1.1.0.tmxplug",
|
||||||
"sha256": "66a301fd04fb8d2a3ece18dfb230069cf78e1138ee78941dc511dfbd8ee4f91e",
|
"sha256": "9023d547f0e0bcef582c6ba7b7a8ab2ef68d8d0b973dde17d9e8c84d92b6429e",
|
||||||
"tier": "standard"
|
"tier": "standard",
|
||||||
|
"onboarding": {
|
||||||
|
"recommended": "desktop",
|
||||||
|
"consent": false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "tailscale",
|
"id": "tailscale",
|
||||||
@@ -351,8 +367,8 @@
|
|||||||
{
|
{
|
||||||
"id": "tunnels",
|
"id": "tunnels",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.2.1/tunnels-1.2.1.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.2.3/tunnels-1.2.3.tmxplug",
|
||||||
"sha256": "2871a8d31854e1a31416c4afe5839099a4c489e34d9d31573659f73b2515f159",
|
"sha256": "5a23953ee19b2951bb895e033d2099b8f6d5abc0121253ef3da25328134984ad",
|
||||||
"tier": "standard",
|
"tier": "standard",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
@@ -390,8 +406,8 @@
|
|||||||
{
|
{
|
||||||
"id": "webauthn",
|
"id": "webauthn",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.1.0/webauthn-1.1.0.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.2.0/webauthn-1.2.0.tmxplug",
|
||||||
"sha256": "83a61a530db0273f9fef2080d6aa027a82ef8374fb4e0f158a8559a2770299de",
|
"sha256": "4e7e06d05a17b6e474fdb0d45c8fc320f2ea89375bda440f824636cc393d4615",
|
||||||
"tier": "standard",
|
"tier": "standard",
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
|
|||||||
@@ -276,6 +276,15 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location ~ ^/operations(/.*)?$ {
|
||||||
|
proxy_pass http://127.0.0.1:30001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
|
}
|
||||||
|
|
||||||
location ~ ^/plugins(/.*)?$ {
|
location ~ ^/plugins(/.*)?$ {
|
||||||
proxy_pass http://127.0.0.1:30001;
|
proxy_pass http://127.0.0.1:30001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
@@ -245,6 +245,15 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location ~ ^/operations(/.*)?$ {
|
||||||
|
proxy_pass http://127.0.0.1:30001;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
|
||||||
|
}
|
||||||
|
|
||||||
location ~ ^/plugins(/.*)?$ {
|
location ~ ^/plugins(/.*)?$ {
|
||||||
proxy_pass http://127.0.0.1:30001;
|
proxy_pass http://127.0.0.1:30001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE `ssh_data` ADD `color` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `ssh_data` ADD `icon` text;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
CREATE TABLE `shared_folder_auth_overrides` (
|
||||||
|
`id` int AUTO_INCREMENT NOT NULL,
|
||||||
|
`user_id` varchar(255) NOT NULL,
|
||||||
|
`folder` varchar(255) NOT NULL,
|
||||||
|
`protocol` varchar(255) NOT NULL DEFAULT 'ssh',
|
||||||
|
`credential_id` int NOT NULL,
|
||||||
|
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||||
|
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||||
|
CONSTRAINT `shared_folder_auth_overrides_id` PRIMARY KEY(`id`),
|
||||||
|
CONSTRAINT `shared_folder_auth_overrides_user_folder_protocol_unique` UNIQUE(`user_id`,`folder`,`protocol`)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `shared_folder_auth_overrides` ADD CONSTRAINT `shared_folder_auth_overrides_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE `shared_folder_auth_overrides` ADD CONSTRAINT `shared_folder_auth_overrides_credential_id_ssh_credentials_id_fk` FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON DELETE cascade ON UPDATE no action;
|
||||||
@@ -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
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -533,6 +533,27 @@
|
|||||||
"when": 1792430772041,
|
"when": 1792430772041,
|
||||||
"tag": "0075_session_policy",
|
"tag": "0075_session_policy",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 76,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1792945361809,
|
||||||
|
"tag": "0076_host_color_icon",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 77,
|
||||||
|
"version": "5",
|
||||||
|
"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,2 @@
|
|||||||
|
ALTER TABLE "ssh_data" ADD COLUMN "color" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "ssh_data" ADD COLUMN "icon" text;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
CREATE TABLE "shared_folder_auth_overrides" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" varchar(255) NOT NULL,
|
||||||
|
"folder" varchar(255) NOT NULL,
|
||||||
|
"protocol" varchar(255) DEFAULT 'ssh' NOT NULL,
|
||||||
|
"credential_id" integer NOT NULL,
|
||||||
|
"created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
"updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "shared_folder_auth_overrides" ADD CONSTRAINT "shared_folder_auth_overrides_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "shared_folder_auth_overrides" ADD CONSTRAINT "shared_folder_auth_overrides_credential_id_ssh_credentials_id_fk" FOREIGN KEY ("credential_id") REFERENCES "public"."ssh_credentials"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "shared_folder_auth_overrides_user_folder_protocol_unique" ON "shared_folder_auth_overrides" USING btree ("user_id","folder","protocol");
|
||||||
@@ -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
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -533,6 +533,27 @@
|
|||||||
"when": 1792430770990,
|
"when": 1792430770990,
|
||||||
"tag": "0075_session_policy",
|
"tag": "0075_session_policy",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 76,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1792945360507,
|
||||||
|
"tag": "0076_host_color_icon",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 77,
|
||||||
|
"version": "7",
|
||||||
|
"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,2 @@
|
|||||||
|
ALTER TABLE `ssh_data` ADD `color` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `ssh_data` ADD `icon` text;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
CREATE TABLE `shared_folder_auth_overrides` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`user_id` text NOT NULL,
|
||||||
|
`folder` text NOT NULL,
|
||||||
|
`protocol` text DEFAULT 'ssh' NOT NULL,
|
||||||
|
`credential_id` integer NOT NULL,
|
||||||
|
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
`updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `shared_folder_auth_overrides_user_folder_protocol_unique` ON `shared_folder_auth_overrides` (`user_id`,`folder`,`protocol`);
|
||||||
@@ -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
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -498,6 +498,27 @@
|
|||||||
"when": 1792430769899,
|
"when": 1792430769899,
|
||||||
"tag": "0070_session_policy",
|
"tag": "0070_session_policy",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 71,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1792945434651,
|
||||||
|
"tag": "0071_host_color_icon",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 72,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1793304926942,
|
||||||
|
"tag": "0072_shared_folder_auth_overrides",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 73,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1793765382283,
|
||||||
|
"tag": "0073_dazzling_amazoness",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+16
-8
@@ -1235,19 +1235,14 @@ function createTray() {
|
|||||||
// use the unpacked path so the OS sees a real file.
|
// use the unpacked path so the OS sees a real file.
|
||||||
const publicRoot = isDev
|
const publicRoot = isDev
|
||||||
? path.join(appRoot, "public")
|
? path.join(appRoot, "public")
|
||||||
: path.join(
|
: path.join(getUnpackedAppRoot(appRoot), "public");
|
||||||
appRoot.replace(
|
|
||||||
/app(-[a-z0-9]+)?\.asar(?!\.unpacked)/,
|
|
||||||
"app.asar.unpacked",
|
|
||||||
),
|
|
||||||
"public",
|
|
||||||
);
|
|
||||||
|
|
||||||
let trayIcon;
|
let trayIcon;
|
||||||
if (process.platform === "darwin") {
|
if (process.platform === "darwin") {
|
||||||
const iconPath = path.join(publicRoot, "icons", "16x16.png");
|
const iconPath = path.join(publicRoot, "icons", "16x16.png");
|
||||||
|
// The logo is full color on a solid tile. As a template image macOS
|
||||||
|
// keeps only its outline, which showed as an empty square.
|
||||||
trayIcon = nativeImage.createFromPath(iconPath);
|
trayIcon = nativeImage.createFromPath(iconPath);
|
||||||
trayIcon.setTemplateImage(true);
|
|
||||||
} else if (process.platform === "win32") {
|
} else if (process.platform === "win32") {
|
||||||
trayIcon = path.join(publicRoot, "icon.ico");
|
trayIcon = path.join(publicRoot, "icon.ico");
|
||||||
} else {
|
} else {
|
||||||
@@ -2026,6 +2021,19 @@ function createMenu() {
|
|||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "front" },
|
{ role: "front" },
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
|
{
|
||||||
|
label: app.name,
|
||||||
|
accelerator: "CmdOrCtrl+0",
|
||||||
|
click: () => {
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||||
|
mainWindow.show();
|
||||||
|
mainWindow.focus();
|
||||||
|
} else {
|
||||||
|
createWindow();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
{ role: "window" },
|
{ role: "window" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -83,6 +83,30 @@
|
|||||||
"default": "8443",
|
"default": "8443",
|
||||||
"description": "Port for HTTPS."
|
"description": "Port for HTTPS."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "TERMIX_NGINX_TEMPLATE",
|
||||||
|
"group": "https",
|
||||||
|
"default": "/app/nginx/nginx-https.conf.template",
|
||||||
|
"description": "The HTTPS nginx template Termix renders when a certificate is added. Set it on an install outside Docker."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TERMIX_NGINX_CONF",
|
||||||
|
"group": "https",
|
||||||
|
"default": "/tmp/nginx/nginx.conf",
|
||||||
|
"description": "Where Termix writes the rendered nginx config before it reloads nginx."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TERMIX_NGINX_PID",
|
||||||
|
"group": "https",
|
||||||
|
"default": "/tmp/nginx/nginx.pid",
|
||||||
|
"description": "The nginx pid file. Termix only reloads nginx when it exists."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TERMIX_APP_DIR",
|
||||||
|
"group": "https",
|
||||||
|
"default": "/app",
|
||||||
|
"description": "Where Termix is installed, used in place of /app in the nginx template."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "SSL_CERT_PATH",
|
"name": "SSL_CERT_PATH",
|
||||||
"group": "https",
|
"group": "https",
|
||||||
|
|||||||
Generated
+10
-10
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "termix",
|
"name": "termix",
|
||||||
"version": "26.10.6",
|
"version": "26.11.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "termix",
|
"name": "termix",
|
||||||
"version": "26.10.6",
|
"version": "26.11.1",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*"
|
"packages/*"
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
"axios": "^1.19.0",
|
"axios": "^1.19.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^13.0.3",
|
"better-sqlite3": "^13.0.3",
|
||||||
"body-parser": "^2.3.0",
|
"body-parser": "^2.4.0",
|
||||||
"chalk": "^6.0.0",
|
"chalk": "^6.0.0",
|
||||||
"compression": "^1.8.2",
|
"compression": "^1.8.2",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
@@ -7470,18 +7470,18 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/body-parser": {
|
"node_modules/body-parser": {
|
||||||
"version": "2.3.0",
|
"version": "2.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz",
|
||||||
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
|
"integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bytes": "^3.1.2",
|
"bytes": "^3.1.2",
|
||||||
"content-type": "^2.0.0",
|
"content-type": "^2.1.0",
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"http-errors": "^2.0.1",
|
"http-errors": "^2.0.1",
|
||||||
"iconv-lite": "^0.7.2",
|
"iconv-lite": "^0.7.3",
|
||||||
"on-finished": "^2.4.1",
|
"on-finished": "^2.4.1",
|
||||||
"qs": "^6.15.2",
|
"qs": "^6.16.1",
|
||||||
"raw-body": "^3.0.2",
|
"raw-body": "^3.0.2",
|
||||||
"type-is": "^2.1.0"
|
"type-is": "^2.1.0"
|
||||||
},
|
},
|
||||||
@@ -15058,7 +15058,7 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin-sdk": {
|
"packages/plugin-sdk": {
|
||||||
"name": "@termix-ssh/plugin-sdk",
|
"name": "@termix-ssh/plugin-sdk",
|
||||||
"version": "1.0.13",
|
"version": "1.0.22",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@deadendjs/swagger-jsdoc": "^8.1.2",
|
"@deadendjs/swagger-jsdoc": "^8.1.2",
|
||||||
|
|||||||
+2
-2
@@ -4,7 +4,7 @@
|
|||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
"version": "26.10.6",
|
"version": "26.11.1",
|
||||||
"description": "Self-hosted, plugin-based server management.",
|
"description": "Self-hosted, plugin-based server management.",
|
||||||
"author": "Karmaa",
|
"author": "Karmaa",
|
||||||
"main": "electron/main.cjs",
|
"main": "electron/main.cjs",
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
"axios": "^1.19.0",
|
"axios": "^1.19.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^13.0.3",
|
"better-sqlite3": "^13.0.3",
|
||||||
"body-parser": "^2.3.0",
|
"body-parser": "^2.4.0",
|
||||||
"chalk": "^6.0.0",
|
"chalk": "^6.0.0",
|
||||||
"compression": "^1.8.2",
|
"compression": "^1.8.2",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
|
|||||||
@@ -1,5 +1,55 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `contributes.mobile` in the manifest says how a plugin shows in the Termix mobile app: `webview`, `native` or `none`, with an optional mobile bundle, screen sizes, views and a note
|
||||||
|
- `termix-plugin build` bundles `src/mobile` into the `contributes.mobile.entry` file, and `validate` checks that it exists
|
||||||
|
- `@termix-ssh/plugin-sdk/frame-host` runs one plugin view outside the shell, with core's bridge, registries and CSS, so the mobile app can show `webview` plugins
|
||||||
|
- SSH transport providers get the host's owner, sync id, connection origin and local only flag in `claims` and `open`, and core's SSH relay takes a `syncId` instead of a host id
|
||||||
|
- `ctx.sync.host.entities.idsOf` maps sync ids to the local ids of the user's own rows of one type
|
||||||
|
- Rail items take `useStatus` for a small ok, warning or error dot on the icon
|
||||||
|
- `shell.editHost(hostId)` opens the editor of a saved host
|
||||||
|
- A sync entity can be read only with `load`, `write` and `erase`, so a plugin can sync rows a user does not own, like a shared library, down to linked desktops
|
||||||
|
- `getFolderAuthOverride` and `setFolderAuthOverride` in `@termix-ssh/plugin-sdk/ui` read and set a user's own login for the shared hosts in a folder
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `@termix-ssh/plugin-sdk/ui` is built in production mode, so outside Termix it no longer sends requests to the Vite dev server path
|
||||||
|
- `execCommand` buffers output and decodes it once as strict UTF-8 with a GBK fallback, exposed as `decodeCommandOutput`, so messages from a Windows host on a legacy codepage are readable instead of � characters
|
||||||
|
|
||||||
## 1.0.21
|
## 1.0.21
|
||||||
|
|
||||||
Plugin API 1.12.0.
|
Plugin API 1.12.0.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Termix plugin guide
|
# Termix plugin guide
|
||||||
|
|
||||||
SDK 1.0.21 | Plugin API 1.12.0
|
SDK 1.0.25 | Plugin API 1.14.0
|
||||||
|
|
||||||
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
|
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
|
||||||
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
|
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
|
||||||
@@ -175,7 +175,49 @@ Unknown fields are refused at every level, so a typo fails validation. The schem
|
|||||||
|
|
||||||
`contributes` holds `tabs`, `panels`, `dashboardCards`, `actions`, `actionSlots`, `permissions`,
|
`contributes` holds `tabs`, `panels`, `dashboardCards`, `actions`, `actionSlots`, `permissions`,
|
||||||
`settings`, `protocols`, `auth`, `syncEntities`, `keybindingActions`, `http`, `guest`, `guestViews`,
|
`settings`, `protocols`, `auth`, `syncEntities`, `keybindingActions`, `http`, `guest`, `guestViews`,
|
||||||
`uiPresets`, `uses`, `layouts` and `themes`. Frontend `register*` calls need their ids declared here first.
|
`uiPresets`, `uses`, `layouts`, `themes` and `mobile`. Frontend `register*` calls need their ids declared here first.
|
||||||
|
|
||||||
|
### Mobile app
|
||||||
|
|
||||||
|
`contributes.mobile` says how the plugin shows in the Termix mobile app. Leave it out and the app treats the
|
||||||
|
plugin as having nothing to show (official plugins are mapped by the app itself).
|
||||||
|
|
||||||
|
```json
|
||||||
|
"mobile": { "mode": "webview", "views": ["hello"], "sizes": ["phone", "tablet"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Notes |
|
||||||
|
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `mode` | `webview` (the app shows your UI from the server), `native` (the app has its own screens) or `none`. |
|
||||||
|
| `entry` | `webview` only. A bundle made for the app, like `dist/mobile.js`, next to `frontend.js`. Unset: your normal frontend runs in a phone sized frame. |
|
||||||
|
| `sizes` | `webview` only. `phone`, `tablet` or both (the default). |
|
||||||
|
| `views` | `webview` only. Ids from your `tabs`, `panels` or `dashboardCards` the app offers. Default: every tab. |
|
||||||
|
| `feature` | `native` only. The app feature that stands in for the plugin. Default: the plugin id. |
|
||||||
|
| `note` | A locale key or plain text the app shows next to the plugin. |
|
||||||
|
|
||||||
|
`build` makes `entry` from `src/mobile/index.tsx`, and it shares `frontend.css`.
|
||||||
|
|
||||||
|
How a `webview` plugin runs in the app:
|
||||||
|
|
||||||
|
- Only your plugin loads, with the same app object, SDK hooks and `/ui` kit as the web app. Each view opens
|
||||||
|
on its own screen: a tab with `host-context-menu` in `openFrom` opens from a host, the rest from the app's
|
||||||
|
plugin list.
|
||||||
|
- Every request and socket goes through the app, which adds the login, proxy headers and client
|
||||||
|
certificate. The app only lets through your own `/plugin-api`, `/plugin-ws` and `/plugin-assets`, your
|
||||||
|
settings and a short list of core reads (hosts, folders, permissions, current user, version, health,
|
||||||
|
activity). Other core routes answer 403, so call your own backend instead.
|
||||||
|
- Images load straight from the server, without proxy headers or a client certificate.
|
||||||
|
- Nothing in `localStorage` survives closing the view. Keep preferences in settings.
|
||||||
|
- Other plugins' views, extensions and components are not there, and neither is the shell (`useShell()`
|
||||||
|
is null). `/ui` names newer than the app's copy of the SDK make the view say it needs a newer app.
|
||||||
|
- The app checks your `engine.api` against the plugin API of its own copy of the SDK. When the app's API
|
||||||
|
is too old for the range, it asks the user to update the app instead of opening the view. When the range
|
||||||
|
is for an older API major, it asks to update the plugin. Ask only for the API you use.
|
||||||
|
- Turning a plugin off on the server hides it in the app the next time the app checks: when it comes back
|
||||||
|
to the front, when the list is pulled down, or when any request answers 503 with your `pluginId`.
|
||||||
|
|
||||||
|
Termix 26.10.5 and older refuse any manifest with `mobile`, so a plugin that declares it needs
|
||||||
|
`"engine": { "termix": ">=26.10.6", "api": "^1.13" }`.
|
||||||
|
|
||||||
Capabilities and permissions are different things. A capability (`hosts:read`, with a colon) is what your
|
Capabilities and permissions are different things. A capability (`hosts:read`, with a colon) is what your
|
||||||
code may reach. A permission (`hello.use`, with a dot) is what a user may do.
|
code may reach. A permission (`hello.use`, with a dot) is what a user may do.
|
||||||
@@ -304,7 +346,7 @@ router.post("/notes", async (req, res) => {
|
|||||||
- `ctx.rbac.require("manage")` on a single route, `await ctx.rbac.has("manage")` in code. A full id like
|
- `ctx.rbac.require("manage")` on a single route, `await ctx.rbac.has("manage")` in code. A full id like
|
||||||
`hosts.edit` checks a core permission.
|
`hosts.edit` checks a core permission.
|
||||||
- Host access: `ctx.hosts.checkAccess(hostId, "connect" | "view" | "edit" | "manage")`.
|
- Host access: `ctx.hosts.checkAccess(hostId, "connect" | "view" | "edit" | "manage")`.
|
||||||
- Transports: with `ssh:transport`, `ctx.ssh.transports.provide({ claims(hostId), open({ id, ip, port }) })`
|
- Transports: with `ssh:transport`, `ctx.ssh.transports.provide({ claims(hostId, host), open(host) })`, where `host` has `id`, `ip`, `port` and on Termix 26.10.6+ also `userId`, `syncId`, `connectionOrigin` and `localOnly`
|
||||||
returns a stream the SSH client uses instead of a direct, proxy or jump host connection. Core also serves
|
returns a stream the SSH client uses instead of a direct, proxy or jump host connection. Core also serves
|
||||||
`/plugin-ws/core/ssh-relay?hostId=` for callers with connect access, so one Termix can relay to another.
|
`/plugin-ws/core/ssh-relay?hostId=` for callers with connect access, so one Termix can relay to another.
|
||||||
- Managed hosts: with `hosts:lock`, `ctx.hosts.locks.provide({ isLocked, note, allowManualCreate })` makes
|
- Managed hosts: with `hosts:lock`, `ctx.hosts.locks.provide({ isLocked, note, allowManualCreate })` makes
|
||||||
@@ -372,6 +414,70 @@ ctx.schedule.every(60_000, () => refresh(), { jitterMs: 5_000 });
|
|||||||
- Use a service: declare it in `requires`, then `ctx.services.get<T>("snippets.access")`. It returns
|
- Use a service: declare it in `requires`, then `ctx.services.get<T>("snippets.access")`. It returns
|
||||||
nothing while no provider runs, so always handle that.
|
nothing while no provider runs, so always handle that.
|
||||||
|
|
||||||
|
### Operations
|
||||||
|
|
||||||
|
Declare what your plugin can do in `contributes.operations`, and it shows up in Automations, Fleets, the
|
||||||
|
assistant, the CLI, the phone app and the command palette with no other wiring. It also gets per host results,
|
||||||
|
dry runs, run history, approvals and drift checks.
|
||||||
|
|
||||||
|
```json
|
||||||
|
"capabilities": ["operations:provide"],
|
||||||
|
"engine": { "termix": ">=26.11.1-0", "api": "^1.13" },
|
||||||
|
"contributes": {
|
||||||
|
"operations": {
|
||||||
|
"actions": [{
|
||||||
|
"id": "demo.restart",
|
||||||
|
"titleKey": "ops.restart.title",
|
||||||
|
"scope": "host",
|
||||||
|
"inputs": [{ "key": "name", "type": "string", "labelKey": "ops.restart.name", "required": true }],
|
||||||
|
"risk": "change",
|
||||||
|
"permission": "use",
|
||||||
|
"hostAccess": "edit",
|
||||||
|
"dryRun": "describe"
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
ctx.operations.register("demo.restart", {
|
||||||
|
describe: (input) => `Would restart ${input.name}`,
|
||||||
|
run: async (input, { hostId, signal, log }) => {
|
||||||
|
return { summary: `Restarted ${input.name}`, changed: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- Actions: `scope` is `host`, `resource` or `server`. `risk` is `read`, `change` or `destructive`. Host actions declare
|
||||||
|
`hostAccess`. Use `edit` for changes unless people with `connect` already do this today. A destructive action
|
||||||
|
must use `edit` or `manage` and needs a confirm.
|
||||||
|
- Inputs use the settings field types, plus `required`, `pattern`, `optionsFrom` (a read action fills the choices)
|
||||||
|
and type `resource`. Keys you did not declare are refused before your handler runs. `secret` values reach the handler
|
||||||
|
but are never stored or shown. Mark private outputs `sensitive`.
|
||||||
|
- `dryRun`: `native` calls `run` with `dryRun: true` and nothing may change. `describe` calls `describe` instead.
|
||||||
|
- `check` names a read action that proves the result, used for drift. `undo` names the reverse action, and `run` returns
|
||||||
|
`undoInput`. `idempotent` lets a rollout retry it. `exclusive` allows one at a time per host. `when` limits it to
|
||||||
|
hosts by setting or OS.
|
||||||
|
- `surfaces`: the assistant and MCP are off until you set them true. Only do that for actions safe for a model to
|
||||||
|
ask for. The rest are on unless you set them false.
|
||||||
|
- Resources describe a kind of thing, such as a container: a read action on one host returns `{ rows }` as `data`, and
|
||||||
|
core draws the All hosts table and host page section. Attach actions with `scope: "resource"`.
|
||||||
|
- Signals are status tiles: a read action returns `{ value, status, label }` as `data`, with status `ok`, `warn`, `fail` or
|
||||||
|
`unknown`. `fix` names the action offered when it is not ok. Keep these reads cheap, they run for every host.
|
||||||
|
- Events: declare them, then call `ctx.operations.emit(id, payload, { hostId })`.
|
||||||
|
- Handlers must watch `signal` and stop when it fires. Throw on failure; a failed run never counts against your
|
||||||
|
plugin's error budget. Throw an error with `code = "NEEDS_INTERACTION"` when a host needs a person to sign in first.
|
||||||
|
- Handlers have no request and no session. Take what you need from the input and `hostId`.
|
||||||
|
- Keep your REST routes. Inside one, call `ctx.operations.run(id, { hostId, input })` so a button press goes through
|
||||||
|
the same checks and lands in the same history.
|
||||||
|
- To run another plugin's action, add `operations:invoke`, list it in `operations.invokes` and call
|
||||||
|
`ctx.operations.invoke(id, { hostId, input, via: "automation" })`. It acts as the current user and rules still apply.
|
||||||
|
- `risk` is your plugin's claim. Plugins run in-process, so this is not a sandbox. Actions from plugins not signed as
|
||||||
|
official count as `change` when rules decide what needs a person, and stay off the assistant and MCP until an admin
|
||||||
|
allows them.
|
||||||
|
- Set `engine.termix` to at least `>=26.11.1-0`. Keep `engine.api` as it was unless you import something new from
|
||||||
|
`/ui`, or the phone app will ask people to update to see your plugin.
|
||||||
|
|
||||||
### Shared packages
|
### Shared packages
|
||||||
|
|
||||||
These come from Termix and are never bundled: `@termix-ssh/plugin-sdk`, `express`, `ssh2`, `ws`, `multer`,
|
These come from Termix and are never bundled: `@termix-ssh/plugin-sdk`, `express`, `ssh2`, `ws`, `multer`,
|
||||||
@@ -453,6 +559,23 @@ Other storage: `ctx.kv` for a few values, `ctx.settings` for settings, `ctx.secr
|
|||||||
`ctx.files.dataDir()` for files. To sync a table to a linked desktop, use `ctx.sync.registerEntity` and
|
`ctx.files.dataDir()` for files. To sync a table to a linked desktop, use `ctx.sync.registerEntity` and
|
||||||
`contributes.syncEntities`. The entity name is part of the protocol and never changes.
|
`contributes.syncEntities`. The entity name is part of the protocol and never changes.
|
||||||
|
|
||||||
|
A registered table syncs the rows each user owns, both ways. For data you keep for the whole server (a shared
|
||||||
|
library, rows other users share), register a read only entity with `table: null`, `readOnly: true` and `load`,
|
||||||
|
`write` and `erase` (Termix 26.10.6+). `load(userId)` returns the rows that user should get, each with a
|
||||||
|
`syncId`. The desktop keeps them with `write` and drops them with `erase`, and never pushes them back:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
ctx.sync.registerEntity({
|
||||||
|
type: "sharedIcons",
|
||||||
|
table: null,
|
||||||
|
readOnly: true,
|
||||||
|
order: 70,
|
||||||
|
load: (userId) => iconsVisibleTo(userId),
|
||||||
|
write: (userId, row) => storeCopy(userId, row),
|
||||||
|
erase: (userId, syncId) => dropCopy(userId, syncId),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
## Frontend
|
## Frontend
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
@@ -903,6 +1026,7 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
|
|||||||
| `@termix-ssh/plugin-sdk/db` | `defineTable` and column builders |
|
| `@termix-ssh/plugin-sdk/db` | `defineTable` and column builders |
|
||||||
| `@termix-ssh/plugin-sdk/manifest` | `PluginManifest` type and manifest checks |
|
| `@termix-ssh/plugin-sdk/manifest` | `PluginManifest` type and manifest checks |
|
||||||
| `@termix-ssh/plugin-sdk/capabilities` | The capability catalog |
|
| `@termix-ssh/plugin-sdk/capabilities` | The capability catalog |
|
||||||
|
| `@termix-ssh/plugin-sdk/operations` | Types, input checks and JSON Schema for declared actions |
|
||||||
| `@termix-ssh/plugin-sdk/settings` | Settings field validation |
|
| `@termix-ssh/plugin-sdk/settings` | Settings field validation |
|
||||||
| `@termix-ssh/plugin-sdk/testing` | `createMockCtx`, `createTestDb`, `renderWithApp`, `createFakeContext` |
|
| `@termix-ssh/plugin-sdk/testing` | `createMockCtx`, `createTestDb`, `renderWithApp`, `createFakeContext` |
|
||||||
| `@termix-ssh/plugin-sdk/vitest-preset` | `pluginVitestConfig` |
|
| `@termix-ssh/plugin-sdk/vitest-preset` | `pluginVitestConfig` |
|
||||||
@@ -934,6 +1058,8 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
|
|||||||
| `desktop:local` | high | Use the user's own computer, desktop app only |
|
| `desktop:local` | high | Use the user's own computer, desktop app only |
|
||||||
| `hosts:export` | high | Export hosts with their saved logins |
|
| `hosts:export` | high | Export hosts with their saved logins |
|
||||||
| `notify:hub` | high | Receive every alert (the alerts hub only) |
|
| `notify:hub` | high | Receive every alert (the alerts hub only) |
|
||||||
|
| `operations:invoke` | high | Run other plugins' declared actions, listed in `invokes` |
|
||||||
|
| `operations:approve` | high | Decide which actions need a person |
|
||||||
| `ui:layout` | high | Replace the whole app layout |
|
| `ui:layout` | high | Replace the whole app layout |
|
||||||
| `hosts:write` | medium | Add and edit hosts |
|
| `hosts:write` | medium | Add and edit hosts |
|
||||||
| `hosts:status-provide` | medium | Be the source of host status dots |
|
| `hosts:status-provide` | medium | Be the source of host status dots |
|
||||||
@@ -949,6 +1075,7 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
|
|||||||
| `users:read` | medium | Usernames, roles and UI language |
|
| `users:read` | medium | Usernames, roles and UI language |
|
||||||
| `events:core` | medium | Listen to core events |
|
| `events:core` | medium | Listen to core events |
|
||||||
| `notify:send` | medium | Send alerts |
|
| `notify:send` | medium | Send alerts |
|
||||||
|
| `operations:provide` | medium | Offer declared actions and events |
|
||||||
| `audit:read` | medium | Read the audit log |
|
| `audit:read` | medium | Read the audit log |
|
||||||
| `desktop:window` | medium | Open windows in the desktop app |
|
| `desktop:window` | medium | Open windows in the desktop app |
|
||||||
| `hosts:read` | low | Read hosts the user can see |
|
| `hosts:read` | low | Read hosts the user can see |
|
||||||
|
|||||||
@@ -25,6 +25,43 @@ const FRONTEND_ENTRIES = [
|
|||||||
"src/frontend/index.mjs",
|
"src/frontend/index.mjs",
|
||||||
"src/frontend/index.js",
|
"src/frontend/index.js",
|
||||||
];
|
];
|
||||||
|
const MOBILE_ENTRIES = [
|
||||||
|
"src/mobile/index.tsx",
|
||||||
|
"src/mobile/index.ts",
|
||||||
|
"src/mobile/index.mjs",
|
||||||
|
"src/mobile/index.js",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Builds a bundle that runs in a browser against core's shared modules. */
|
||||||
|
async function buildBrowserBundle(entry, outfile, outDir) {
|
||||||
|
await esbuild.build({
|
||||||
|
entryPoints: [entry],
|
||||||
|
outfile,
|
||||||
|
bundle: true,
|
||||||
|
format: "esm",
|
||||||
|
platform: "browser",
|
||||||
|
target: "es2022",
|
||||||
|
jsx: "automatic",
|
||||||
|
sourcemap: true,
|
||||||
|
logLevel: "warning",
|
||||||
|
external: FRONTEND_EXTERNALS,
|
||||||
|
// Vite replaces these in the dev server; a plugin bundle has to have
|
||||||
|
// them too, or code that reads them throws when the plugin loads.
|
||||||
|
define: {
|
||||||
|
"process.env.NODE_ENV": '"production"',
|
||||||
|
"import.meta.env.DEV": "false",
|
||||||
|
"import.meta.env.PROD": "true",
|
||||||
|
"import.meta.env.MODE": '"production"',
|
||||||
|
"import.meta.env.SSR": "false",
|
||||||
|
"import.meta.env":
|
||||||
|
'{"DEV":false,"PROD":true,"MODE":"production","SSR":false,"BASE_URL":"/"}',
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
externalRequireInterop(FRONTEND_EXTERNALS),
|
||||||
|
staticUrlImports({ outDir }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A bundled CommonJS package that calls require("react/jsx-runtime") (or any
|
* A bundled CommonJS package that calls require("react/jsx-runtime") (or any
|
||||||
@@ -192,15 +229,12 @@ export async function buildTailwind(cwd, outDir) {
|
|||||||
// build() adds to what a compiler already emitted, so each half gets its own.
|
// build() adds to what a compiler already emitted, so each half gets its own.
|
||||||
const compiler = () =>
|
const compiler = () =>
|
||||||
compile(input, { base: path.dirname(THEME_CSS), onDependency: () => {} });
|
compile(input, { base: path.dirname(THEME_CSS), onDependency: () => {} });
|
||||||
const scanner = new Scanner({
|
// The mobile bundle uses the same stylesheet.
|
||||||
sources: [
|
const sources = ["frontend", "mobile"]
|
||||||
{
|
.map((dir) => path.join(cwd, "src", dir))
|
||||||
base: path.join(cwd, "src", "frontend"),
|
.filter((dir, index) => index === 0 || fs.existsSync(dir))
|
||||||
pattern: "**/*",
|
.map((base) => ({ base, pattern: "**/*", negated: false }));
|
||||||
negated: false,
|
const scanner = new Scanner({ sources });
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
// Flattened and minified the way core's own CSS is, so it does not rely
|
// Flattened and minified the way core's own CSS is, so it does not rely
|
||||||
// on native CSS nesting.
|
// on native CSS nesting.
|
||||||
const emit = async (candidates, options) =>
|
const emit = async (candidates, options) =>
|
||||||
@@ -297,33 +331,18 @@ export async function build({ cwd }) {
|
|||||||
|
|
||||||
const frontendEntry = resolveEntry(cwd, FRONTEND_ENTRIES);
|
const frontendEntry = resolveEntry(cwd, FRONTEND_ENTRIES);
|
||||||
if (frontendEntry) {
|
if (frontendEntry) {
|
||||||
await esbuild.build({
|
await buildBrowserBundle(
|
||||||
entryPoints: [frontendEntry],
|
frontendEntry,
|
||||||
outfile: path.join(outDir, "frontend.js"),
|
path.join(outDir, "frontend.js"),
|
||||||
bundle: true,
|
outDir,
|
||||||
format: "esm",
|
);
|
||||||
platform: "browser",
|
}
|
||||||
target: "es2022",
|
|
||||||
jsx: "automatic",
|
// A bundle the mobile app loads in its WebView, next to the frontend one.
|
||||||
sourcemap: true,
|
const mobileEntry = resolveEntry(cwd, MOBILE_ENTRIES);
|
||||||
logLevel: "warning",
|
const mobileOut = manifest.contributes?.mobile?.entry;
|
||||||
external: FRONTEND_EXTERNALS,
|
if (mobileEntry && typeof mobileOut === "string") {
|
||||||
// Vite replaces these in the dev server; a plugin bundle has to have
|
await buildBrowserBundle(mobileEntry, path.join(cwd, mobileOut), outDir);
|
||||||
// them too, or code that reads them throws when the plugin loads.
|
|
||||||
define: {
|
|
||||||
"process.env.NODE_ENV": '"production"',
|
|
||||||
"import.meta.env.DEV": "false",
|
|
||||||
"import.meta.env.PROD": "true",
|
|
||||||
"import.meta.env.MODE": '"production"',
|
|
||||||
"import.meta.env.SSR": "false",
|
|
||||||
"import.meta.env":
|
|
||||||
'{"DEV":false,"PROD":true,"MODE":"production","SSR":false,"BASE_URL":"/"}',
|
|
||||||
},
|
|
||||||
plugins: [
|
|
||||||
externalRequireInterop(FRONTEND_EXTERNALS),
|
|
||||||
staticUrlImports({ outDir }),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (frontendEntry) await buildTailwind(cwd, outDir);
|
if (frontendEntry) await buildTailwind(cwd, outDir);
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ export async function validate({ cwd }) {
|
|||||||
// A theme only plugin has no frontend code.
|
// A theme only plugin has no frontend code.
|
||||||
...(isThemeOnly(raw) ? [] : [raw.frontend ?? "dist/frontend.js"]),
|
...(isThemeOnly(raw) ? [] : [raw.frontend ?? "dist/frontend.js"]),
|
||||||
raw.locales ?? "locales",
|
raw.locales ?? "locales",
|
||||||
|
// Built from src/mobile by termix-plugin build.
|
||||||
|
...(typeof raw.contributes?.mobile?.entry === "string"
|
||||||
|
? [raw.contributes.mobile.entry]
|
||||||
|
: []),
|
||||||
];
|
];
|
||||||
for (const rel of referenced) {
|
for (const rel of referenced) {
|
||||||
if (!fs.existsSync(path.join(cwd, rel))) {
|
if (!fs.existsSync(path.join(cwd, rel))) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@termix-ssh/plugin-sdk",
|
"name": "@termix-ssh/plugin-sdk",
|
||||||
"version": "1.0.21",
|
"version": "1.0.25",
|
||||||
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -61,6 +61,10 @@
|
|||||||
"types": "./dist/openapi.d.ts",
|
"types": "./dist/openapi.d.ts",
|
||||||
"default": "./dist/openapi.js"
|
"default": "./dist/openapi.js"
|
||||||
},
|
},
|
||||||
|
"./operations": {
|
||||||
|
"types": "./dist/operations.d.ts",
|
||||||
|
"default": "./dist/operations.js"
|
||||||
|
},
|
||||||
"./settings": {
|
"./settings": {
|
||||||
"types": "./dist/settings.d.ts",
|
"types": "./dist/settings.d.ts",
|
||||||
"default": "./dist/settings.js"
|
"default": "./dist/settings.js"
|
||||||
@@ -102,6 +106,10 @@
|
|||||||
"./testing-host": {
|
"./testing-host": {
|
||||||
"default": "./dist/host/testing-host.js"
|
"default": "./dist/host/testing-host.js"
|
||||||
},
|
},
|
||||||
|
"./frame-host": {
|
||||||
|
"default": "./dist/host/frame-host.js"
|
||||||
|
},
|
||||||
|
"./frame-host.css": "./dist/host/frame-host.css",
|
||||||
"./theme.css": "./dist/host/theme.css"
|
"./theme.css": "./dist/host/theme.css"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@@ -536,6 +536,31 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"hostSections": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["id", "titleKey"],
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "string", "minLength": 1 },
|
||||||
|
"titleKey": { "type": "string", "minLength": 1 },
|
||||||
|
"icon": { "type": "string", "minLength": 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"operations": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "What this plugin can do. Every surface finds these on its own. See the operations section of PLUGIN-GUIDE.md.",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"actions": { "type": "array", "items": { "type": "object" } },
|
||||||
|
"events": { "type": "array", "items": { "type": "object" } },
|
||||||
|
"resources": { "type": "array", "items": { "type": "object" } },
|
||||||
|
"signals": { "type": "array", "items": { "type": "object" } },
|
||||||
|
"invokes": { "type": "array", "items": { "type": "string" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
"dashboardCards": {
|
"dashboardCards": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
@@ -1239,6 +1264,48 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"mobile": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "How the plugin shows in the Termix mobile app. Needs plugin API 1.13 (Termix 26.10.6).",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["mode"],
|
||||||
|
"properties": {
|
||||||
|
"mode": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["webview", "native", "none"],
|
||||||
|
"description": "webview: the app shows the plugin's UI from the server. native: the app has its own screens. none: nothing to show in the app."
|
||||||
|
},
|
||||||
|
"entry": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "\\.js$",
|
||||||
|
"description": "webview only. A bundle built for the app, in the same folder as the frontend bundle. Unset means the normal frontend runs in a phone sized frame."
|
||||||
|
},
|
||||||
|
"sizes": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": { "type": "string", "enum": ["phone", "tablet"] },
|
||||||
|
"description": "webview only. Screen sizes the UI works at. Defaults to both."
|
||||||
|
},
|
||||||
|
"views": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": { "type": "string", "minLength": 1 },
|
||||||
|
"description": "webview only. Tab, panel or card ids the app offers. Defaults to every tab."
|
||||||
|
},
|
||||||
|
"feature": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z][a-z0-9-]{1,39}$",
|
||||||
|
"description": "native only. The app feature that stands in for this plugin. Defaults to the plugin id."
|
||||||
|
},
|
||||||
|
"note": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "i18n key in the plugin's namespace, or plain text, shown in the app."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ import type { HostSshOptions } from "./ssh-options.js";
|
|||||||
export type { HostSshOptions } from "./ssh-options.js";
|
export type { HostSshOptions } from "./ssh-options.js";
|
||||||
import type { PluginManifest, PluginSettingsField } from "./manifest.js";
|
import type { PluginManifest, PluginSettingsField } from "./manifest.js";
|
||||||
import type { PluginTableDefinition } from "./db.js";
|
import type { PluginTableDefinition } from "./db.js";
|
||||||
|
import type {
|
||||||
|
OperationResult,
|
||||||
|
OperationRisk,
|
||||||
|
OperationSurfaces,
|
||||||
|
} from "./operations.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Thrown when a plugin calls a guarded member without the capability.
|
* Thrown when a plugin calls a guarded member without the capability.
|
||||||
@@ -177,8 +182,24 @@ export interface SyncWriteEvent {
|
|||||||
export interface SyncEntityRegistration {
|
export interface SyncEntityRegistration {
|
||||||
/** Stable wire name. Never change it: records on both sides match on it. */
|
/** Stable wire name. Never change it: records on both sides match on it. */
|
||||||
type: string;
|
type: string;
|
||||||
/** The table, as returned by ctx.db.define. */
|
/** The table, as returned by ctx.db.define. Null for an entity with `load`. */
|
||||||
table: unknown;
|
table: unknown;
|
||||||
|
/**
|
||||||
|
* Server to desktop only: a desktop never pushes these rows. Required with
|
||||||
|
* `load`.
|
||||||
|
*/
|
||||||
|
readOnly?: boolean;
|
||||||
|
/**
|
||||||
|
* Builds the rows the user gets instead of reading the rows they own in
|
||||||
|
* `table`, for data kept for the whole server (a shared library, rows other
|
||||||
|
* users share with them). Rows are on the wire: plaintext, each with a
|
||||||
|
* syncId. Needs `readOnly: true`, `write` and `erase`.
|
||||||
|
*/
|
||||||
|
load?: (userId: string) => Promise<SyncRow[]>;
|
||||||
|
/** Keeps a copy of an inbound row on the desktop, for an entity with `load`. */
|
||||||
|
write?: (userId: string, wire: SyncRow) => Promise<void>;
|
||||||
|
/** Drops the desktop copy with this syncId, for an entity with `load`. */
|
||||||
|
erase?: (userId: string, syncId: string) => Promise<void>;
|
||||||
/** Column holding the owning user id. Defaults to "userId". */
|
/** Column holding the owning user id. Defaults to "userId". */
|
||||||
userColumn?: string;
|
userColumn?: string;
|
||||||
/** Fields stored encrypted with the owner's data key. Plaintext on the wire. */
|
/** Fields stored encrypted with the owner's data key. Plaintext on the wire. */
|
||||||
@@ -470,6 +491,12 @@ export interface PluginSyncHost {
|
|||||||
ensureCore: () => Promise<void>;
|
ensureCore: () => Promise<void>;
|
||||||
onChanged: (listener: () => void) => () => void;
|
onChanged: (listener: () => void) => () => void;
|
||||||
onLocalWrite: (listener: () => void) => () => void;
|
onLocalWrite: (listener: () => void) => () => void;
|
||||||
|
/** Local ids of the user's own rows of a type, by sync id. Termix 26.10.6+. */
|
||||||
|
idsOf?: (
|
||||||
|
type: string,
|
||||||
|
userId: string,
|
||||||
|
syncIds: string[],
|
||||||
|
) => Promise<Record<string, number>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Rows and bookkeeping of one local user's replica. */
|
/** Rows and bookkeeping of one local user's replica. */
|
||||||
@@ -1813,13 +1840,21 @@ export interface PluginNetwork {
|
|||||||
* asked for every connection, so it must be quick. `open` returns a stream
|
* asked for every connection, so it must be quick. `open` returns a stream
|
||||||
* the SSH client runs over, like a relayed socket.
|
* the SSH client runs over, like a relayed socket.
|
||||||
*/
|
*/
|
||||||
|
/** The host a transport is asked about. Fields past port come from Termix 26.10.6. */
|
||||||
|
export interface PluginSshTransportHost {
|
||||||
|
id: number;
|
||||||
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
/** Owner of the host row. */
|
||||||
|
userId?: string | null;
|
||||||
|
syncId?: string | null;
|
||||||
|
connectionOrigin?: "local" | "remote" | null;
|
||||||
|
localOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PluginSshTransportProvider {
|
export interface PluginSshTransportProvider {
|
||||||
claims: (hostId: number) => boolean;
|
claims: (hostId: number, host?: PluginSshTransportHost) => boolean;
|
||||||
open: (host: {
|
open: (host: PluginSshTransportHost) => Promise<import("node:stream").Duplex>;
|
||||||
id: number;
|
|
||||||
ip: string;
|
|
||||||
port: number;
|
|
||||||
}) => Promise<import("node:stream").Duplex>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginSshTransports {
|
export interface PluginSshTransports {
|
||||||
@@ -3074,6 +3109,136 @@ export interface PluginPlugins {
|
|||||||
list: () => Promise<PluginSummary[]>;
|
list: () => Promise<PluginSummary[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What a handler gets besides its input. */
|
||||||
|
export interface PluginOperationContext {
|
||||||
|
hostId?: number;
|
||||||
|
/** The row a resource action targets. */
|
||||||
|
resourceKey?: string;
|
||||||
|
/** Null for a background read. */
|
||||||
|
runId: string | null;
|
||||||
|
/** Nothing may change when this is set. */
|
||||||
|
dryRun: boolean;
|
||||||
|
/** Fires when the run is cancelled, times out or the plugin stops. */
|
||||||
|
signal: AbortSignal;
|
||||||
|
log: (line: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginOperationHandler {
|
||||||
|
run: (
|
||||||
|
input: Record<string, unknown>,
|
||||||
|
context: PluginOperationContext,
|
||||||
|
) => Promise<OperationResult> | OperationResult;
|
||||||
|
/** The dry run text for an action declared with dryRun "describe". */
|
||||||
|
describe?: (
|
||||||
|
input: Record<string, unknown>,
|
||||||
|
context: PluginOperationContext,
|
||||||
|
) => string | Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginOperationInvokeOptions {
|
||||||
|
hostId?: number;
|
||||||
|
resourceKey?: string;
|
||||||
|
input?: Record<string, unknown>;
|
||||||
|
dryRun?: boolean;
|
||||||
|
/** A person agreed to a destructive action. */
|
||||||
|
confirmed?: boolean;
|
||||||
|
idempotencyKey?: string;
|
||||||
|
batchId?: string;
|
||||||
|
/** What this plugin is doing: automation, assistant, mcp, rollout and so on. */
|
||||||
|
via?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginOperationOutcome {
|
||||||
|
runId: string | null;
|
||||||
|
status: string;
|
||||||
|
summary?: string;
|
||||||
|
changed?: boolean;
|
||||||
|
data?: unknown;
|
||||||
|
error?: string;
|
||||||
|
errorCode?: string;
|
||||||
|
approvalId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginOperationApprovalInput {
|
||||||
|
actionId: string;
|
||||||
|
pluginId: string;
|
||||||
|
risk: OperationRisk;
|
||||||
|
/** What rules should use. Plugins not signed as official count as change. */
|
||||||
|
effectiveRisk: OperationRisk;
|
||||||
|
origin: { kind: string; pluginId?: string; via?: string };
|
||||||
|
userId: string;
|
||||||
|
hostId?: number;
|
||||||
|
hostTags?: string[];
|
||||||
|
batchId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginOperationApprovalDecision {
|
||||||
|
decision: "allow" | "approve" | "deny";
|
||||||
|
reason?: string;
|
||||||
|
allowSelf?: boolean;
|
||||||
|
expiresInMinutes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginOperationEvent {
|
||||||
|
id: string;
|
||||||
|
eventId: string;
|
||||||
|
pluginId: string;
|
||||||
|
hostId?: number;
|
||||||
|
severity?: string;
|
||||||
|
payload?: unknown;
|
||||||
|
runId?: string;
|
||||||
|
depth: number;
|
||||||
|
at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declared actions and events. See contributes.operations in the manifest.
|
||||||
|
* register and emit need operations:provide. invoke, list and onEvent need
|
||||||
|
* operations:invoke, and invoke only reaches ids in the manifest's invokes list.
|
||||||
|
*/
|
||||||
|
export interface PluginOperations {
|
||||||
|
register: (id: string, handler: PluginOperationHandler) => void;
|
||||||
|
/**
|
||||||
|
* Runs one of this plugin's own actions from its route, so a button press
|
||||||
|
* goes through the same checks and lands in the same history.
|
||||||
|
*/
|
||||||
|
run: (
|
||||||
|
actionId: string,
|
||||||
|
options?: PluginOperationInvokeOptions,
|
||||||
|
) => Promise<PluginOperationOutcome>;
|
||||||
|
invoke: (
|
||||||
|
actionId: string,
|
||||||
|
options?: PluginOperationInvokeOptions,
|
||||||
|
) => Promise<PluginOperationOutcome>;
|
||||||
|
list: (options?: {
|
||||||
|
lang?: string;
|
||||||
|
surface?: keyof OperationSurfaces;
|
||||||
|
}) => Promise<unknown>;
|
||||||
|
emit: (
|
||||||
|
eventId: string,
|
||||||
|
payload?: unknown,
|
||||||
|
options?: { hostId?: number },
|
||||||
|
) => Promise<void>;
|
||||||
|
onEvent: (subscription: {
|
||||||
|
pattern: string;
|
||||||
|
handler: (event: PluginOperationEvent) => void | Promise<void>;
|
||||||
|
userId?: string;
|
||||||
|
cooldownMs?: number;
|
||||||
|
}) => () => void;
|
||||||
|
/** Rules for what needs a person. Needs operations:approve. */
|
||||||
|
approvals: {
|
||||||
|
provide: (
|
||||||
|
provider: (
|
||||||
|
input: PluginOperationApprovalInput,
|
||||||
|
) =>
|
||||||
|
| PluginOperationApprovalDecision
|
||||||
|
| null
|
||||||
|
| undefined
|
||||||
|
| Promise<PluginOperationApprovalDecision | null | undefined>,
|
||||||
|
) => () => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface PluginContext {
|
export interface PluginContext {
|
||||||
readonly pluginId: string;
|
readonly pluginId: string;
|
||||||
readonly manifest: PluginManifest;
|
readonly manifest: PluginManifest;
|
||||||
@@ -3114,6 +3279,8 @@ export interface PluginContext {
|
|||||||
readonly schedule: PluginSchedule;
|
readonly schedule: PluginSchedule;
|
||||||
/** Core's notification channels. Needs notify:send. */
|
/** Core's notification channels. Needs notify:send. */
|
||||||
readonly notify: PluginNotify;
|
readonly notify: PluginNotify;
|
||||||
|
/** Declared actions and events, reachable from every surface. */
|
||||||
|
readonly operations: PluginOperations;
|
||||||
/** Outbound HTTP through core's SSRF guard. Needs network:outbound. */
|
/** Outbound HTTP through core's SSRF guard. Needs network:outbound. */
|
||||||
readonly fetch: PluginFetch;
|
readonly fetch: PluginFetch;
|
||||||
/** Plain reachability checks. Needs network:probe. */
|
/** Plain reachability checks. Needs network:probe. */
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
|
|||||||
entry("ssh:transport", "high"),
|
entry("ssh:transport", "high"),
|
||||||
entry("device:serial", "high"),
|
entry("device:serial", "high"),
|
||||||
entry("notify:hub", "high"),
|
entry("notify:hub", "high"),
|
||||||
|
entry("operations:invoke", "high"),
|
||||||
|
entry("operations:approve", "high"),
|
||||||
entry("ui:layout", "high"),
|
entry("ui:layout", "high"),
|
||||||
|
|
||||||
entry("hosts:write", "medium"),
|
entry("hosts:write", "medium"),
|
||||||
@@ -68,6 +70,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
|
|||||||
entry("users:read", "medium"),
|
entry("users:read", "medium"),
|
||||||
entry("events:core", "medium"),
|
entry("events:core", "medium"),
|
||||||
entry("notify:send", "medium"),
|
entry("notify:send", "medium"),
|
||||||
|
entry("operations:provide", "medium"),
|
||||||
entry("audit:read", "medium"),
|
entry("audit:read", "medium"),
|
||||||
entry("desktop:window", "medium"),
|
entry("desktop:window", "medium"),
|
||||||
entry("desktop:local", "high"),
|
entry("desktop:local", "high"),
|
||||||
|
|||||||
@@ -206,6 +206,8 @@ export interface ShellApi {
|
|||||||
* user reviews and saves it. Absent in some shells.
|
* user reviews and saves it. Absent in some shells.
|
||||||
*/
|
*/
|
||||||
openHostEditor?: (draft?: HostDraft) => void;
|
openHostEditor?: (draft?: HostDraft) => void;
|
||||||
|
/** Opens the editor of a saved host. Termix 26.10.6+, absent in some shells. */
|
||||||
|
editHost?: (hostId: number) => void;
|
||||||
/** Opens a rail view in the left sidebar. */
|
/** Opens a rail view in the left sidebar. */
|
||||||
openRailView: (id: string) => void;
|
openRailView: (id: string) => void;
|
||||||
/** Closes a rail view wherever it is shown. */
|
/** Closes a rail view wherever it is shown. */
|
||||||
@@ -425,6 +427,11 @@ export interface RailItemContribution {
|
|||||||
* Zero or nothing shows no badge.
|
* Zero or nothing shows no badge.
|
||||||
*/
|
*/
|
||||||
useBadge?: () => number | null | undefined;
|
useBadge?: () => number | null | undefined;
|
||||||
|
/**
|
||||||
|
* A hook for a small status dot on the icon: "ok" (green), "warning" or
|
||||||
|
* "error". Nothing shows no dot. Layouts from Termix 26.10.6 draw it.
|
||||||
|
*/
|
||||||
|
useStatus?: () => "ok" | "warning" | "error" | null | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PanelProps {
|
export interface PanelProps {
|
||||||
@@ -663,6 +670,22 @@ export type HostStatusIndicatorContribution = HostBadgeContribution;
|
|||||||
/** A control in the host editor's toolbar. */
|
/** A control in the host editor's toolbar. */
|
||||||
export type HostEditorToolbarItemContribution = HostBadgeContribution;
|
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 {
|
export interface HostContextMenuItemContribution {
|
||||||
id: string;
|
id: string;
|
||||||
titleKey: string;
|
titleKey: string;
|
||||||
@@ -1165,6 +1188,11 @@ export interface TermixApp extends TermixAppInfo {
|
|||||||
registerHostContextMenuItem: (
|
registerHostContextMenuItem: (
|
||||||
item: HostContextMenuItemContribution,
|
item: HostContextMenuItemContribution,
|
||||||
) => Disposer;
|
) => 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;
|
registerPaletteEntry: (entry: PaletteEntryContribution) => Disposer;
|
||||||
registerPaletteGroup: (group: PaletteGroupContribution) => Disposer;
|
registerPaletteGroup: (group: PaletteGroupContribution) => Disposer;
|
||||||
registerKeybindingAction: (action: KeybindingActionContribution) => Disposer;
|
registerKeybindingAction: (action: KeybindingActionContribution) => Disposer;
|
||||||
|
|||||||
@@ -21,6 +21,30 @@ export interface ExecResult {
|
|||||||
code: number | null;
|
code: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes raw command output. Strict UTF-8 first; when that fails, tries GBK,
|
||||||
|
* which is what a Chinese-locale Windows console sends (its bytes are almost
|
||||||
|
* never valid UTF-8) and keeps it only when it decodes without replacement
|
||||||
|
* characters. Anything else falls back to lossy UTF-8, the old behavior.
|
||||||
|
*/
|
||||||
|
export function decodeCommandOutput(buf: Buffer): string {
|
||||||
|
try {
|
||||||
|
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
|
||||||
|
buf,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// not valid UTF-8, try the legacy codepage
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// A Node built with small-ICU has no GBK decoder and throws here.
|
||||||
|
const gbk = new TextDecoder("gbk").decode(buf);
|
||||||
|
if (!gbk.includes("�")) return gbk;
|
||||||
|
} catch {
|
||||||
|
// expected - fall through to lossy UTF-8
|
||||||
|
}
|
||||||
|
return buf.toString("utf8");
|
||||||
|
}
|
||||||
|
|
||||||
/** Runs one non-interactive command over an existing ssh2 client. */
|
/** Runs one non-interactive command over an existing ssh2 client. */
|
||||||
export function execCommand(
|
export function execCommand(
|
||||||
client: Client,
|
client: Client,
|
||||||
@@ -65,8 +89,10 @@ export function execCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
stream = _stream;
|
stream = _stream;
|
||||||
let stdout = "";
|
// Chunks are buffered and decoded once on close: a multi-byte character
|
||||||
let stderr = "";
|
// can be split across chunks, and charset detection needs it whole.
|
||||||
|
const stdout: Buffer[] = [];
|
||||||
|
const stderr: Buffer[] = [];
|
||||||
let exitCode: number | null = null;
|
let exitCode: number | null = null;
|
||||||
|
|
||||||
stream
|
stream
|
||||||
@@ -75,11 +101,15 @@ export function execCommand(
|
|||||||
settled = true;
|
settled = true;
|
||||||
exitCode = typeof code === "number" ? code : null;
|
exitCode = typeof code === "number" ? code : null;
|
||||||
cleanup();
|
cleanup();
|
||||||
resolve({ stdout, stderr, code: exitCode });
|
resolve({
|
||||||
|
stdout: decodeCommandOutput(Buffer.concat(stdout)),
|
||||||
|
stderr: decodeCommandOutput(Buffer.concat(stderr)),
|
||||||
|
code: exitCode,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.on("data", (data: Buffer) => {
|
.on("data", (data: Buffer) => {
|
||||||
stdout += data.toString("utf8");
|
stdout.push(data);
|
||||||
})
|
})
|
||||||
.on("error", (streamErr: Error) => {
|
.on("error", (streamErr: Error) => {
|
||||||
if (!settled) {
|
if (!settled) {
|
||||||
@@ -92,7 +122,7 @@ export function execCommand(
|
|||||||
if (stream.stderr) {
|
if (stream.stderr) {
|
||||||
stream.stderr
|
stream.stderr
|
||||||
.on("data", (data: Buffer) => {
|
.on("data", (data: Buffer) => {
|
||||||
stderr += data.toString("utf8");
|
stderr.push(data);
|
||||||
})
|
})
|
||||||
.on("error", (stderrErr: Error) => {
|
.on("error", (stderrErr: Error) => {
|
||||||
if (!settled) {
|
if (!settled) {
|
||||||
|
|||||||
@@ -12,6 +12,11 @@
|
|||||||
|
|
||||||
import semver from "semver";
|
import semver from "semver";
|
||||||
import { isKnownCapability } from "./capabilities.js";
|
import { isKnownCapability } from "./capabilities.js";
|
||||||
|
import {
|
||||||
|
checkOperationReferences,
|
||||||
|
validateOperations,
|
||||||
|
type PluginOperationsContribution,
|
||||||
|
} from "./operations.js";
|
||||||
import {
|
import {
|
||||||
ENV_NAME_PATTERN,
|
ENV_NAME_PATTERN,
|
||||||
MAX_ENV_DESCRIPTION_LENGTH,
|
MAX_ENV_DESCRIPTION_LENGTH,
|
||||||
@@ -37,7 +42,7 @@ export {
|
|||||||
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
|
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
|
||||||
* version by the server.
|
* version by the server.
|
||||||
*/
|
*/
|
||||||
export const PLUGIN_API_VERSION = "1.12.0";
|
export const PLUGIN_API_VERSION = "1.14.0";
|
||||||
|
|
||||||
/** The API major, the default engine.api for a new plugin. */
|
/** The API major, the default engine.api for a new plugin. */
|
||||||
export const SUPPORTED_PLUGIN_API_VERSION = String(
|
export const SUPPORTED_PLUGIN_API_VERSION = String(
|
||||||
@@ -465,6 +470,10 @@ export interface PluginContributions {
|
|||||||
tabs?: PluginTabContribution[];
|
tabs?: PluginTabContribution[];
|
||||||
panels?: PluginViewContribution[];
|
panels?: PluginViewContribution[];
|
||||||
dashboardCards?: PluginViewContribution[];
|
dashboardCards?: PluginViewContribution[];
|
||||||
|
/** Cards on the host page, filled with app.registerHostSection. */
|
||||||
|
hostSections?: PluginViewContribution[];
|
||||||
|
/** What this plugin can do, for every surface to find on its own. */
|
||||||
|
operations?: PluginOperationsContribution;
|
||||||
/**
|
/**
|
||||||
* The frontend also runs on anonymous guest pages (shared-session and
|
* The frontend also runs on anonymous guest pages (shared-session and
|
||||||
* collab links), where it is activated with app.guest set.
|
* collab links), where it is activated with app.guest set.
|
||||||
@@ -526,6 +535,36 @@ export interface PluginContributions {
|
|||||||
layouts?: PluginLayoutContribution[];
|
layouts?: PluginLayoutContribution[];
|
||||||
/** Themes. Needs the ui:theme capability. */
|
/** Themes. Needs the ui:theme capability. */
|
||||||
themes?: PluginThemeContribution[];
|
themes?: PluginThemeContribution[];
|
||||||
|
/** How the plugin shows in the Termix mobile app. Plugin API 1.13. */
|
||||||
|
mobile?: PluginMobileContribution;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - "webview": the app shows the plugin's UI from the server in a WebView.
|
||||||
|
* - "native": the app has its own screens for this plugin.
|
||||||
|
* - "none": nothing to show in the app.
|
||||||
|
*/
|
||||||
|
export const MOBILE_MODES = ["webview", "native", "none"] as const;
|
||||||
|
export type PluginMobileMode = (typeof MOBILE_MODES)[number];
|
||||||
|
|
||||||
|
export const MOBILE_SIZES = ["phone", "tablet"] as const;
|
||||||
|
export type PluginMobileSize = (typeof MOBILE_SIZES)[number];
|
||||||
|
|
||||||
|
export interface PluginMobileContribution {
|
||||||
|
mode: PluginMobileMode;
|
||||||
|
/**
|
||||||
|
* webview: a bundle built for the app, in the same folder as the frontend
|
||||||
|
* bundle. Unset means the normal frontend runs in a phone sized frame.
|
||||||
|
*/
|
||||||
|
entry?: string;
|
||||||
|
/** webview: screen sizes the UI works at. Defaults to both. */
|
||||||
|
sizes?: PluginMobileSize[];
|
||||||
|
/** webview: tab, panel or card ids the app offers. Defaults to every tab. */
|
||||||
|
views?: string[];
|
||||||
|
/** native: the app feature that stands in for it. Defaults to the plugin id. */
|
||||||
|
feature?: string;
|
||||||
|
/** i18n key in the plugin's namespace, or plain text, shown in the app. */
|
||||||
|
note?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A field a protocol login carries besides username and password. */
|
/** A field a protocol login carries besides username and password. */
|
||||||
@@ -749,6 +788,8 @@ const ALLOWED_CONTRIBUTES = new Set([
|
|||||||
"tabs",
|
"tabs",
|
||||||
"panels",
|
"panels",
|
||||||
"dashboardCards",
|
"dashboardCards",
|
||||||
|
"hostSections",
|
||||||
|
"operations",
|
||||||
"guest",
|
"guest",
|
||||||
"guestViews",
|
"guestViews",
|
||||||
"actions",
|
"actions",
|
||||||
@@ -765,6 +806,7 @@ const ALLOWED_CONTRIBUTES = new Set([
|
|||||||
"protocols",
|
"protocols",
|
||||||
"layouts",
|
"layouts",
|
||||||
"themes",
|
"themes",
|
||||||
|
"mobile",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const ALLOWED_SETTINGS_FIELD = [
|
const ALLOWED_SETTINGS_FIELD = [
|
||||||
@@ -946,10 +988,135 @@ export function validateManifest(manifest: unknown): string[] {
|
|||||||
typeof m.id === "string" ? m.id : undefined,
|
typeof m.id === "string" ? m.id : undefined,
|
||||||
errors,
|
errors,
|
||||||
);
|
);
|
||||||
|
if (isPlainObject(m.contributes)) {
|
||||||
|
validateMobile(
|
||||||
|
m.contributes,
|
||||||
|
typeof m.frontend === "string" ? m.frontend : DEFAULT_FRONTEND_ENTRY,
|
||||||
|
errors,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function folderOf(file: string): string {
|
||||||
|
const parts = file.split("/").filter((part) => part !== "" && part !== ".");
|
||||||
|
return parts.slice(0, -1).join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateMobile(
|
||||||
|
contributes: Record<string, unknown>,
|
||||||
|
frontend: string,
|
||||||
|
errors: string[],
|
||||||
|
): void {
|
||||||
|
const mobile = contributes.mobile;
|
||||||
|
if (mobile === undefined) return;
|
||||||
|
const where = "contributes.mobile";
|
||||||
|
if (!isPlainObject(mobile)) {
|
||||||
|
errors.push(`Field "${where}" must be an object`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rejectUnknown(
|
||||||
|
mobile,
|
||||||
|
["mode", "entry", "sizes", "views", "feature", "note"],
|
||||||
|
`"${where}"`,
|
||||||
|
errors,
|
||||||
|
);
|
||||||
|
const mode = mobile.mode;
|
||||||
|
if (!(MOBILE_MODES as readonly unknown[]).includes(mode)) {
|
||||||
|
errors.push(`${where}.mode must be one of: ${MOBILE_MODES.join(", ")}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const key of ["entry", "sizes", "views"]) {
|
||||||
|
if (mobile[key] !== undefined && mode !== "webview") {
|
||||||
|
errors.push(`${where}.${key} is only for mode "webview"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mobile.feature !== undefined && mode !== "native") {
|
||||||
|
errors.push(`${where}.feature is only for mode "native"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
mobile.entry !== undefined &&
|
||||||
|
requireString(mobile.entry, `${where}.entry`, errors)
|
||||||
|
) {
|
||||||
|
const before = errors.length;
|
||||||
|
requireRelativePath(mobile.entry, `${where}.entry`, errors);
|
||||||
|
if (!mobile.entry.endsWith(".js")) {
|
||||||
|
errors.push(`${where}.entry must be a .js file`);
|
||||||
|
} else if (
|
||||||
|
errors.length === before &&
|
||||||
|
folderOf(mobile.entry) !== folderOf(frontend)
|
||||||
|
) {
|
||||||
|
// Core only serves the folder the frontend bundle sits in.
|
||||||
|
errors.push(
|
||||||
|
`${where}.entry must sit in the same folder as the frontend bundle (${frontend})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mobile.sizes !== undefined) {
|
||||||
|
const sizes = mobile.sizes;
|
||||||
|
if (!Array.isArray(sizes) || sizes.length === 0) {
|
||||||
|
errors.push(`${where}.sizes must be a non-empty array`);
|
||||||
|
} else {
|
||||||
|
const seen = new Set<unknown>();
|
||||||
|
for (const size of sizes) {
|
||||||
|
if (!(MOBILE_SIZES as readonly unknown[]).includes(size)) {
|
||||||
|
errors.push(
|
||||||
|
`${where}.sizes entries must be one of: ${MOBILE_SIZES.join(", ")}`,
|
||||||
|
);
|
||||||
|
} else if (seen.has(size)) {
|
||||||
|
errors.push(`${where}.sizes duplicates "${String(size)}"`);
|
||||||
|
}
|
||||||
|
seen.add(size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mobile.views !== undefined) {
|
||||||
|
const views = mobile.views;
|
||||||
|
if (!Array.isArray(views) || views.length === 0) {
|
||||||
|
errors.push(`${where}.views must be a non-empty array`);
|
||||||
|
} else {
|
||||||
|
const declared = new Set<string>();
|
||||||
|
for (const field of ["tabs", "panels", "dashboardCards"]) {
|
||||||
|
const list = contributes[field];
|
||||||
|
if (!Array.isArray(list)) continue;
|
||||||
|
for (const view of list) {
|
||||||
|
if (isPlainObject(view) && typeof view.id === "string") {
|
||||||
|
declared.add(view.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const seen = new Set<unknown>();
|
||||||
|
for (const view of views) {
|
||||||
|
if (typeof view !== "string" || !declared.has(view)) {
|
||||||
|
errors.push(
|
||||||
|
`${where}.views entries must be ids from contributes.tabs, panels or dashboardCards, got: ${JSON.stringify(view)}`,
|
||||||
|
);
|
||||||
|
} else if (seen.has(view)) {
|
||||||
|
errors.push(`${where}.views duplicates "${view}"`);
|
||||||
|
}
|
||||||
|
seen.add(view);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
mobile.feature !== undefined &&
|
||||||
|
(typeof mobile.feature !== "string" ||
|
||||||
|
!LOOK_ID_PATTERN.test(mobile.feature))
|
||||||
|
) {
|
||||||
|
errors.push(
|
||||||
|
`${where}.feature must match ${LOOK_ID_PATTERN} (lowercase letters, digits, dashes)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (mobile.note !== undefined) {
|
||||||
|
requireString(mobile.note, `${where}.note`, errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function validateFeatures(features: unknown, errors: string[]): void {
|
function validateFeatures(features: unknown, errors: string[]): void {
|
||||||
if (features === undefined) return;
|
if (features === undefined) return;
|
||||||
if (!Array.isArray(features)) {
|
if (!Array.isArray(features)) {
|
||||||
@@ -1344,6 +1511,8 @@ function validateContributes(
|
|||||||
validateTabs(contributes.tabs, errors);
|
validateTabs(contributes.tabs, errors);
|
||||||
validateViews(contributes.panels, "panels", errors);
|
validateViews(contributes.panels, "panels", errors);
|
||||||
validateViews(contributes.dashboardCards, "dashboardCards", errors);
|
validateViews(contributes.dashboardCards, "dashboardCards", errors);
|
||||||
|
validateViews(contributes.hostSections, "hostSections", errors);
|
||||||
|
validateOperations(contributes.operations, pluginId, errors);
|
||||||
if (
|
if (
|
||||||
contributes.guest !== undefined &&
|
contributes.guest !== undefined &&
|
||||||
typeof contributes.guest !== "boolean"
|
typeof contributes.guest !== "boolean"
|
||||||
@@ -2466,6 +2635,12 @@ export function parseManifest(raw: unknown): ParsedManifest {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
checkOperationReferences(
|
||||||
|
manifest.contributes?.operations,
|
||||||
|
manifest.capabilities,
|
||||||
|
(name) => declared.has(qualifyPermission(manifest.id, name)),
|
||||||
|
errors,
|
||||||
|
);
|
||||||
// A settings field may gate on one of this plugin's own permissions, or on
|
// A settings field may gate on one of this plugin's own permissions, or on
|
||||||
// a core/other-plugin id used as given. Only the first form is checkable
|
// a core/other-plugin id used as given. Only the first form is checkable
|
||||||
// here, and an undeclared one would hide the field from every admin.
|
// here, and an undeclared one would hide the field from every admin.
|
||||||
|
|||||||
@@ -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,
|
PluginSshKeyCredential,
|
||||||
PluginSshKeyCredentialInput,
|
PluginSshKeyCredentialInput,
|
||||||
} from "./backend.js";
|
} from "./backend.js";
|
||||||
import type { PluginSyncHost, SyncEntityRegistration } from "./backend.js";
|
import type {
|
||||||
|
PluginOperationHandler,
|
||||||
|
PluginSyncHost,
|
||||||
|
SyncEntityRegistration,
|
||||||
|
} from "./backend.js";
|
||||||
import type { PluginDatabase } from "./backend.js";
|
import type { PluginDatabase } from "./backend.js";
|
||||||
import type {
|
import type {
|
||||||
PluginExternalClientRequest,
|
PluginExternalClientRequest,
|
||||||
@@ -355,6 +359,10 @@ export interface FakePluginContext {
|
|||||||
}>;
|
}>;
|
||||||
/** The hub the plugin registered with ctx.notify.serve, if any. */
|
/** The hub the plugin registered with ctx.notify.serve, if any. */
|
||||||
notifyHub: { current: PluginNotifyHub | null };
|
notifyHub: { current: PluginNotifyHub | null };
|
||||||
|
/** Handlers from ctx.operations.register, by action id. */
|
||||||
|
operationHandlers: Map<string, PluginOperationHandler>;
|
||||||
|
/** Every ctx.operations.emit call. */
|
||||||
|
operationEvents: { eventId: string; payload: unknown; hostId?: number }[];
|
||||||
/** Backing store behind ctx.secrets.get/set, keyed "<userId>:<key>". */
|
/** Backing store behind ctx.secrets.get/set, keyed "<userId>:<key>". */
|
||||||
secretStore: Map<string, string>;
|
secretStore: Map<string, string>;
|
||||||
/** Every ctx.fetch call, in order. */
|
/** Every ctx.fetch call, in order. */
|
||||||
@@ -566,6 +574,8 @@ export function createFakeContext(
|
|||||||
const createdSshKeys: FakePluginContext["createdSshKeys"] = [];
|
const createdSshKeys: FakePluginContext["createdSshKeys"] = [];
|
||||||
const notifications: FakePluginContext["notifications"] = [];
|
const notifications: FakePluginContext["notifications"] = [];
|
||||||
const notifyHub: FakePluginContext["notifyHub"] = { current: null };
|
const notifyHub: FakePluginContext["notifyHub"] = { current: null };
|
||||||
|
const operationHandlers: FakePluginContext["operationHandlers"] = new Map();
|
||||||
|
const operationEvents: FakePluginContext["operationEvents"] = [];
|
||||||
const fetches: FakePluginContext["fetches"] = [];
|
const fetches: FakePluginContext["fetches"] = [];
|
||||||
const processRuns: FakePluginContext["processRuns"] = [];
|
const processRuns: FakePluginContext["processRuns"] = [];
|
||||||
const binaries: FakePluginContext["binaries"] = [];
|
const binaries: FakePluginContext["binaries"] = [];
|
||||||
@@ -1391,6 +1401,46 @@ export function createFakeContext(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
operations: {
|
||||||
|
register: (id, handler) => {
|
||||||
|
operationHandlers.set(id, handler);
|
||||||
|
},
|
||||||
|
run: async (actionId, options = {}) => {
|
||||||
|
const handler = operationHandlers.get(actionId);
|
||||||
|
if (!handler) throw new Error(`No action "${actionId}"`);
|
||||||
|
const result = await handler.run(options.input ?? {}, {
|
||||||
|
hostId: options.hostId,
|
||||||
|
resourceKey: options.resourceKey,
|
||||||
|
runId: null,
|
||||||
|
dryRun: options.dryRun === true,
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
log: () => {},
|
||||||
|
});
|
||||||
|
return { runId: null, status: "success", ...result };
|
||||||
|
},
|
||||||
|
invoke: async (actionId, options = {}) => {
|
||||||
|
const handler = operationHandlers.get(actionId);
|
||||||
|
if (!handler) throw new Error(`No action "${actionId}"`);
|
||||||
|
const result = await handler.run(options.input ?? {}, {
|
||||||
|
hostId: options.hostId,
|
||||||
|
resourceKey: options.resourceKey,
|
||||||
|
runId: null,
|
||||||
|
dryRun: options.dryRun === true,
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
log: () => {},
|
||||||
|
});
|
||||||
|
return { runId: null, status: "success", ...result };
|
||||||
|
},
|
||||||
|
list: async () => ({
|
||||||
|
actions: manifest.contributes?.operations?.actions ?? [],
|
||||||
|
}),
|
||||||
|
emit: async (eventId, payload, options) => {
|
||||||
|
operationEvents.push({ eventId, payload, hostId: options?.hostId });
|
||||||
|
},
|
||||||
|
onEvent: () => () => {},
|
||||||
|
approvals: { provide: () => () => {} },
|
||||||
|
},
|
||||||
|
|
||||||
notify: {
|
notify: {
|
||||||
channels: async () => {
|
channels: async () => {
|
||||||
if (!actor) throw new Error("ctx.notify needs an acting user");
|
if (!actor) throw new Error("ctx.notify needs an acting user");
|
||||||
@@ -1554,6 +1604,8 @@ export function createFakeContext(
|
|||||||
createdSshKeys,
|
createdSshKeys,
|
||||||
notifications,
|
notifications,
|
||||||
notifyHub,
|
notifyHub,
|
||||||
|
operationHandlers,
|
||||||
|
operationEvents,
|
||||||
fetches,
|
fetches,
|
||||||
processRuns,
|
processRuns,
|
||||||
binaries,
|
binaries,
|
||||||
|
|||||||
@@ -159,8 +159,10 @@ HostProtocolAuthInput (type): HostProtocolAuthInput
|
|||||||
HostProtocolDef (type): HostProtocolDef
|
HostProtocolDef (type): HostProtocolDef
|
||||||
HostProtocols (type): HostProtocols
|
HostProtocols (type): HostProtocols
|
||||||
HostRowFields (type): HostRowFields
|
HostRowFields (type): HostRowFields
|
||||||
|
HostSectionDef (type): HostSectionDef
|
||||||
HostSidebarFilterState (type): HostSidebarFilterState
|
HostSidebarFilterState (type): HostSidebarFilterState
|
||||||
HostSidebarPreferences (type): HostSidebarPreferences
|
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
|
HostTrayTrigger (type): HostTrayTrigger
|
||||||
HttpsSettings (type): HttpsSettings
|
HttpsSettings (type): HttpsSettings
|
||||||
IconPicker (value): ({ value, color, onChange, }: { value: string; color?: string; onChange: (icon: string) => void; }) => Element
|
IconPicker (value): ({ value, color, onChange, }: { value: string; color?: string; onChange: (icon: string) => void; }) => Element
|
||||||
@@ -354,7 +356,7 @@ changePassword (value): (oldPassword: string, newPassword: string) => Promise<an
|
|||||||
clearLocalAdaptivePreferences (value): () => void
|
clearLocalAdaptivePreferences (value): () => void
|
||||||
cn (value): (...inputs: ClassValue[]) => string
|
cn (value): (...inputs: ClassValue[]) => string
|
||||||
copyToClipboard (value): (text: string) => Promise<boolean>
|
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>>
|
createCredential (value): (credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
|
||||||
createFrontendLogger (value): (serviceName: string, serviceIcon?: string, serviceColor?: string) => FrontendLogger
|
createFrontendLogger (value): (serviceName: string, serviceIcon?: string, serviceColor?: string) => FrontendLogger
|
||||||
createQuickConnectHost (value): (input: QuickConnectInput) => Host
|
createQuickConnectHost (value): (input: QuickConnectInput) => Host
|
||||||
@@ -403,6 +405,7 @@ getDeviceId (value): () => string
|
|||||||
getErrorMessage (value): (error: unknown, fallback?: string) => string
|
getErrorMessage (value): (error: unknown, fallback?: string) => string
|
||||||
getExternalAutoProvision (value): () => Promise<{ enabled: boolean; }>
|
getExternalAutoProvision (value): () => Promise<{ enabled: boolean; }>
|
||||||
getFolderAccess (value): (folder: string) => Promise<{ rules: FolderAccessRule[]; }>
|
getFolderAccess (value): (folder: string) => Promise<{ rules: FolderAccessRule[]; }>
|
||||||
|
getFolderAuthOverride (value): (folder: string, protocol: string, sharedCopySyncId?: string) => Promise<{ folder: string; protocol: string; credentialId: number; }>
|
||||||
getFolderDefaultsId (value): (name: string) => Promise<number>
|
getFolderDefaultsId (value): (name: string) => Promise<number>
|
||||||
getHostAccess (value): (hostId: number, syncId?: string) => Promise<{ accessList: AccessRecord[]; isOwner?: boolean; }>
|
getHostAccess (value): (hostId: number, syncId?: string) => Promise<{ accessList: AccessRecord[]; isOwner?: boolean; }>
|
||||||
getHostAuthOverride (value): (hostId: number, protocol: string, sharedCopySyncId?: string) => Promise<{ protocol: string; credentialId: number; }>
|
getHostAuthOverride (value): (hostId: number, protocol: string, sharedCopySyncId?: string) => Promise<{ protocol: string; credentialId: number; }>
|
||||||
@@ -456,6 +459,7 @@ hostBadgesFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[]
|
|||||||
hostEditorToolbarItemsFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[]
|
hostEditorToolbarItemsFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[]
|
||||||
hostMenuItemsFor (value): (all: HostContextMenuItemDef[], host: Host) => HostContextMenuItemDef[]
|
hostMenuItemsFor (value): (all: HostContextMenuItemDef[], host: Host) => HostContextMenuItemDef[]
|
||||||
hostProtocolFlags (value): (host: { enableSsh?: boolean; pluginSettings?: Record<string, Record<string, unknown>>; }, list?: HostProtocolDef[]) => HostProtocols
|
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[]
|
hostStatusIndicatorsFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[]
|
||||||
hydrateLocalSharedHostAuth (value): <T>(host: T) => Promise<T>
|
hydrateLocalSharedHostAuth (value): <T>(host: T) => Promise<T>
|
||||||
invalidateHostsAndStatusCaches (value): () => void
|
invalidateHostsAndStatusCaches (value): () => void
|
||||||
@@ -474,6 +478,7 @@ linkExternalToPasswordAccount (value): (externalUserId: string, targetUsername:
|
|||||||
linkedServerUrl (value): () => Promise<string>
|
linkedServerUrl (value): () => Promise<string>
|
||||||
listHostActions (value): () => HostActionDef[]
|
listHostActions (value): () => HostActionDef[]
|
||||||
listHostProtocols (value): () => HostProtocolDef[]
|
listHostProtocols (value): () => HostProtocolDef[]
|
||||||
|
listHostSections (value): () => HostSectionDef[]
|
||||||
loadPaletteGroup (value): (group: PaletteGroupDef) => Promise<PaletteItemDef[]>
|
loadPaletteGroup (value): (group: PaletteGroupDef) => Promise<PaletteItemDef[]>
|
||||||
logoutUser (value): () => Promise<{ success: boolean; message: string; }>
|
logoutUser (value): () => Promise<{ success: boolean; message: string; }>
|
||||||
makeHostSshSubTabs (value): (t: (key: string, options?: { defaultValue?: string; }) => string, protocols?: Record<string, boolean>, defaultsMode?: boolean) => HostTab[]
|
makeHostSshSubTabs (value): (t: (key: string, options?: { defaultValue?: string; }) => string, protocols?: Record<string, boolean>, defaultsMode?: boolean) => HostTab[]
|
||||||
@@ -487,6 +492,7 @@ moveSplitDrag (value): (x: number, y: number) => void
|
|||||||
normalizeLanguageCode (value): (language?: string) => string
|
normalizeLanguageCode (value): (language?: string) => string
|
||||||
notifySyncChanged (value): () => void
|
notifySyncChanged (value): () => void
|
||||||
openInSplit (value): (target: SplitOpenTarget, open: () => void) => void
|
openInSplit (value): (target: SplitOpenTarget, open: () => void) => void
|
||||||
|
operationsApi (value): AxiosInstance
|
||||||
paletteEntriesFor (value): (all: PaletteEntryDef[], scope: "global" | "host", host?: Host) => PaletteEntryDef[]
|
paletteEntriesFor (value): (all: PaletteEntryDef[], scope: "global" | "host", host?: Host) => PaletteEntryDef[]
|
||||||
parseCustomKeybindings (value): (raw?: string) => CustomKeybinding[]
|
parseCustomKeybindings (value): (raw?: string) => CustomKeybinding[]
|
||||||
parseServerTime (value): (ts: string) => Date
|
parseServerTime (value): (ts: string) => Date
|
||||||
@@ -526,7 +532,7 @@ resolveHostTabType (value): (host: Host, preferredType?: TabType) => TabType
|
|||||||
resolveRemoteHostId (value): (syncId: string) => Promise<number>
|
resolveRemoteHostId (value): (syncId: string) => Promise<number>
|
||||||
revokeAllUserSessions (value): (userId: string) => Promise<{ success: boolean; message: string; }>
|
revokeAllUserSessions (value): (userId: string) => Promise<{ success: boolean; message: string; }>
|
||||||
revokeCredentialAccess (value): (credentialId: number, accessId: number) => Promise<void>
|
revokeCredentialAccess (value): (credentialId: number, accessId: number) => Promise<void>
|
||||||
revokeFolderAccess (value): (ruleId: number) => Promise<void>
|
revokeFolderAccess (value): (ruleId: number, options?: { fromHosts?: boolean; }) => Promise<void>
|
||||||
revokeHostAccess (value): (hostId: number, accessId: number, syncId?: string) => Promise<{ success: boolean; }>
|
revokeHostAccess (value): (hostId: number, accessId: number, syncId?: string) => Promise<{ success: boolean; }>
|
||||||
revokeSession (value): (sessionId: string) => Promise<{ success: boolean; message: string; }>
|
revokeSession (value): (sessionId: string) => Promise<{ success: boolean; message: string; }>
|
||||||
rightDockableIds (value): () => string[]
|
rightDockableIds (value): () => string[]
|
||||||
@@ -544,6 +550,7 @@ saveHostTags (value): (tags: string[]) => Promise<string[]>
|
|||||||
saveUserPreferences (value): (prefs: Partial<UserPreferences>) => Promise<void>
|
saveUserPreferences (value): (prefs: Partial<UserPreferences>) => Promise<void>
|
||||||
setAllPluginChannels (value): (channel: PluginChannel) => Promise<{ changed: string[]; }>
|
setAllPluginChannels (value): (channel: PluginChannel) => Promise<{ changed: string[]; }>
|
||||||
setDeveloperMode (value): (enabled: boolean) => Promise<void>
|
setDeveloperMode (value): (enabled: boolean) => Promise<void>
|
||||||
|
setFolderAuthOverride (value): (folder: string, protocol: string, credentialId: number, sharedCopySyncId?: string) => Promise<{ success: boolean; folder: string; protocol: string; credentialId: number; }>
|
||||||
setHostAuthOverride (value): (hostId: number, protocol: string, credentialId: number, sharedCopySyncId?: string) => Promise<{ success: boolean; protocol: string; credentialId: number; }>
|
setHostAuthOverride (value): (hostId: number, protocol: string, credentialId: number, sharedCopySyncId?: string) => Promise<{ success: boolean; protocol: string; credentialId: number; }>
|
||||||
setNotificationPrivateEndpoints (value): (hosts: string[]) => Promise<string[]>
|
setNotificationPrivateEndpoints (value): (hosts: string[]) => Promise<string[]>
|
||||||
setPluginOptions (value): (pluginId: string, options: { autoUpdate?: boolean; pinned?: boolean; channel?: PluginChannel; }) => Promise<{ autoUpdate: boolean; pinnedVersion: string; channel: PluginChannel; }>
|
setPluginOptions (value): (pluginId: string, options: { autoUpdate?: boolean; pinned?: boolean; channel?: PluginChannel; }) => Promise<{ autoUpdate: boolean; pinnedVersion: string; channel: PluginChannel; }>
|
||||||
@@ -596,6 +603,7 @@ useHostContextMenuItems (value): () => HostContextMenuItemDef[]
|
|||||||
useHostEditorSections (value): () => HostEditorSectionDef[]
|
useHostEditorSections (value): () => HostEditorSectionDef[]
|
||||||
useHostEditorToolbarItems (value): () => HostBadgeDef[]
|
useHostEditorToolbarItems (value): () => HostBadgeDef[]
|
||||||
useHostProtocols (value): () => HostProtocolDef[]
|
useHostProtocols (value): () => HostProtocolDef[]
|
||||||
|
useHostSections (value): () => HostSectionDef[]
|
||||||
useHostStatus (value): (hostId: number, statusCheckEnabled?: boolean) => StatusValue
|
useHostStatus (value): (hostId: number, statusCheckEnabled?: boolean) => StatusValue
|
||||||
useHostStatusIndicators (value): () => HostBadgeDef[]
|
useHostStatusIndicators (value): () => HostBadgeDef[]
|
||||||
useInstalledPlugins (value): () => PluginSummary[]
|
useInstalledPlugins (value): () => PluginSummary[]
|
||||||
|
|||||||
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 |
@@ -1,9 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Builds the two pieces of the plugin SDK that come from core:
|
* Builds the pieces of the plugin SDK that come from core:
|
||||||
*
|
*
|
||||||
* dist/host/ui.js @termix-ssh/plugin-sdk/ui, the shell's UI kit
|
* dist/host/ui.js @termix-ssh/plugin-sdk/ui, the shell's UI kit
|
||||||
* dist/host/testing-host.js what renderWithApp() renders plugins into
|
* dist/host/testing-host.js what renderWithApp() renders plugins into
|
||||||
* dist/host/core-classes.json the classes core's CSS has rules for
|
* dist/host/core-classes.json the classes core's CSS has rules for
|
||||||
|
* dist/host/frame-host.js runs one plugin outside the shell (mobile app)
|
||||||
|
* dist/host/frame-host.css core's compiled CSS for that frame
|
||||||
*
|
*
|
||||||
* Inside Termix the page's import map points @termix-ssh/plugin-sdk/ui at the
|
* Inside Termix the page's import map points @termix-ssh/plugin-sdk/ui at the
|
||||||
* shell's own copy, so ui.js is never loaded there. It exists so a plugin in
|
* shell's own copy, so ui.js is never loaded there. It exists so a plugin in
|
||||||
@@ -46,6 +48,7 @@ await esbuild.build({
|
|||||||
entryPoints: {
|
entryPoints: {
|
||||||
ui: path.join(root, "src/ui/plugin-host/sdk-ui.ts"),
|
ui: path.join(root, "src/ui/plugin-host/sdk-ui.ts"),
|
||||||
"testing-host": path.join(root, "src/ui/plugin-host/testing-host.tsx"),
|
"testing-host": path.join(root, "src/ui/plugin-host/testing-host.tsx"),
|
||||||
|
"frame-host": path.join(root, "src/ui/plugin-host/frame-host.tsx"),
|
||||||
},
|
},
|
||||||
outdir: outDir,
|
outdir: outDir,
|
||||||
bundle: true,
|
bundle: true,
|
||||||
@@ -72,6 +75,9 @@ await esbuild.build({
|
|||||||
// so the translated locale files it would list are simply none.
|
// so the translated locale files it would list are simply none.
|
||||||
banner: { js: "const __termixNoGlob = () => ({});" },
|
banner: { js: "const __termixNoGlob = () => ({});" },
|
||||||
define: {
|
define: {
|
||||||
|
// esbuild picks "development" for an unminified build, which sends core's
|
||||||
|
// requests to the Vite dev proxy on any server without a port.
|
||||||
|
"process.env.NODE_ENV": '"production"',
|
||||||
"import.meta.glob": "__termixNoGlob",
|
"import.meta.glob": "__termixNoGlob",
|
||||||
"import.meta.env.DEV": "false",
|
"import.meta.env.DEV": "false",
|
||||||
"import.meta.env.PROD": "true",
|
"import.meta.env.PROD": "true",
|
||||||
@@ -157,14 +163,16 @@ const rootSource =
|
|||||||
const scanner = new Scanner({
|
const scanner = new Scanner({
|
||||||
sources: [...rootSource, ...compiler.sources],
|
sources: [...rootSource, ...compiler.sources],
|
||||||
});
|
});
|
||||||
const coreClasses = [
|
const coreCss = optimize(compiler.build(scanner.scan()), {
|
||||||
...classNamesInCss(
|
minify: true,
|
||||||
optimize(compiler.build(scanner.scan()), { minify: true }).code,
|
}).code;
|
||||||
),
|
const coreClasses = [...classNamesInCss(coreCss)].sort();
|
||||||
].sort();
|
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(outDir, "core-classes.json"),
|
path.join(outDir, "core-classes.json"),
|
||||||
`${JSON.stringify(coreClasses)}\n`,
|
`${JSON.stringify(coreClasses)}\n`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The frame host has no Vite build to load core's CSS, so it gets it here.
|
||||||
|
fs.writeFileSync(path.join(outDir, "frame-host.css"), coreCss);
|
||||||
|
|
||||||
console.log(`Built the SDK host modules into ${path.relative(root, outDir)}`);
|
console.log(`Built the SDK host modules into ${path.relative(root, outDir)}`);
|
||||||
@@ -537,6 +537,38 @@ describe("default look", () => {
|
|||||||
).toMatchObject({ id: "look", defaultLook: true });
|
).toMatchObject({ id: "look", defaultLook: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("leaves out new plugins with no usable release when pinning all", () => {
|
||||||
|
const stable = {
|
||||||
|
version: "1.0.0",
|
||||||
|
url: "https://x/a.tmxplug",
|
||||||
|
sha256: SHA,
|
||||||
|
};
|
||||||
|
const index = {
|
||||||
|
plugins: [
|
||||||
|
{ id: "ready", versions: [stable] },
|
||||||
|
{
|
||||||
|
id: "beta-only",
|
||||||
|
versions: [],
|
||||||
|
prereleases: [{ ...stable, version: "1.0.0-beta.1" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "too-new",
|
||||||
|
versions: [],
|
||||||
|
gated: [{ ...stable, engine: { termix: ">=99.0.0" } }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
pinsFromIndex(index, null, [], "26.11.1").map(
|
||||||
|
(pin: { id: string }) => pin.id,
|
||||||
|
),
|
||||||
|
).toEqual(["ready"]);
|
||||||
|
expect(() =>
|
||||||
|
pinsFromIndex(index, null, [{ id: "beta-only" }], "26.11.1"),
|
||||||
|
).toThrow(/beta-only/);
|
||||||
|
expect(() => pinsFromIndex(index, ["beta-only"])).toThrow(/beta-only/);
|
||||||
|
});
|
||||||
|
|
||||||
it("copies a local build's theme files", () => {
|
it("copies a local build's theme files", () => {
|
||||||
const repo = tempDir();
|
const repo = tempDir();
|
||||||
fs.mkdirSync(path.join(repo, "dist"), { recursive: true });
|
fs.mkdirSync(path.join(repo, "dist"), { recursive: true });
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ const SDK = path.join(ROOT, "packages", "plugin-sdk");
|
|||||||
const INTERNAL_EXPORTS = new Set([
|
const INTERNAL_EXPORTS = new Set([
|
||||||
"./testing/setup",
|
"./testing/setup",
|
||||||
"./testing-host",
|
"./testing-host",
|
||||||
|
"./frame-host",
|
||||||
|
"./frame-host.css",
|
||||||
"./tsconfig.plugin.json",
|
"./tsconfig.plugin.json",
|
||||||
"./schema/manifest.schema.json",
|
"./schema/manifest.schema.json",
|
||||||
"./theme.css",
|
"./theme.css",
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ const SDK_UI = path.join(ROOT, "src", "ui", "plugin-host", "sdk-ui.ts");
|
|||||||
const TSCONFIG = path.join(ROOT, "tsconfig.app.json");
|
const TSCONFIG = path.join(ROOT, "tsconfig.app.json");
|
||||||
|
|
||||||
const ALLOWED = [
|
const ALLOWED = [
|
||||||
|
"useHostSections",
|
||||||
|
"operationsApi",
|
||||||
|
"listHostSections",
|
||||||
|
"hostSectionsFor",
|
||||||
|
"HostTabEmbed",
|
||||||
|
"HostSectionDef",
|
||||||
"ACCENT_PRESET_COLORS",
|
"ACCENT_PRESET_COLORS",
|
||||||
"APP_VERSION",
|
"APP_VERSION",
|
||||||
"AccessRecord",
|
"AccessRecord",
|
||||||
@@ -426,6 +432,7 @@ const ALLOWED = [
|
|||||||
"getFolderAccess",
|
"getFolderAccess",
|
||||||
"getFolderDefaultsId",
|
"getFolderDefaultsId",
|
||||||
"getHostAccess",
|
"getHostAccess",
|
||||||
|
"getFolderAuthOverride",
|
||||||
"getHostAuthOverride",
|
"getHostAuthOverride",
|
||||||
"getHostDefaultsLevel",
|
"getHostDefaultsLevel",
|
||||||
"getHostEditorSection",
|
"getHostEditorSection",
|
||||||
@@ -565,6 +572,7 @@ const ALLOWED = [
|
|||||||
"saveUserPreferences",
|
"saveUserPreferences",
|
||||||
"setAllPluginChannels",
|
"setAllPluginChannels",
|
||||||
"setDeveloperMode",
|
"setDeveloperMode",
|
||||||
|
"setFolderAuthOverride",
|
||||||
"setHostAuthOverride",
|
"setHostAuthOverride",
|
||||||
"setNotificationPrivateEndpoints",
|
"setNotificationPrivateEndpoints",
|
||||||
"setPluginOptions",
|
"setPluginOptions",
|
||||||
|
|||||||
+31
-5
@@ -404,6 +404,7 @@ function watchBackend() {
|
|||||||
{ cwd: root, stdio: ["ignore", "pipe", "inherit"] },
|
{ cwd: root, stdio: ["ignore", "pipe", "inherit"] },
|
||||||
);
|
);
|
||||||
let first = true;
|
let first = true;
|
||||||
|
let restartQueued = false;
|
||||||
let buffer = "";
|
let buffer = "";
|
||||||
tsc.stdout.on("data", (chunk) => {
|
tsc.stdout.on("data", (chunk) => {
|
||||||
buffer += chunk.toString();
|
buffer += chunk.toString();
|
||||||
@@ -423,14 +424,21 @@ function watchBackend() {
|
|||||||
? "starting the backend"
|
? "starting the backend"
|
||||||
: "backend changed, restarting it";
|
: "backend changed, restarting it";
|
||||||
first = false;
|
first = false;
|
||||||
void queue(() => restartBackend(reason));
|
if (restartQueued) continue;
|
||||||
|
restartQueued = true;
|
||||||
|
void queue(() => {
|
||||||
|
restartQueued = false;
|
||||||
|
return restartBackend(reason);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function watchSdk() {
|
function watchSdk() {
|
||||||
|
let buildStarted = Date.now();
|
||||||
const changed = createDebouncer(300, () =>
|
const changed = createDebouncer(300, () =>
|
||||||
queue(async () => {
|
queue(async () => {
|
||||||
|
buildStarted = Date.now();
|
||||||
try {
|
try {
|
||||||
await run("npm", ["run", "build:sdk"]);
|
await run("npm", ["run", "build:sdk"]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -440,7 +448,16 @@ function watchSdk() {
|
|||||||
await restartBackend("SDK changed, restarting the backend");
|
await restartBackend("SDK changed, restarting the backend");
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
watch(SDK_SRC, true, () => changed("sdk"));
|
// Windows also reports reads (access time), so only a file saved after the last build counts
|
||||||
|
watch(SDK_SRC, true, (_event, filename) => {
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(path.join(SDK_SRC, String(filename ?? "")));
|
||||||
|
if (stat.isDirectory() || stat.mtimeMs < buildStarted) return;
|
||||||
|
} catch {
|
||||||
|
// deleted or renamed, still a change
|
||||||
|
}
|
||||||
|
changed("sdk");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function watchElectron() {
|
function watchElectron() {
|
||||||
@@ -580,9 +597,18 @@ function watchPlugins(dir) {
|
|||||||
queue(() => rebuildPlugin(byId.get(id))),
|
queue(() => rebuildPlugin(byId.get(id))),
|
||||||
);
|
);
|
||||||
for (const plugin of plugins) {
|
for (const plugin of plugins) {
|
||||||
watch(plugin.repo, true, (_event, filename) => {
|
// Only source folders, so node_modules and .git are never watched
|
||||||
if (isPluginEdit(plugin.repo, filename)) changed(plugin.id);
|
const onEvent = (prefix) => (_event, filename) => {
|
||||||
});
|
const name = prefix
|
||||||
|
? path.join(prefix, String(filename ?? ""))
|
||||||
|
: filename;
|
||||||
|
if (isPluginEdit(plugin.repo, name)) changed(plugin.id);
|
||||||
|
};
|
||||||
|
watch(plugin.repo, false, onEvent(""));
|
||||||
|
for (const sub of ["src", "locales", "migrations"]) {
|
||||||
|
const target = path.join(plugin.repo, sub);
|
||||||
|
if (fs.existsSync(target)) watch(target, true, onEvent(sub));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
log(`watching ${plugins.length} plugin(s) in ${dir}`);
|
log(`watching ${plugins.length} plugin(s) in ${dir}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,18 @@ function buildTable(version, mobileVersion, mobileTag) {
|
|||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildFullBuilds(version) {
|
||||||
|
const base = `https://github.com/Termix-SSH/Termix/releases/download/v${version}`;
|
||||||
|
const link = (label, file) => `[${label}](${base}/${file})`;
|
||||||
|
return [
|
||||||
|
"The builds above include the standard plugins. The full builds include every official plugin, which helps on a network with no internet. You can still turn plugins on or off in the Plugins tab. Full builds are x64 only (universal on Mac):",
|
||||||
|
"",
|
||||||
|
`- Windows: ${link("EXE", "termix_windows_x64_full_nsis.exe")}`,
|
||||||
|
`- Linux: ${link("AppImage", "termix_linux_x64_full_appimage.AppImage")} · ${link("DEB", "termix_linux_x64_full_deb.deb")}`,
|
||||||
|
`- Mac: ${link("DMG", "termix_macos_universal_full_dmg.dmg")}`,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
function buildBody({ version, mobileVersion, mobileTag, changelog }) {
|
function buildBody({ version, mobileVersion, mobileTag, changelog }) {
|
||||||
const notes = changelogSection(changelog, version);
|
const notes = changelogSection(changelog, version);
|
||||||
if (!notes) fail(`CHANGELOG.md has no notes for ${version}`);
|
if (!notes) fail(`CHANGELOG.md has no notes for ${version}`);
|
||||||
@@ -62,6 +74,8 @@ function buildBody({ version, mobileVersion, mobileTag, changelog }) {
|
|||||||
"### Downloads",
|
"### Downloads",
|
||||||
"",
|
"",
|
||||||
buildTable(version, mobileVersion, mobileTag),
|
buildTable(version, mobileVersion, mobileTag),
|
||||||
|
"",
|
||||||
|
buildFullBuilds(version),
|
||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,18 @@ describe("buildBody", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("links the full builds", () => {
|
||||||
|
const body = buildBody({
|
||||||
|
version: "26.10.0",
|
||||||
|
mobileVersion: "1.2.0",
|
||||||
|
changelog: CHANGELOG,
|
||||||
|
});
|
||||||
|
expect(body).toContain("v26.10.0/termix_windows_x64_full_nsis.exe");
|
||||||
|
expect(body).toContain("v26.10.0/termix_linux_x64_full_appimage.AppImage");
|
||||||
|
expect(body).toContain("v26.10.0/termix_linux_x64_full_deb.deb");
|
||||||
|
expect(body).toContain("v26.10.0/termix_macos_universal_full_dmg.dmg");
|
||||||
|
});
|
||||||
|
|
||||||
it("works for the real changelog", () => {
|
it("works for the real changelog", () => {
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const body = buildBody({
|
const body = buildBody({
|
||||||
|
|||||||
@@ -278,14 +278,15 @@ function pluginsForProfile(plugins, profile) {
|
|||||||
* pin list, so fields the registry does not know (onboarding, tier) carry over;
|
* pin list, so fields the registry does not know (onboarding, tier) carry over;
|
||||||
* a plugin new to the list starts in the full tier. coreVersion, when given,
|
* a plugin new to the list starts in the full tier. coreVersion, when given,
|
||||||
* skips releases whose engine.termix range does not include it, so the image
|
* skips releases whose engine.termix range does not include it, so the image
|
||||||
* never ships a plugin its own core cannot run.
|
* never ships a plugin its own core cannot run. With ids null, a plugin new to
|
||||||
|
* the list that has no usable release (only betas, say) is left out.
|
||||||
*/
|
*/
|
||||||
function pinsFromIndex(index, ids, current = [], coreVersion = null) {
|
function pinsFromIndex(index, ids, current = [], coreVersion = null) {
|
||||||
const plugins = Array.isArray(index?.plugins) ? index.plugins : [];
|
const plugins = Array.isArray(index?.plugins) ? index.plugins : [];
|
||||||
const byId = new Map(plugins.map((plugin) => [plugin.id, plugin]));
|
const byId = new Map(plugins.map((plugin) => [plugin.id, plugin]));
|
||||||
const currentById = new Map(current.map((entry) => [entry.id, entry]));
|
const currentById = new Map(current.map((entry) => [entry.id, entry]));
|
||||||
const wanted = ids ?? plugins.map((plugin) => plugin.id);
|
const wanted = ids ?? plugins.map((plugin) => plugin.id);
|
||||||
return [...wanted].sort().map((id) => {
|
const pins = [...wanted].sort().map((id) => {
|
||||||
const listed = byId.get(id);
|
const listed = byId.get(id);
|
||||||
// gated holds releases that need a newer Termix than old builds check for.
|
// gated holds releases that need a newer Termix than old builds check for.
|
||||||
const latest = [...(listed?.versions ?? []), ...(listed?.gated ?? [])]
|
const latest = [...(listed?.versions ?? []), ...(listed?.gated ?? [])]
|
||||||
@@ -300,7 +301,10 @@ function pinsFromIndex(index, ids, current = [], coreVersion = null) {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.sort((a, b) => semver.rcompare(a.version, b.version))[0];
|
.sort((a, b) => semver.rcompare(a.version, b.version))[0];
|
||||||
if (!latest) throw new Error(`${id} is not in the registry index`);
|
if (!latest) {
|
||||||
|
if (ids === null && !currentById.has(id)) return null;
|
||||||
|
throw new Error(`${id} is not in the registry index`);
|
||||||
|
}
|
||||||
const onboarding = currentById.get(id)?.onboarding;
|
const onboarding = currentById.get(id)?.onboarding;
|
||||||
const defaultLook = currentById.get(id)?.defaultLook === true;
|
const defaultLook = currentById.get(id)?.defaultLook === true;
|
||||||
const tier = currentById.get(id)?.tier ?? "full";
|
const tier = currentById.get(id)?.tier ?? "full";
|
||||||
@@ -314,6 +318,7 @@ function pinsFromIndex(index, ids, current = [], coreVersion = null) {
|
|||||||
...(defaultLook ? { defaultLook } : {}),
|
...(defaultLook ? { defaultLook } : {}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
return pins.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
const LOCAL_FILES = [
|
const LOCAL_FILES = [
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* re-released.
|
* re-released.
|
||||||
*
|
*
|
||||||
* node scripts/pin-bundled-plugins.cjs refresh the plugins already listed
|
* node scripts/pin-bundled-plugins.cjs refresh the plugins already listed
|
||||||
* node scripts/pin-bundled-plugins.cjs --all also add plugins new to the index (tier full)
|
* node scripts/pin-bundled-plugins.cjs --all also add plugins new to the index that have a stable release (tier full)
|
||||||
* --core-version X skip releases whose engine.termix excludes X
|
* --core-version X skip releases whose engine.termix excludes X
|
||||||
*
|
*
|
||||||
* The release workflow runs it with --all and the version being released.
|
* The release workflow runs it with --all and the version being released.
|
||||||
|
|||||||
@@ -144,3 +144,32 @@ describe("termix-plugin validate layouts and capabilities", () => {
|
|||||||
expect(output).not.toContain("is declared but");
|
expect(output).not.toContain("is declared but");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("termix-plugin validate mobile entry", () => {
|
||||||
|
function mobile(built: boolean) {
|
||||||
|
const root = plugin([]);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(root, "manifest.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
id: "fixture",
|
||||||
|
version: "1.0.0",
|
||||||
|
contributes: { mobile: { mode: "webview", entry: "dist/mobile.js" } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (built) {
|
||||||
|
fs.mkdirSync(path.join(root, "dist"), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(root, "dist", "mobile.js"), "");
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("reports a mobile entry that was not built", async () => {
|
||||||
|
expect(await problems(mobile(false))).toContain(
|
||||||
|
"manifest references dist/mobile.js, which does not exist",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds a built mobile entry", async () => {
|
||||||
|
expect(await problems(mobile(true))).not.toContain("dist/mobile.js");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -30,6 +30,7 @@ import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
|
|||||||
import { syncForwarder } from "../sync/provider.js";
|
import { syncForwarder } from "../sync/provider.js";
|
||||||
import { syncChangeWatcher } from "../sync/server/change-watcher.js";
|
import { syncChangeWatcher } from "../sync/server/change-watcher.js";
|
||||||
import dashboardRoutes from "./routes/dashboard-routes.js";
|
import dashboardRoutes from "./routes/dashboard-routes.js";
|
||||||
|
import operationsRoutes from "./routes/operations-routes.js";
|
||||||
import {
|
import {
|
||||||
mountPluginApi,
|
mountPluginApi,
|
||||||
mountPluginLegacyPaths,
|
mountPluginLegacyPaths,
|
||||||
@@ -1627,6 +1628,7 @@ app.use("/ui-preferences", uiPreferencesRoutes);
|
|||||||
registerAuditLogRoutes(app, authenticateJWT);
|
registerAuditLogRoutes(app, authenticateJWT);
|
||||||
app.use("/sync", syncForwarder);
|
app.use("/sync", syncForwarder);
|
||||||
app.use("/dashboard", dashboardRoutes);
|
app.use("/dashboard", dashboardRoutes);
|
||||||
|
app.use("/operations", operationsRoutes);
|
||||||
app.use("/plugins", pluginRoutes);
|
app.use("/plugins", pluginRoutes);
|
||||||
app.use(
|
app.use(
|
||||||
"/plugin-assets",
|
"/plugin-assets",
|
||||||
|
|||||||
@@ -484,6 +484,19 @@ async function initializeCompleteDatabase(): Promise<void> {
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_folder_access_owner_folder ON folder_access (owner_user_id, folder);
|
CREATE INDEX IF NOT EXISTS idx_folder_access_owner_folder ON folder_access (owner_user_id, folder);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS shared_folder_auth_overrides (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
folder TEXT NOT NULL,
|
||||||
|
protocol TEXT NOT NULL DEFAULT 'ssh',
|
||||||
|
credential_id INTEGER NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS shared_folder_auth_overrides_user_folder_protocol_unique ON shared_folder_auth_overrides (user_id, folder, protocol);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS plugins (
|
CREATE TABLE IF NOT EXISTS plugins (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
@@ -586,6 +599,70 @@ async function initializeCompleteDatabase(): Promise<void> {
|
|||||||
UNIQUE (role_name, permission)
|
UNIQUE (role_name, permission)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS operation_runs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
action_id TEXT NOT NULL,
|
||||||
|
action_version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
plugin_id TEXT NOT NULL,
|
||||||
|
plugin_version TEXT NOT NULL,
|
||||||
|
user_id TEXT REFERENCES users (id) ON DELETE SET NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
origin TEXT NOT NULL,
|
||||||
|
host_id INTEGER,
|
||||||
|
host_name TEXT,
|
||||||
|
batch_id TEXT,
|
||||||
|
dry_run INTEGER NOT NULL DEFAULT 0,
|
||||||
|
risk TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
summary TEXT,
|
||||||
|
changed INTEGER,
|
||||||
|
input TEXT,
|
||||||
|
sealed_input TEXT,
|
||||||
|
output TEXT,
|
||||||
|
undo_input TEXT,
|
||||||
|
error TEXT,
|
||||||
|
error_code TEXT,
|
||||||
|
undo_of TEXT,
|
||||||
|
idempotency_key TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
started_at TEXT,
|
||||||
|
finished_at TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_operation_runs_created ON operation_runs (created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_operation_runs_user_created ON operation_runs (user_id, created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_operation_runs_host_created ON operation_runs (host_id, created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_operation_runs_status ON operation_runs (status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_operation_runs_batch ON operation_runs (batch_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS operation_approvals (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
run_id TEXT NOT NULL REFERENCES operation_runs (id) ON DELETE CASCADE,
|
||||||
|
requested_by TEXT,
|
||||||
|
reason TEXT,
|
||||||
|
allow_self INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
decided_by TEXT,
|
||||||
|
comment TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
decided_at TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_operation_approvals_status ON operation_approvals (status, expires_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_operation_approvals_run ON operation_approvals (run_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS operation_events (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
event_id TEXT NOT NULL,
|
||||||
|
plugin_id TEXT NOT NULL,
|
||||||
|
host_id INTEGER,
|
||||||
|
severity TEXT,
|
||||||
|
payload TEXT,
|
||||||
|
run_id TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_operation_events_created ON operation_events (created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_operation_events_host_created ON operation_events (host_id, created_at);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS api_keys (
|
CREATE TABLE IF NOT EXISTS api_keys (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
@@ -948,6 +1025,7 @@ const enforceUniqueUsernames = () => {
|
|||||||
|
|
||||||
const migrateSchema = () => {
|
const migrateSchema = () => {
|
||||||
repairSnippetsColumns(sqlite);
|
repairSnippetsColumns(sqlite);
|
||||||
|
addColumnIfNotExists("api_keys", "scopes", "TEXT");
|
||||||
addColumnIfNotExists("user_preferences", "theme", "TEXT");
|
addColumnIfNotExists("user_preferences", "theme", "TEXT");
|
||||||
addColumnIfNotExists("user_preferences", "font_size", "TEXT");
|
addColumnIfNotExists("user_preferences", "font_size", "TEXT");
|
||||||
addColumnIfNotExists("user_preferences", "accent_color", "TEXT");
|
addColumnIfNotExists("user_preferences", "accent_color", "TEXT");
|
||||||
@@ -1316,6 +1394,8 @@ const migrateSchema = () => {
|
|||||||
{ column: "ssh_port", sql: "ALTER TABLE ssh_data ADD COLUMN ssh_port INTEGER DEFAULT 22" },
|
{ column: "ssh_port", sql: "ALTER TABLE ssh_data ADD COLUMN ssh_port INTEGER DEFAULT 22" },
|
||||||
{ column: "connection_origin", sql: "ALTER TABLE ssh_data ADD COLUMN connection_origin TEXT" },
|
{ column: "connection_origin", sql: "ALTER TABLE ssh_data ADD COLUMN connection_origin TEXT" },
|
||||||
{ column: "parent_host_id", sql: "ALTER TABLE ssh_data ADD COLUMN parent_host_id INTEGER REFERENCES ssh_data(id) ON DELETE SET NULL" },
|
{ column: "parent_host_id", sql: "ALTER TABLE ssh_data ADD COLUMN parent_host_id INTEGER REFERENCES ssh_data(id) ON DELETE SET NULL" },
|
||||||
|
{ column: "color", sql: "ALTER TABLE ssh_data ADD COLUMN color TEXT" },
|
||||||
|
{ column: "icon", sql: "ALTER TABLE ssh_data ADD COLUMN icon TEXT" },
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const migration of sshDataMigrations) {
|
for (const migration of sshDataMigrations) {
|
||||||
@@ -1797,6 +1877,8 @@ async function saveMemoryDatabaseToFile(): Promise<void> {
|
|||||||
if (!memoryDatabase) return;
|
if (!memoryDatabase) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Before the copy, so a write during the slow part below stays dirty.
|
||||||
|
DatabaseSaveTrigger.markClean();
|
||||||
const buffer = memoryDatabase.serialize();
|
const buffer = memoryDatabase.serialize();
|
||||||
|
|
||||||
if (!fs.existsSync(dataDir)) {
|
if (!fs.existsSync(dataDir)) {
|
||||||
@@ -1817,11 +1899,10 @@ async function saveMemoryDatabaseToFile(): Promise<void> {
|
|||||||
encryptedDbPath,
|
encryptedDbPath,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
fs.writeFileSync(dbPath, buffer);
|
await fs.promises.writeFile(dbPath, buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
DatabaseSaveTrigger.markClean();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
DatabaseSaveTrigger.markDirty();
|
||||||
databaseLogger.error("Failed to save in-memory database", error, {
|
databaseLogger.error("Failed to save in-memory database", error, {
|
||||||
operation: "memory_db_save_failed",
|
operation: "memory_db_save_failed",
|
||||||
enableFileEncryption,
|
enableFileEncryption,
|
||||||
@@ -1840,7 +1921,8 @@ async function handlePostInitFileEncryption() {
|
|||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
if (DatabaseSaveTrigger.isDirty) {
|
if (DatabaseSaveTrigger.isDirty) {
|
||||||
saveMemoryDatabaseToFile();
|
// Through the trigger so it waits for any save already running.
|
||||||
|
void DatabaseSaveTrigger.forceSave("periodic_save").catch(() => {});
|
||||||
}
|
}
|
||||||
}, 5 * 60 * 1000);
|
}, 5 * 60 * 1000);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -193,6 +193,9 @@ export const hosts = mysqlTable(
|
|||||||
// legacy algorithms, agent, environment). JSON.
|
// legacy algorithms, agent, environment). JSON.
|
||||||
sshOptions: text("ssh_options"),
|
sshOptions: text("ssh_options"),
|
||||||
notes: text("notes"),
|
notes: text("notes"),
|
||||||
|
// Hex color and Lucide icon name a layout may show next to the host.
|
||||||
|
color: text("color"),
|
||||||
|
icon: text("icon"),
|
||||||
enableSsh: boolean("enable_ssh").notNull().default(true),
|
enableSsh: boolean("enable_ssh").notNull().default(true),
|
||||||
|
|
||||||
sshPort: int("ssh_port").default(22),
|
sshPort: int("ssh_port").default(22),
|
||||||
@@ -487,6 +490,38 @@ export const sharedHostAuthOverrides = mysqlTable(
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A recipient's own login for the shared hosts in a folder ("Parent / Child")
|
||||||
|
* and its subfolders. A host's own override still wins.
|
||||||
|
*/
|
||||||
|
export const sharedFolderAuthOverrides = mysqlTable(
|
||||||
|
"shared_folder_auth_overrides",
|
||||||
|
{
|
||||||
|
id: int("id").autoincrement().primaryKey(),
|
||||||
|
userId: varchar("user_id", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
folder: varchar("folder", { length: 255 }).notNull(),
|
||||||
|
protocol: varchar("protocol", { length: 255 }).notNull().default("ssh"),
|
||||||
|
credentialId: int("credential_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => sshCredentials.id, { onDelete: "cascade" }),
|
||||||
|
createdAt: text("created_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||||
|
updatedAt: text("updated_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("shared_folder_auth_overrides_user_folder_protocol_unique").on(
|
||||||
|
table.userId,
|
||||||
|
table.folder,
|
||||||
|
table.protocol,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
export const sharedHostSecrets = mysqlTable(
|
export const sharedHostSecrets = mysqlTable(
|
||||||
"shared_host_secrets",
|
"shared_host_secrets",
|
||||||
{
|
{
|
||||||
@@ -638,10 +673,102 @@ export const apiKeys = mysqlTable(
|
|||||||
expiresAt: text("expires_at"),
|
expiresAt: text("expires_at"),
|
||||||
lastUsedAt: text("last_used_at"),
|
lastUsedAt: text("last_used_at"),
|
||||||
isActive: boolean("is_active").notNull().default(true),
|
isActive: boolean("is_active").notNull().default(true),
|
||||||
|
// JSON list. Null means full access, which every older key has.
|
||||||
|
scopes: text("scopes"),
|
||||||
},
|
},
|
||||||
(table) => [index("idx_api_keys_user_id").on(table.userId)],
|
(table) => [index("idx_api_keys_user_id").on(table.userId)],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const operationRuns = mysqlTable(
|
||||||
|
"operation_runs",
|
||||||
|
{
|
||||||
|
id: varchar("id", { length: 255 }).primaryKey(),
|
||||||
|
actionId: text("action_id").notNull(),
|
||||||
|
actionVersion: int("action_version").notNull().default(1),
|
||||||
|
pluginId: text("plugin_id").notNull(),
|
||||||
|
pluginVersion: text("plugin_version").notNull(),
|
||||||
|
|
||||||
|
// Nullable on purpose: history outlives the account, like audit_logs.
|
||||||
|
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "set null" }),
|
||||||
|
username: text("username").notNull(),
|
||||||
|
|
||||||
|
origin: text("origin").notNull(),
|
||||||
|
hostId: int("host_id"),
|
||||||
|
hostName: text("host_name"),
|
||||||
|
batchId: varchar("batch_id", { length: 255 }),
|
||||||
|
dryRun: boolean("dry_run").notNull().default(false),
|
||||||
|
risk: text("risk").notNull(),
|
||||||
|
status: varchar("status", { length: 255 }).notNull(),
|
||||||
|
summary: text("summary"),
|
||||||
|
changed: boolean("changed"),
|
||||||
|
input: text("input"),
|
||||||
|
// Only until the run ends, then wiped.
|
||||||
|
sealedInput: text("sealed_input"),
|
||||||
|
output: text("output"),
|
||||||
|
undoInput: text("undo_input"),
|
||||||
|
error: text("error"),
|
||||||
|
errorCode: text("error_code"),
|
||||||
|
undoOf: text("undo_of"),
|
||||||
|
idempotencyKey: text("idempotency_key"),
|
||||||
|
createdAt: varchar("created_at", { length: 255 }).notNull(),
|
||||||
|
startedAt: text("started_at"),
|
||||||
|
finishedAt: text("finished_at"),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_operation_runs_created").on(table.createdAt),
|
||||||
|
index("idx_operation_runs_user_created").on(table.userId, table.createdAt),
|
||||||
|
index("idx_operation_runs_host_created").on(table.hostId, table.createdAt),
|
||||||
|
index("idx_operation_runs_status").on(table.status),
|
||||||
|
index("idx_operation_runs_batch").on(table.batchId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const operationApprovals = mysqlTable(
|
||||||
|
"operation_approvals",
|
||||||
|
{
|
||||||
|
id: varchar("id", { length: 255 }).primaryKey(),
|
||||||
|
runId: varchar("run_id", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => operationRuns.id, { onDelete: "cascade" }),
|
||||||
|
requestedBy: text("requested_by"),
|
||||||
|
reason: text("reason"),
|
||||||
|
allowSelf: boolean("allow_self")
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
|
status: varchar("status", { length: 255 }).notNull(),
|
||||||
|
decidedBy: text("decided_by"),
|
||||||
|
comment: text("comment"),
|
||||||
|
createdAt: text("created_at").notNull(),
|
||||||
|
expiresAt: varchar("expires_at", { length: 255 }).notNull(),
|
||||||
|
decidedAt: text("decided_at"),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_operation_approvals_status").on(table.status, table.expiresAt),
|
||||||
|
index("idx_operation_approvals_run").on(table.runId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const operationEvents = mysqlTable(
|
||||||
|
"operation_events",
|
||||||
|
{
|
||||||
|
id: varchar("id", { length: 255 }).primaryKey(),
|
||||||
|
eventId: text("event_id").notNull(),
|
||||||
|
pluginId: text("plugin_id").notNull(),
|
||||||
|
hostId: int("host_id"),
|
||||||
|
severity: text("severity"),
|
||||||
|
payload: text("payload"),
|
||||||
|
runId: text("run_id"),
|
||||||
|
createdAt: varchar("created_at", { length: 255 }).notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_operation_events_created").on(table.createdAt),
|
||||||
|
index("idx_operation_events_host_created").on(
|
||||||
|
table.hostId,
|
||||||
|
table.createdAt,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
export const userOpenTabs = mysqlTable(
|
export const userOpenTabs = mysqlTable(
|
||||||
"user_open_tabs",
|
"user_open_tabs",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -193,6 +193,9 @@ export const hosts = pgTable(
|
|||||||
// legacy algorithms, agent, environment). JSON.
|
// legacy algorithms, agent, environment). JSON.
|
||||||
sshOptions: text("ssh_options"),
|
sshOptions: text("ssh_options"),
|
||||||
notes: text("notes"),
|
notes: text("notes"),
|
||||||
|
// Hex color and Lucide icon name a layout may show next to the host.
|
||||||
|
color: text("color"),
|
||||||
|
icon: text("icon"),
|
||||||
enableSsh: boolean("enable_ssh").notNull().default(true),
|
enableSsh: boolean("enable_ssh").notNull().default(true),
|
||||||
|
|
||||||
sshPort: integer("ssh_port").default(22),
|
sshPort: integer("ssh_port").default(22),
|
||||||
@@ -487,6 +490,38 @@ export const sharedHostAuthOverrides = pgTable(
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A recipient's own login for the shared hosts in a folder ("Parent / Child")
|
||||||
|
* and its subfolders. A host's own override still wins.
|
||||||
|
*/
|
||||||
|
export const sharedFolderAuthOverrides = pgTable(
|
||||||
|
"shared_folder_auth_overrides",
|
||||||
|
{
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
userId: varchar("user_id", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
folder: varchar("folder", { length: 255 }).notNull(),
|
||||||
|
protocol: varchar("protocol", { length: 255 }).notNull().default("ssh"),
|
||||||
|
credentialId: integer("credential_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => sshCredentials.id, { onDelete: "cascade" }),
|
||||||
|
createdAt: text("created_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
updatedAt: text("updated_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("shared_folder_auth_overrides_user_folder_protocol_unique").on(
|
||||||
|
table.userId,
|
||||||
|
table.folder,
|
||||||
|
table.protocol,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
export const sharedHostSecrets = pgTable(
|
export const sharedHostSecrets = pgTable(
|
||||||
"shared_host_secrets",
|
"shared_host_secrets",
|
||||||
{
|
{
|
||||||
@@ -638,10 +673,102 @@ export const apiKeys = pgTable(
|
|||||||
expiresAt: text("expires_at"),
|
expiresAt: text("expires_at"),
|
||||||
lastUsedAt: text("last_used_at"),
|
lastUsedAt: text("last_used_at"),
|
||||||
isActive: boolean("is_active").notNull().default(true),
|
isActive: boolean("is_active").notNull().default(true),
|
||||||
|
// JSON list. Null means full access, which every older key has.
|
||||||
|
scopes: text("scopes"),
|
||||||
},
|
},
|
||||||
(table) => [index("idx_api_keys_user_id").on(table.userId)],
|
(table) => [index("idx_api_keys_user_id").on(table.userId)],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const operationRuns = pgTable(
|
||||||
|
"operation_runs",
|
||||||
|
{
|
||||||
|
id: varchar("id", { length: 255 }).primaryKey(),
|
||||||
|
actionId: text("action_id").notNull(),
|
||||||
|
actionVersion: integer("action_version").notNull().default(1),
|
||||||
|
pluginId: text("plugin_id").notNull(),
|
||||||
|
pluginVersion: text("plugin_version").notNull(),
|
||||||
|
|
||||||
|
// Nullable on purpose: history outlives the account, like audit_logs.
|
||||||
|
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "set null" }),
|
||||||
|
username: text("username").notNull(),
|
||||||
|
|
||||||
|
origin: text("origin").notNull(),
|
||||||
|
hostId: integer("host_id"),
|
||||||
|
hostName: text("host_name"),
|
||||||
|
batchId: varchar("batch_id", { length: 255 }),
|
||||||
|
dryRun: boolean("dry_run").notNull().default(false),
|
||||||
|
risk: text("risk").notNull(),
|
||||||
|
status: varchar("status", { length: 255 }).notNull(),
|
||||||
|
summary: text("summary"),
|
||||||
|
changed: boolean("changed"),
|
||||||
|
input: text("input"),
|
||||||
|
// Only until the run ends, then wiped.
|
||||||
|
sealedInput: text("sealed_input"),
|
||||||
|
output: text("output"),
|
||||||
|
undoInput: text("undo_input"),
|
||||||
|
error: text("error"),
|
||||||
|
errorCode: text("error_code"),
|
||||||
|
undoOf: text("undo_of"),
|
||||||
|
idempotencyKey: text("idempotency_key"),
|
||||||
|
createdAt: varchar("created_at", { length: 255 }).notNull(),
|
||||||
|
startedAt: text("started_at"),
|
||||||
|
finishedAt: text("finished_at"),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_operation_runs_created").on(table.createdAt),
|
||||||
|
index("idx_operation_runs_user_created").on(table.userId, table.createdAt),
|
||||||
|
index("idx_operation_runs_host_created").on(table.hostId, table.createdAt),
|
||||||
|
index("idx_operation_runs_status").on(table.status),
|
||||||
|
index("idx_operation_runs_batch").on(table.batchId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const operationApprovals = pgTable(
|
||||||
|
"operation_approvals",
|
||||||
|
{
|
||||||
|
id: varchar("id", { length: 255 }).primaryKey(),
|
||||||
|
runId: varchar("run_id", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => operationRuns.id, { onDelete: "cascade" }),
|
||||||
|
requestedBy: text("requested_by"),
|
||||||
|
reason: text("reason"),
|
||||||
|
allowSelf: boolean("allow_self")
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
|
status: varchar("status", { length: 255 }).notNull(),
|
||||||
|
decidedBy: text("decided_by"),
|
||||||
|
comment: text("comment"),
|
||||||
|
createdAt: text("created_at").notNull(),
|
||||||
|
expiresAt: varchar("expires_at", { length: 255 }).notNull(),
|
||||||
|
decidedAt: text("decided_at"),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_operation_approvals_status").on(table.status, table.expiresAt),
|
||||||
|
index("idx_operation_approvals_run").on(table.runId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const operationEvents = pgTable(
|
||||||
|
"operation_events",
|
||||||
|
{
|
||||||
|
id: varchar("id", { length: 255 }).primaryKey(),
|
||||||
|
eventId: text("event_id").notNull(),
|
||||||
|
pluginId: text("plugin_id").notNull(),
|
||||||
|
hostId: integer("host_id"),
|
||||||
|
severity: text("severity"),
|
||||||
|
payload: text("payload"),
|
||||||
|
runId: text("run_id"),
|
||||||
|
createdAt: varchar("created_at", { length: 255 }).notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_operation_events_created").on(table.createdAt),
|
||||||
|
index("idx_operation_events_host_created").on(
|
||||||
|
table.hostId,
|
||||||
|
table.createdAt,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
export const userOpenTabs = pgTable(
|
export const userOpenTabs = pgTable(
|
||||||
"user_open_tabs",
|
"user_open_tabs",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -183,6 +183,9 @@ export const hosts = sqliteTable(
|
|||||||
// legacy algorithms, agent, environment). JSON.
|
// legacy algorithms, agent, environment). JSON.
|
||||||
sshOptions: text("ssh_options"),
|
sshOptions: text("ssh_options"),
|
||||||
notes: text("notes"),
|
notes: text("notes"),
|
||||||
|
// Hex color and Lucide icon name a layout may show next to the host.
|
||||||
|
color: text("color"),
|
||||||
|
icon: text("icon"),
|
||||||
enableSsh: integer("enable_ssh", { mode: "boolean" }).notNull().default(true),
|
enableSsh: integer("enable_ssh", { mode: "boolean" }).notNull().default(true),
|
||||||
|
|
||||||
sshPort: integer("ssh_port").default(22),
|
sshPort: integer("ssh_port").default(22),
|
||||||
@@ -477,6 +480,38 @@ export const sharedHostAuthOverrides = sqliteTable(
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A recipient's own login for the shared hosts in a folder ("Parent / Child")
|
||||||
|
* and its subfolders. A host's own override still wins.
|
||||||
|
*/
|
||||||
|
export const sharedFolderAuthOverrides = sqliteTable(
|
||||||
|
"shared_folder_auth_overrides",
|
||||||
|
{
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
folder: text("folder").notNull(),
|
||||||
|
protocol: text("protocol").notNull().default("ssh"),
|
||||||
|
credentialId: integer("credential_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => sshCredentials.id, { onDelete: "cascade" }),
|
||||||
|
createdAt: text("created_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
updatedAt: text("updated_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("shared_folder_auth_overrides_user_folder_protocol_unique").on(
|
||||||
|
table.userId,
|
||||||
|
table.folder,
|
||||||
|
table.protocol,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
export const sharedHostSecrets = sqliteTable(
|
export const sharedHostSecrets = sqliteTable(
|
||||||
"shared_host_secrets",
|
"shared_host_secrets",
|
||||||
{
|
{
|
||||||
@@ -628,10 +663,102 @@ export const apiKeys = sqliteTable(
|
|||||||
expiresAt: text("expires_at"),
|
expiresAt: text("expires_at"),
|
||||||
lastUsedAt: text("last_used_at"),
|
lastUsedAt: text("last_used_at"),
|
||||||
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
|
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
|
||||||
|
// JSON list. Null means full access, which every older key has.
|
||||||
|
scopes: text("scopes"),
|
||||||
},
|
},
|
||||||
(table) => [index("idx_api_keys_user_id").on(table.userId)],
|
(table) => [index("idx_api_keys_user_id").on(table.userId)],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const operationRuns = sqliteTable(
|
||||||
|
"operation_runs",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
actionId: text("action_id").notNull(),
|
||||||
|
actionVersion: integer("action_version").notNull().default(1),
|
||||||
|
pluginId: text("plugin_id").notNull(),
|
||||||
|
pluginVersion: text("plugin_version").notNull(),
|
||||||
|
|
||||||
|
// Nullable on purpose: history outlives the account, like audit_logs.
|
||||||
|
userId: text("user_id").references(() => users.id, { onDelete: "set null" }),
|
||||||
|
username: text("username").notNull(),
|
||||||
|
|
||||||
|
origin: text("origin").notNull(),
|
||||||
|
hostId: integer("host_id"),
|
||||||
|
hostName: text("host_name"),
|
||||||
|
batchId: text("batch_id"),
|
||||||
|
dryRun: integer("dry_run", { mode: "boolean" }).notNull().default(false),
|
||||||
|
risk: text("risk").notNull(),
|
||||||
|
status: text("status").notNull(),
|
||||||
|
summary: text("summary"),
|
||||||
|
changed: integer("changed", { mode: "boolean" }),
|
||||||
|
input: text("input"),
|
||||||
|
// Only until the run ends, then wiped.
|
||||||
|
sealedInput: text("sealed_input"),
|
||||||
|
output: text("output"),
|
||||||
|
undoInput: text("undo_input"),
|
||||||
|
error: text("error"),
|
||||||
|
errorCode: text("error_code"),
|
||||||
|
undoOf: text("undo_of"),
|
||||||
|
idempotencyKey: text("idempotency_key"),
|
||||||
|
createdAt: text("created_at").notNull(),
|
||||||
|
startedAt: text("started_at"),
|
||||||
|
finishedAt: text("finished_at"),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_operation_runs_created").on(table.createdAt),
|
||||||
|
index("idx_operation_runs_user_created").on(table.userId, table.createdAt),
|
||||||
|
index("idx_operation_runs_host_created").on(table.hostId, table.createdAt),
|
||||||
|
index("idx_operation_runs_status").on(table.status),
|
||||||
|
index("idx_operation_runs_batch").on(table.batchId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const operationApprovals = sqliteTable(
|
||||||
|
"operation_approvals",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
runId: text("run_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => operationRuns.id, { onDelete: "cascade" }),
|
||||||
|
requestedBy: text("requested_by"),
|
||||||
|
reason: text("reason"),
|
||||||
|
allowSelf: integer("allow_self", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
|
status: text("status").notNull(),
|
||||||
|
decidedBy: text("decided_by"),
|
||||||
|
comment: text("comment"),
|
||||||
|
createdAt: text("created_at").notNull(),
|
||||||
|
expiresAt: text("expires_at").notNull(),
|
||||||
|
decidedAt: text("decided_at"),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_operation_approvals_status").on(table.status, table.expiresAt),
|
||||||
|
index("idx_operation_approvals_run").on(table.runId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const operationEvents = sqliteTable(
|
||||||
|
"operation_events",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
eventId: text("event_id").notNull(),
|
||||||
|
pluginId: text("plugin_id").notNull(),
|
||||||
|
hostId: integer("host_id"),
|
||||||
|
severity: text("severity"),
|
||||||
|
payload: text("payload"),
|
||||||
|
runId: text("run_id"),
|
||||||
|
createdAt: text("created_at").notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("idx_operation_events_created").on(table.createdAt),
|
||||||
|
index("idx_operation_events_host_created").on(
|
||||||
|
table.hostId,
|
||||||
|
table.createdAt,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
export const userOpenTabs = sqliteTable(
|
export const userOpenTabs = sqliteTable(
|
||||||
"user_open_tabs",
|
"user_open_tabs",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export interface ApiKeyListRecord {
|
|||||||
expiresAt: string | null;
|
expiresAt: string | null;
|
||||||
lastUsedAt: string | null;
|
lastUsedAt: string | null;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
/** JSON list, or null for full access. */
|
||||||
|
scopes: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ApiKeyRepository {
|
export class ApiKeyRepository {
|
||||||
@@ -43,6 +45,7 @@ export class ApiKeyRepository {
|
|||||||
expiresAt: apiKeys.expiresAt,
|
expiresAt: apiKeys.expiresAt,
|
||||||
lastUsedAt: apiKeys.lastUsedAt,
|
lastUsedAt: apiKeys.lastUsedAt,
|
||||||
isActive: apiKeys.isActive,
|
isActive: apiKeys.isActive,
|
||||||
|
scopes: apiKeys.scopes,
|
||||||
})
|
})
|
||||||
.from(apiKeys)
|
.from(apiKeys)
|
||||||
.leftJoin(users, eq(apiKeys.userId, users.id))
|
.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 { CredentialSidebarPreferenceRepository } from "./credential-sidebar-preference-repository.js";
|
||||||
import { UiPreferenceRepository } from "./ui-preference-repository.js";
|
import { UiPreferenceRepository } from "./ui-preference-repository.js";
|
||||||
import { OpenTabRepository } from "./open-tab-repository.js";
|
import { OpenTabRepository } from "./open-tab-repository.js";
|
||||||
|
import { OperationRepository } from "./operation-repository.js";
|
||||||
import { PluginRepository } from "./plugin-repository.js";
|
import { PluginRepository } from "./plugin-repository.js";
|
||||||
import { PluginStorageRepository } from "./plugin-storage-repository.js";
|
import { PluginStorageRepository } from "./plugin-storage-repository.js";
|
||||||
import { PluginSettingsRepository } from "./plugin-settings-repository.js";
|
import { PluginSettingsRepository } from "./plugin-settings-repository.js";
|
||||||
@@ -31,6 +32,7 @@ import { FolderAccessRepository } from "./folder-access-repository.js";
|
|||||||
import { SettingsRepository } from "./settings-repository.js";
|
import { SettingsRepository } from "./settings-repository.js";
|
||||||
import { HostProtocolAuthRepository } from "./host-protocol-auth-repository.js";
|
import { HostProtocolAuthRepository } from "./host-protocol-auth-repository.js";
|
||||||
import { SharedHostAuthOverrideRepository } from "./shared-host-auth-override-repository.js";
|
import { SharedHostAuthOverrideRepository } from "./shared-host-auth-override-repository.js";
|
||||||
|
import { SharedFolderAuthOverrideRepository } from "./shared-folder-auth-override-repository.js";
|
||||||
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
|
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
|
||||||
import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js";
|
import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js";
|
||||||
import { TrustedDeviceRepository } from "./trusted-device-repository.js";
|
import { TrustedDeviceRepository } from "./trusted-device-repository.js";
|
||||||
@@ -133,6 +135,14 @@ export function createCurrentAuditLogRepository(): AuditLogRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createCurrentOperationRepository(): OperationRepository {
|
||||||
|
return new OperationRepository(
|
||||||
|
createCurrentRepositoryContext(),
|
||||||
|
// Rows land on every run. A rollout saves once on its own when it starts.
|
||||||
|
createCurrentRepositoryLazyWriteHook("operation_repository_write"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function createCurrentCredentialRepository(): CredentialRepository {
|
export function createCurrentCredentialRepository(): CredentialRepository {
|
||||||
return new CredentialRepository(
|
return new CredentialRepository(
|
||||||
createCurrentRepositoryContext(),
|
createCurrentRepositoryContext(),
|
||||||
@@ -316,6 +326,15 @@ export function createCurrentSharedHostAuthOverrideRepository(): SharedHostAuthO
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createCurrentSharedFolderAuthOverrideRepository(): SharedFolderAuthOverrideRepository {
|
||||||
|
return new SharedFolderAuthOverrideRepository(
|
||||||
|
createCurrentRepositoryContext(),
|
||||||
|
createCurrentRepositoryWriteHook(
|
||||||
|
"shared_folder_auth_override_repository_write",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function createCurrentSshCredentialUsageRepository(): SshCredentialUsageRepository {
|
export function createCurrentSshCredentialUsageRepository(): SshCredentialUsageRepository {
|
||||||
return new SshCredentialUsageRepository(
|
return new SshCredentialUsageRepository(
|
||||||
createCurrentRepositoryContext(),
|
createCurrentRepositoryContext(),
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import { and, desc, eq, inArray, lt, or, sql } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
operationApprovals,
|
||||||
|
operationEvents,
|
||||||
|
operationRuns,
|
||||||
|
} from "../db/schema.js";
|
||||||
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
|
import { countValue, rowsAffected } from "./mutation-result.js";
|
||||||
|
|
||||||
|
export type OperationRunRecord = typeof operationRuns.$inferSelect;
|
||||||
|
export type NewOperationRunRecord = typeof operationRuns.$inferInsert;
|
||||||
|
export type OperationApprovalRecord = typeof operationApprovals.$inferSelect;
|
||||||
|
export type NewOperationApprovalRecord = typeof operationApprovals.$inferInsert;
|
||||||
|
export type OperationEventRecord = typeof operationEvents.$inferSelect;
|
||||||
|
export type NewOperationEventRecord = typeof operationEvents.$inferInsert;
|
||||||
|
|
||||||
|
export interface OperationRunFilters {
|
||||||
|
userId?: string;
|
||||||
|
/** Rows also visible because the viewer owns the host. */
|
||||||
|
orHostIds?: number[];
|
||||||
|
hostId?: number;
|
||||||
|
actionId?: string;
|
||||||
|
status?: string;
|
||||||
|
batchId?: string;
|
||||||
|
before?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_PAGE = 200;
|
||||||
|
|
||||||
|
export class OperationRepository {
|
||||||
|
constructor(
|
||||||
|
private readonly context: DatabaseContext,
|
||||||
|
private readonly onWrite?: () => void | Promise<void>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async insertRun(run: NewOperationRunRecord): Promise<void> {
|
||||||
|
await this.context.drizzle.insert(operationRuns).values(run);
|
||||||
|
await this.onWrite?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findRun(id: string): Promise<OperationRunRecord | null> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(operationRuns)
|
||||||
|
.where(eq(operationRuns.id, id))
|
||||||
|
.limit(1);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByIdempotencyKey(
|
||||||
|
userId: string,
|
||||||
|
key: string,
|
||||||
|
): Promise<OperationRunRecord | null> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(operationRuns)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(operationRuns.userId, userId),
|
||||||
|
eq(operationRuns.idempotencyKey, key),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listRuns(
|
||||||
|
filters: OperationRunFilters,
|
||||||
|
limit = 50,
|
||||||
|
): Promise<OperationRunRecord[]> {
|
||||||
|
const owner = filters.userId
|
||||||
|
? filters.orHostIds?.length
|
||||||
|
? or(
|
||||||
|
eq(operationRuns.userId, filters.userId),
|
||||||
|
inArray(operationRuns.hostId, filters.orHostIds),
|
||||||
|
)
|
||||||
|
: eq(operationRuns.userId, filters.userId)
|
||||||
|
: undefined;
|
||||||
|
const conditions = [
|
||||||
|
owner,
|
||||||
|
filters.hostId !== undefined
|
||||||
|
? eq(operationRuns.hostId, filters.hostId)
|
||||||
|
: undefined,
|
||||||
|
filters.actionId
|
||||||
|
? eq(operationRuns.actionId, filters.actionId)
|
||||||
|
: undefined,
|
||||||
|
filters.status ? eq(operationRuns.status, filters.status) : undefined,
|
||||||
|
filters.batchId ? eq(operationRuns.batchId, filters.batchId) : undefined,
|
||||||
|
filters.before ? lt(operationRuns.createdAt, filters.before) : undefined,
|
||||||
|
].filter((condition) => condition !== undefined);
|
||||||
|
return this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(operationRuns)
|
||||||
|
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||||
|
.orderBy(desc(operationRuns.createdAt))
|
||||||
|
.limit(Math.min(Math.max(limit, 1), MAX_PAGE));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Moves a run on only from the status given, so two writers cannot race. */
|
||||||
|
async updateRunFrom(
|
||||||
|
id: string,
|
||||||
|
fromStatus: string,
|
||||||
|
patch: Partial<NewOperationRunRecord>,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const result = await this.context.drizzle
|
||||||
|
.update(operationRuns)
|
||||||
|
.set(patch)
|
||||||
|
.where(
|
||||||
|
and(eq(operationRuns.id, id), eq(operationRuns.status, fromStatus)),
|
||||||
|
);
|
||||||
|
await this.onWrite?.();
|
||||||
|
return rowsAffected(result) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Anything still running when the server stopped has an unknown outcome. */
|
||||||
|
async markRunningInterrupted(finishedAt: string): Promise<number> {
|
||||||
|
const result = await this.context.drizzle
|
||||||
|
.update(operationRuns)
|
||||||
|
.set({
|
||||||
|
status: "interrupted",
|
||||||
|
finishedAt,
|
||||||
|
sealedInput: null,
|
||||||
|
error: "The server stopped during this run. Check the host.",
|
||||||
|
})
|
||||||
|
.where(eq(operationRuns.status, "running"));
|
||||||
|
await this.onWrite?.();
|
||||||
|
return rowsAffected(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
async insertApproval(approval: NewOperationApprovalRecord): Promise<void> {
|
||||||
|
await this.context.drizzle.insert(operationApprovals).values(approval);
|
||||||
|
await this.onWrite?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findApproval(id: string): Promise<OperationApprovalRecord | null> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(operationApprovals)
|
||||||
|
.where(eq(operationApprovals.id, id))
|
||||||
|
.limit(1);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findApprovalForRun(
|
||||||
|
runId: string,
|
||||||
|
): Promise<OperationApprovalRecord | null> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(operationApprovals)
|
||||||
|
.where(eq(operationApprovals.runId, runId))
|
||||||
|
.limit(1);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPendingApprovals(limit = 100): Promise<OperationApprovalRecord[]> {
|
||||||
|
return this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(operationApprovals)
|
||||||
|
.where(eq(operationApprovals.status, "pending"))
|
||||||
|
.orderBy(desc(operationApprovals.createdAt))
|
||||||
|
.limit(Math.min(Math.max(limit, 1), MAX_PAGE));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One shot: only the first decision on a pending row wins. */
|
||||||
|
async decideApproval(
|
||||||
|
id: string,
|
||||||
|
status: "approved" | "rejected" | "expired",
|
||||||
|
decidedBy: string | null,
|
||||||
|
comment: string | null,
|
||||||
|
decidedAt: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const result = await this.context.drizzle
|
||||||
|
.update(operationApprovals)
|
||||||
|
.set({ status, decidedBy, comment, decidedAt })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(operationApprovals.id, id),
|
||||||
|
eq(operationApprovals.status, "pending"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await this.onWrite?.();
|
||||||
|
return rowsAffected(result) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listExpiredApprovals(now: string): Promise<OperationApprovalRecord[]> {
|
||||||
|
return this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(operationApprovals)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(operationApprovals.status, "pending"),
|
||||||
|
lt(operationApprovals.expiresAt, now),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(MAX_PAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
async insertEvent(event: NewOperationEventRecord): Promise<void> {
|
||||||
|
await this.context.drizzle.insert(operationEvents).values(event);
|
||||||
|
await this.onWrite?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
async listEvents(
|
||||||
|
filters: { hostId?: number; before?: string },
|
||||||
|
limit = 50,
|
||||||
|
): Promise<OperationEventRecord[]> {
|
||||||
|
const conditions = [
|
||||||
|
filters.hostId !== undefined
|
||||||
|
? eq(operationEvents.hostId, filters.hostId)
|
||||||
|
: undefined,
|
||||||
|
filters.before
|
||||||
|
? lt(operationEvents.createdAt, filters.before)
|
||||||
|
: undefined,
|
||||||
|
].filter((condition) => condition !== undefined);
|
||||||
|
return this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(operationEvents)
|
||||||
|
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||||
|
.orderBy(desc(operationEvents.createdAt))
|
||||||
|
.limit(Math.min(Math.max(limit, 1), MAX_PAGE));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drops rows older than the cutoff, finished runs only. */
|
||||||
|
async prune(before: string): Promise<number> {
|
||||||
|
const runs = await this.context.drizzle
|
||||||
|
.delete(operationRuns)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
lt(operationRuns.createdAt, before),
|
||||||
|
sql`${operationRuns.status} NOT IN ('running', 'pending_approval')`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const events = await this.context.drizzle
|
||||||
|
.delete(operationEvents)
|
||||||
|
.where(lt(operationEvents.createdAt, before));
|
||||||
|
await this.onWrite?.();
|
||||||
|
return rowsAffected(runs) + rowsAffected(events);
|
||||||
|
}
|
||||||
|
|
||||||
|
async countRuns(): Promise<number> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select({ value: sql<number>`count(*)` })
|
||||||
|
.from(operationRuns);
|
||||||
|
return countValue(rows[0]?.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** History outlives the account, but the name goes with it. */
|
||||||
|
async anonymizeUser(userId: string): Promise<void> {
|
||||||
|
await this.context.drizzle
|
||||||
|
.update(operationRuns)
|
||||||
|
.set({ username: "deleted user" })
|
||||||
|
.where(eq(operationRuns.userId, userId));
|
||||||
|
await this.onWrite?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
|
import type { AuthOverrideProtocol } from "../../../types/auth-protocols.js";
|
||||||
|
import { sharedFolderAuthOverrides } from "../db/schema.js";
|
||||||
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
|
import { deleteReturning, upsert } from "./returning.js";
|
||||||
|
|
||||||
|
/** "A / B / C" and the folders above it, closest first. */
|
||||||
|
export function folderAndParents(folder: string | null | undefined): string[] {
|
||||||
|
const parts = (folder ?? "")
|
||||||
|
.split(" / ")
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const out: string[] = [];
|
||||||
|
for (let i = parts.length; i > 0; i--) {
|
||||||
|
out.push(parts.slice(0, i).join(" / "));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SharedFolderAuthOverrideRepository {
|
||||||
|
constructor(
|
||||||
|
private readonly context: DatabaseContext,
|
||||||
|
private readonly onWrite?: () => void | Promise<void>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async findCredentialId(
|
||||||
|
userId: string,
|
||||||
|
folder: string,
|
||||||
|
protocol: AuthOverrideProtocol,
|
||||||
|
): Promise<number | null> {
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select({ credentialId: sharedFolderAuthOverrides.credentialId })
|
||||||
|
.from(sharedFolderAuthOverrides)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sharedFolderAuthOverrides.userId, userId),
|
||||||
|
eq(sharedFolderAuthOverrides.folder, folder),
|
||||||
|
eq(sharedFolderAuthOverrides.protocol, protocol),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
return rows[0]?.credentialId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The login set on the host's folder or the closest folder above it, for
|
||||||
|
* one protocol. Null when none is set.
|
||||||
|
*/
|
||||||
|
async findClosest(
|
||||||
|
userId: string,
|
||||||
|
hostFolder: string | null | undefined,
|
||||||
|
protocol: AuthOverrideProtocol,
|
||||||
|
): Promise<{ folder: string; credentialId: number } | null> {
|
||||||
|
const folders = folderAndParents(hostFolder);
|
||||||
|
if (folders.length === 0) return null;
|
||||||
|
const rows = await this.context.drizzle
|
||||||
|
.select({
|
||||||
|
folder: sharedFolderAuthOverrides.folder,
|
||||||
|
credentialId: sharedFolderAuthOverrides.credentialId,
|
||||||
|
})
|
||||||
|
.from(sharedFolderAuthOverrides)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sharedFolderAuthOverrides.userId, userId),
|
||||||
|
eq(sharedFolderAuthOverrides.protocol, protocol),
|
||||||
|
inArray(sharedFolderAuthOverrides.folder, folders),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const folder of folders) {
|
||||||
|
const row = rows.find((candidate) => candidate.folder === folder);
|
||||||
|
if (row) return row;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setCredential(
|
||||||
|
userId: string,
|
||||||
|
folder: string,
|
||||||
|
protocol: AuthOverrideProtocol,
|
||||||
|
credentialId: number,
|
||||||
|
): Promise<void> {
|
||||||
|
await upsert(
|
||||||
|
this.context,
|
||||||
|
sharedFolderAuthOverrides,
|
||||||
|
{ userId, folder, protocol, credentialId },
|
||||||
|
{
|
||||||
|
target: [
|
||||||
|
sharedFolderAuthOverrides.userId,
|
||||||
|
sharedFolderAuthOverrides.folder,
|
||||||
|
sharedFolderAuthOverrides.protocol,
|
||||||
|
],
|
||||||
|
set: { credentialId, updatedAt: new Date().toISOString() },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await this.onWrite?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearCredential(
|
||||||
|
userId: string,
|
||||||
|
folder: string,
|
||||||
|
protocol: AuthOverrideProtocol,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const rows = await deleteReturning(
|
||||||
|
this.context,
|
||||||
|
sharedFolderAuthOverrides,
|
||||||
|
and(
|
||||||
|
eq(sharedFolderAuthOverrides.userId, userId),
|
||||||
|
eq(sharedFolderAuthOverrides.folder, folder),
|
||||||
|
eq(sharedFolderAuthOverrides.protocol, protocol),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (rows.length > 0) await this.onWrite?.();
|
||||||
|
return rows.length > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { recentActivity } from "../../plugins/activity-provider.js";
|
|||||||
import {
|
import {
|
||||||
createCurrentApiKeyRepository,
|
createCurrentApiKeyRepository,
|
||||||
createCurrentAuditLogRepository,
|
createCurrentAuditLogRepository,
|
||||||
|
createCurrentOperationRepository,
|
||||||
createCurrentCredentialRepository,
|
createCurrentCredentialRepository,
|
||||||
createCurrentHostFolderRepository,
|
createCurrentHostFolderRepository,
|
||||||
createCurrentHostRepository,
|
createCurrentHostRepository,
|
||||||
@@ -61,6 +62,7 @@ export async function deleteUserAndRelatedData(
|
|||||||
|
|
||||||
await createCurrentRoleRepository().removeAllRolesFromUser(userId);
|
await createCurrentRoleRepository().removeAllRolesFromUser(userId);
|
||||||
await createCurrentAuditLogRepository().anonymizeByUserId(userId);
|
await createCurrentAuditLogRepository().anonymizeByUserId(userId);
|
||||||
|
await createCurrentOperationRepository().anonymizeUser(userId);
|
||||||
|
|
||||||
await createCurrentSshCredentialUsageRepository().deleteByUserId(userId);
|
await createCurrentSshCredentialUsageRepository().deleteByUserId(userId);
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,33 @@ export function applyHostKeyTypeUpdate(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const HOST_COLOR = /^#[0-9a-fA-F]{6}$/;
|
||||||
|
const HOST_ICON = /^[a-z0-9-]{1,64}$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Color and icon from a host write. A field left out stays as stored, so
|
||||||
|
* editors that don't know about them never clear them. Bad values store null.
|
||||||
|
*/
|
||||||
|
export function normalizeHostLookFields(values: Record<string, unknown>): {
|
||||||
|
color?: string | null;
|
||||||
|
icon?: string | null;
|
||||||
|
} {
|
||||||
|
const result: { color?: string | null; icon?: string | null } = {};
|
||||||
|
if (values.color !== undefined) {
|
||||||
|
result.color =
|
||||||
|
typeof values.color === "string" && HOST_COLOR.test(values.color)
|
||||||
|
? values.color.toLowerCase()
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
if (values.icon !== undefined) {
|
||||||
|
result.icon =
|
||||||
|
typeof values.icon === "string" && HOST_ICON.test(values.icon)
|
||||||
|
? values.icon
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
const PROTOCOL_ENABLE_FIELDS = ["enableSsh"] as const;
|
const PROTOCOL_ENABLE_FIELDS = ["enableSsh"] as const;
|
||||||
|
|
||||||
export function normalizeProtocolEnableFields(
|
export function normalizeProtocolEnableFields(
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
isOptionalAuthType,
|
isOptionalAuthType,
|
||||||
isOptionalBoolean,
|
isOptionalBoolean,
|
||||||
isValidPort,
|
isValidPort,
|
||||||
|
normalizeHostLookFields,
|
||||||
normalizeProtocolEnableFields,
|
normalizeProtocolEnableFields,
|
||||||
OWNER_PRIVATE_AUTH_FIELDS,
|
OWNER_PRIVATE_AUTH_FIELDS,
|
||||||
OWNER_PRIVATE_SSH_OPTION_FIELDS,
|
OWNER_PRIVATE_SSH_OPTION_FIELDS,
|
||||||
@@ -335,6 +336,7 @@ router.post(
|
|||||||
? JSON.stringify(portKnockSequence)
|
? JSON.stringify(portKnockSequence)
|
||||||
: null,
|
: null,
|
||||||
...normalizeProtocolEnableFields(hostData),
|
...normalizeProtocolEnableFields(hostData),
|
||||||
|
...normalizeHostLookFields(hostData),
|
||||||
sshPort: sshPort || port || 22,
|
sshPort: sshPort || port || 22,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -912,6 +914,7 @@ router.put(
|
|||||||
? JSON.stringify(portKnockSequence)
|
? JSON.stringify(portKnockSequence)
|
||||||
: null,
|
: null,
|
||||||
...normalizeProtocolEnableFields(hostData),
|
...normalizeProtocolEnableFields(hostData),
|
||||||
|
...normalizeHostLookFields(hostData),
|
||||||
sshPort: sshPort || port || 22,
|
sshPort: sshPort || port || 22,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,760 @@
|
|||||||
|
import express, { type Request, type Response } from "express";
|
||||||
|
import type {
|
||||||
|
ResourceListResult,
|
||||||
|
SignalResult,
|
||||||
|
} from "@termix-ssh/plugin-sdk/operations";
|
||||||
|
import { AuthManager } from "../../utils/auth-manager.js";
|
||||||
|
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||||
|
import { databaseLogger } from "../../utils/logger.js";
|
||||||
|
import { getErrorMessage } from "../../utils/error-message.js";
|
||||||
|
import { createCurrentOperationRepository } from "../repositories/factory.js";
|
||||||
|
import type { OperationRunRecord } from "../repositories/operation-repository.js";
|
||||||
|
import { buildCatalog } from "../../operations/catalog.js";
|
||||||
|
import {
|
||||||
|
cancelOperationRun,
|
||||||
|
decideOperationApproval,
|
||||||
|
OperationError,
|
||||||
|
runOperation,
|
||||||
|
} from "../../operations/gate.js";
|
||||||
|
import { getAction } from "../../operations/registry.js";
|
||||||
|
import {
|
||||||
|
flattenResourceRows,
|
||||||
|
readAcrossHosts,
|
||||||
|
} from "../../operations/resources.js";
|
||||||
|
import type { ActorOrigin } from "../../plugins/actor.js";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const authenticateJWT = AuthManager.getInstance().createAuthMiddleware();
|
||||||
|
const permissions = PermissionManager.getInstance();
|
||||||
|
|
||||||
|
/** Sees every run on the server, not only their own. */
|
||||||
|
export const VIEW_ALL_RUNS = "admin.operations.view";
|
||||||
|
|
||||||
|
type OperationsRequest = Request & {
|
||||||
|
userId: string;
|
||||||
|
apiKeyId?: string;
|
||||||
|
apiKeyScopes?: string[] | null;
|
||||||
|
actingAdminUserId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function originOf(req: OperationsRequest): ActorOrigin {
|
||||||
|
return { kind: req.apiKeyId ? "api" : "ui" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendError(res: Response, error: unknown, operation: string): void {
|
||||||
|
if (error instanceof OperationError) {
|
||||||
|
res.status(error.status).json({
|
||||||
|
error: error.message,
|
||||||
|
code: error.code,
|
||||||
|
details: error.details,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
databaseLogger.error("Operations route failed", error, { operation });
|
||||||
|
res.status(500).json({ error: getErrorMessage(error, "Request failed") });
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJson(text: string | null): unknown {
|
||||||
|
if (!text) return undefined;
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Never includes the sealed input. */
|
||||||
|
function publicRun(run: OperationRunRecord) {
|
||||||
|
return {
|
||||||
|
id: run.id,
|
||||||
|
actionId: run.actionId,
|
||||||
|
pluginId: run.pluginId,
|
||||||
|
pluginVersion: run.pluginVersion,
|
||||||
|
userId: run.userId,
|
||||||
|
username: run.username,
|
||||||
|
origin: parseJson(run.origin),
|
||||||
|
hostId: run.hostId,
|
||||||
|
hostName: run.hostName,
|
||||||
|
batchId: run.batchId,
|
||||||
|
dryRun: run.dryRun,
|
||||||
|
risk: run.risk,
|
||||||
|
status: run.status,
|
||||||
|
summary: run.summary,
|
||||||
|
changed: run.changed,
|
||||||
|
input: parseJson(run.input),
|
||||||
|
output: parseJson(run.output),
|
||||||
|
undoAvailable: run.undoInput !== null,
|
||||||
|
error: run.error,
|
||||||
|
errorCode: run.errorCode,
|
||||||
|
undoOf: run.undoOf,
|
||||||
|
createdAt: run.createdAt,
|
||||||
|
startedAt: run.startedAt,
|
||||||
|
finishedAt: run.finishedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function canSeeRun(
|
||||||
|
run: OperationRunRecord,
|
||||||
|
userId: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (run.userId === userId) return true;
|
||||||
|
if (await permissions.hasPermission(userId, VIEW_ALL_RUNS)) return true;
|
||||||
|
if (run.hostId === null) return false;
|
||||||
|
const access = await permissions.canAccessHost(userId, run.hostId, "manage");
|
||||||
|
return access.hasAccess;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostIdsFrom(value: unknown): number[] | undefined {
|
||||||
|
if (typeof value !== "string" || value === "") return undefined;
|
||||||
|
const ids = value
|
||||||
|
.split(",")
|
||||||
|
.map(Number)
|
||||||
|
.filter((id) => Number.isInteger(id));
|
||||||
|
return ids.length > 0 ? ids : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function limitFrom(value: unknown): number | undefined {
|
||||||
|
const limit = Number(value);
|
||||||
|
return Number.isInteger(limit) && limit > 0 ? limit : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /operations/catalog:
|
||||||
|
* get:
|
||||||
|
* summary: List what this user can do
|
||||||
|
* description: Every action, event, resource and signal from running plugins that the caller has permission for, with titles in the requested language.
|
||||||
|
* tags:
|
||||||
|
* - Operations
|
||||||
|
* parameters:
|
||||||
|
* - in: query
|
||||||
|
* name: lang
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* - in: query
|
||||||
|
* name: surface
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* enum: [assistant, mcp, palette, automations, mobile, cli]
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: The catalog.
|
||||||
|
*/
|
||||||
|
router.get("/catalog", authenticateJWT, async (req, res) => {
|
||||||
|
const { userId } = req as OperationsRequest;
|
||||||
|
try {
|
||||||
|
res.json(
|
||||||
|
await buildCatalog({
|
||||||
|
userId,
|
||||||
|
lang: typeof req.query.lang === "string" ? req.query.lang : undefined,
|
||||||
|
surface:
|
||||||
|
typeof req.query.surface === "string"
|
||||||
|
? (req.query.surface as never)
|
||||||
|
: undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
sendError(res, error, "operations_catalog");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /operations/catalog/{id}:
|
||||||
|
* get:
|
||||||
|
* summary: Describe one action
|
||||||
|
* tags:
|
||||||
|
* - Operations
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: The action.
|
||||||
|
* 404:
|
||||||
|
* description: No such action, or the caller may not use it.
|
||||||
|
*/
|
||||||
|
router.get("/catalog/:id", authenticateJWT, async (req, res) => {
|
||||||
|
const { userId } = req as OperationsRequest;
|
||||||
|
try {
|
||||||
|
const catalog = await buildCatalog({
|
||||||
|
userId,
|
||||||
|
lang: typeof req.query.lang === "string" ? req.query.lang : undefined,
|
||||||
|
});
|
||||||
|
const action = catalog.actions.find((entry) => entry.id === req.params.id);
|
||||||
|
if (!action) {
|
||||||
|
res.status(404).json({ error: "Action not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json(action);
|
||||||
|
} catch (error) {
|
||||||
|
sendError(res, error, "operations_catalog_action");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /operations/runs:
|
||||||
|
* get:
|
||||||
|
* summary: List runs
|
||||||
|
* description: The caller's own runs, runs on hosts they manage, or every run with the view permission.
|
||||||
|
* tags:
|
||||||
|
* - Operations
|
||||||
|
* parameters:
|
||||||
|
* - in: query
|
||||||
|
* name: hostId
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* - in: query
|
||||||
|
* name: actionId
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* - in: query
|
||||||
|
* name: status
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* - in: query
|
||||||
|
* name: batchId
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* - in: query
|
||||||
|
* name: before
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* - in: query
|
||||||
|
* name: limit
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Runs, newest first.
|
||||||
|
*/
|
||||||
|
router.get("/runs", authenticateJWT, async (req, res) => {
|
||||||
|
const { userId } = req as OperationsRequest;
|
||||||
|
try {
|
||||||
|
const viewAll = await permissions.hasPermission(userId, VIEW_ALL_RUNS);
|
||||||
|
const hostId = limitFrom(req.query.hostId);
|
||||||
|
let managed = false;
|
||||||
|
if (!viewAll && hostId !== undefined) {
|
||||||
|
managed = (await permissions.canAccessHost(userId, hostId, "manage"))
|
||||||
|
.hasAccess;
|
||||||
|
}
|
||||||
|
const runs = await createCurrentOperationRepository().listRuns(
|
||||||
|
{
|
||||||
|
userId: viewAll || managed ? undefined : userId,
|
||||||
|
hostId,
|
||||||
|
actionId:
|
||||||
|
typeof req.query.actionId === "string"
|
||||||
|
? req.query.actionId
|
||||||
|
: undefined,
|
||||||
|
status:
|
||||||
|
typeof req.query.status === "string" ? req.query.status : undefined,
|
||||||
|
batchId:
|
||||||
|
typeof req.query.batchId === "string" ? req.query.batchId : undefined,
|
||||||
|
before:
|
||||||
|
typeof req.query.before === "string" ? req.query.before : undefined,
|
||||||
|
},
|
||||||
|
limitFrom(req.query.limit),
|
||||||
|
);
|
||||||
|
res.json({ runs: runs.map(publicRun) });
|
||||||
|
} catch (error) {
|
||||||
|
sendError(res, error, "operations_runs");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /operations/runs/{id}:
|
||||||
|
* get:
|
||||||
|
* summary: Get a run
|
||||||
|
* tags:
|
||||||
|
* - Operations
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: The run.
|
||||||
|
* 404:
|
||||||
|
* description: Not found, or not visible to the caller.
|
||||||
|
*/
|
||||||
|
router.get("/runs/:id", authenticateJWT, async (req, res) => {
|
||||||
|
const { userId } = req as OperationsRequest;
|
||||||
|
try {
|
||||||
|
const run = await createCurrentOperationRepository().findRun(
|
||||||
|
String(req.params.id),
|
||||||
|
);
|
||||||
|
if (!run || !(await canSeeRun(run, userId))) {
|
||||||
|
res.status(404).json({ error: "Run not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json(publicRun(run));
|
||||||
|
} catch (error) {
|
||||||
|
sendError(res, error, "operations_run");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /operations/runs/{id}/cancel:
|
||||||
|
* post:
|
||||||
|
* summary: Cancel a run
|
||||||
|
* description: Stops a running action, or withdraws one waiting for approval.
|
||||||
|
* tags:
|
||||||
|
* - Operations
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Whether anything was cancelled.
|
||||||
|
* 404:
|
||||||
|
* description: Not found, or not visible to the caller.
|
||||||
|
*/
|
||||||
|
router.post("/runs/:id/cancel", authenticateJWT, async (req, res) => {
|
||||||
|
const { userId } = req as OperationsRequest;
|
||||||
|
try {
|
||||||
|
const run = await createCurrentOperationRepository().findRun(
|
||||||
|
String(req.params.id),
|
||||||
|
);
|
||||||
|
if (!run || !(await canSeeRun(run, userId))) {
|
||||||
|
res.status(404).json({ error: "Run not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ cancelled: await cancelOperationRun(run) });
|
||||||
|
} catch (error) {
|
||||||
|
sendError(res, error, "operations_cancel");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /operations/runs/{id}/undo:
|
||||||
|
* post:
|
||||||
|
* summary: Undo a run
|
||||||
|
* description: Runs the action's undo with the input the run recorded. It goes through the same checks as any run.
|
||||||
|
* tags:
|
||||||
|
* - Operations
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: The new run.
|
||||||
|
* 404:
|
||||||
|
* description: Not found, or nothing to undo.
|
||||||
|
*/
|
||||||
|
router.post("/runs/:id/undo", authenticateJWT, async (req, res) => {
|
||||||
|
const authReq = req as OperationsRequest;
|
||||||
|
try {
|
||||||
|
const run = await createCurrentOperationRepository().findRun(
|
||||||
|
String(req.params.id),
|
||||||
|
);
|
||||||
|
const undo = run ? getAction(run.actionId)?.action.undo : undefined;
|
||||||
|
if (
|
||||||
|
!run ||
|
||||||
|
!undo ||
|
||||||
|
!run.undoInput ||
|
||||||
|
!(await canSeeRun(run, authReq.userId))
|
||||||
|
) {
|
||||||
|
res.status(404).json({ error: "Nothing to undo" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json(
|
||||||
|
await runOperation({
|
||||||
|
actionId: undo,
|
||||||
|
hostId: run.hostId ?? undefined,
|
||||||
|
input: parseJson(run.undoInput),
|
||||||
|
confirmed: req.body?.confirmed === true,
|
||||||
|
undoOf: run.id,
|
||||||
|
userId: authReq.userId,
|
||||||
|
origin: originOf(authReq),
|
||||||
|
apiKeyScopes: authReq.apiKeyScopes,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
sendError(res, error, "operations_undo");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /operations/approvals:
|
||||||
|
* get:
|
||||||
|
* summary: List waiting approvals
|
||||||
|
* description: Requests the caller could decide, with the exact input that would run.
|
||||||
|
* tags:
|
||||||
|
* - Operations
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Waiting approvals.
|
||||||
|
*/
|
||||||
|
router.get("/approvals", authenticateJWT, async (req, res) => {
|
||||||
|
const { userId } = req as OperationsRequest;
|
||||||
|
try {
|
||||||
|
const repository = createCurrentOperationRepository();
|
||||||
|
const approvals = [];
|
||||||
|
for (const approval of await repository.listPendingApprovals()) {
|
||||||
|
const run = await repository.findRun(approval.runId);
|
||||||
|
if (run && (await canSeeRun(run, userId))) {
|
||||||
|
approvals.push({ ...approval, run: publicRun(run) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.json({ approvals });
|
||||||
|
} catch (error) {
|
||||||
|
sendError(res, error, "operations_approvals");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /operations/approvals/{id}:
|
||||||
|
* post:
|
||||||
|
* summary: Approve or reject a request
|
||||||
|
* description: One shot. What runs is the sealed request, never anything sent here.
|
||||||
|
* tags:
|
||||||
|
* - Operations
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* required: [approve]
|
||||||
|
* properties:
|
||||||
|
* approve:
|
||||||
|
* type: boolean
|
||||||
|
* comment:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: The run's outcome.
|
||||||
|
* 403:
|
||||||
|
* description: The caller may not decide this.
|
||||||
|
* 409:
|
||||||
|
* description: Already decided, expired, or the host changed.
|
||||||
|
*/
|
||||||
|
router.post("/approvals/:id", authenticateJWT, async (req, res) => {
|
||||||
|
const authReq = req as OperationsRequest;
|
||||||
|
if (typeof req.body?.approve !== "boolean") {
|
||||||
|
res.status(400).json({ error: "approve must be true or false" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A key could be handed to a script, so a person decides from a session.
|
||||||
|
if (authReq.apiKeyScopes) {
|
||||||
|
res.status(403).json({ error: "This API key may not approve" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
res.json(
|
||||||
|
await decideOperationApproval({
|
||||||
|
approvalId: String(req.params.id),
|
||||||
|
approverId: authReq.userId,
|
||||||
|
approve: req.body.approve,
|
||||||
|
comment:
|
||||||
|
typeof req.body.comment === "string" ? req.body.comment : undefined,
|
||||||
|
impersonated: !!authReq.actingAdminUserId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
sendError(res, error, "operations_approve");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /operations/events:
|
||||||
|
* get:
|
||||||
|
* summary: List declared events
|
||||||
|
* tags:
|
||||||
|
* - Operations
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Declared events from running plugins, plus core's run events.
|
||||||
|
*/
|
||||||
|
router.get("/events", authenticateJWT, async (req, res) => {
|
||||||
|
const { userId } = req as OperationsRequest;
|
||||||
|
try {
|
||||||
|
const catalog = await buildCatalog({
|
||||||
|
userId,
|
||||||
|
lang: typeof req.query.lang === "string" ? req.query.lang : undefined,
|
||||||
|
});
|
||||||
|
res.json({ events: catalog.events });
|
||||||
|
} catch (error) {
|
||||||
|
sendError(res, error, "operations_events");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /operations/timeline:
|
||||||
|
* get:
|
||||||
|
* summary: Recent events
|
||||||
|
* description: Events the caller may see, newest first. Events tied to a host need access to it.
|
||||||
|
* tags:
|
||||||
|
* - Operations
|
||||||
|
* parameters:
|
||||||
|
* - in: query
|
||||||
|
* name: hostId
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* - in: query
|
||||||
|
* name: before
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* - in: query
|
||||||
|
* name: limit
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Events.
|
||||||
|
*/
|
||||||
|
router.get("/timeline", authenticateJWT, async (req, res) => {
|
||||||
|
const { userId } = req as OperationsRequest;
|
||||||
|
try {
|
||||||
|
const rows = await createCurrentOperationRepository().listEvents(
|
||||||
|
{
|
||||||
|
hostId: limitFrom(req.query.hostId),
|
||||||
|
before:
|
||||||
|
typeof req.query.before === "string" ? req.query.before : undefined,
|
||||||
|
},
|
||||||
|
limitFrom(req.query.limit),
|
||||||
|
);
|
||||||
|
const viewAll = await permissions.hasPermission(userId, VIEW_ALL_RUNS);
|
||||||
|
const access = new Map<number, boolean>();
|
||||||
|
const events = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.hostId === null) {
|
||||||
|
if (viewAll) events.push(row);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let allowed = access.get(row.hostId);
|
||||||
|
if (allowed === undefined) {
|
||||||
|
allowed = (
|
||||||
|
await permissions.canAccessHost(userId, row.hostId, "connect")
|
||||||
|
).hasAccess;
|
||||||
|
access.set(row.hostId, allowed);
|
||||||
|
}
|
||||||
|
if (allowed) events.push(row);
|
||||||
|
}
|
||||||
|
res.json({
|
||||||
|
events: events.map((event) => ({
|
||||||
|
...event,
|
||||||
|
payload: parseJson(event.payload),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
sendError(res, error, "operations_timeline");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /operations/signals:
|
||||||
|
* get:
|
||||||
|
* summary: Read status tiles
|
||||||
|
* description: Answers from memory right away and refreshes stale hosts behind it, so a page should poll. Reads hosts the caller can reach.
|
||||||
|
* tags:
|
||||||
|
* - Operations
|
||||||
|
* parameters:
|
||||||
|
* - in: query
|
||||||
|
* name: hostIds
|
||||||
|
* description: Comma separated. Leave out for every host.
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* - in: query
|
||||||
|
* name: ids
|
||||||
|
* description: Comma separated signal ids. Leave out for all.
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: One entry per signal with per host results.
|
||||||
|
*/
|
||||||
|
router.get("/signals", authenticateJWT, async (req, res) => {
|
||||||
|
const authReq = req as OperationsRequest;
|
||||||
|
try {
|
||||||
|
const catalog = await buildCatalog({ userId: authReq.userId });
|
||||||
|
const wanted =
|
||||||
|
typeof req.query.ids === "string" && req.query.ids
|
||||||
|
? new Set(req.query.ids.split(","))
|
||||||
|
: null;
|
||||||
|
const hostIds = hostIdsFrom(req.query.hostIds);
|
||||||
|
const signals = [];
|
||||||
|
for (const signal of catalog.signals) {
|
||||||
|
if (wanted && !wanted.has(signal.id)) continue;
|
||||||
|
if (signal.scope === "server") {
|
||||||
|
const outcome = await runOperation({
|
||||||
|
actionId: signal.read,
|
||||||
|
internal: true,
|
||||||
|
userId: authReq.userId,
|
||||||
|
origin: originOf(authReq),
|
||||||
|
}).catch(() => null);
|
||||||
|
signals.push({
|
||||||
|
id: signal.id,
|
||||||
|
hosts: [],
|
||||||
|
result: outcome?.data as SignalResult | undefined,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
signals.push({
|
||||||
|
id: signal.id,
|
||||||
|
hosts: await readAcrossHosts<SignalResult>({
|
||||||
|
userId: authReq.userId,
|
||||||
|
actionId: signal.read,
|
||||||
|
hostIds,
|
||||||
|
origin: originOf(authReq),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.json({ signals });
|
||||||
|
} catch (error) {
|
||||||
|
sendError(res, error, "operations_signals");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /operations/resources/{id}:
|
||||||
|
* get:
|
||||||
|
* summary: Read one kind of thing across hosts
|
||||||
|
* description: Rows from cache plus each host's state (ok, loading, offline, error, no-access). Answers right away and refreshes stale hosts behind it, so a page should poll until no host is loading.
|
||||||
|
* tags:
|
||||||
|
* - Operations
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* - in: query
|
||||||
|
* name: hostIds
|
||||||
|
* description: Comma separated. Leave out for every host.
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* - in: query
|
||||||
|
* name: refresh
|
||||||
|
* schema:
|
||||||
|
* type: boolean
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Rows and per host state.
|
||||||
|
* 404:
|
||||||
|
* description: No such resource, or the caller may not read it.
|
||||||
|
*/
|
||||||
|
router.get("/resources/:id", authenticateJWT, async (req, res) => {
|
||||||
|
const authReq = req as OperationsRequest;
|
||||||
|
try {
|
||||||
|
const catalog = await buildCatalog({ userId: authReq.userId });
|
||||||
|
const resource = catalog.resources.find(
|
||||||
|
(entry) => entry.id === req.params.id,
|
||||||
|
);
|
||||||
|
if (!resource) {
|
||||||
|
res.status(404).json({ error: "Resource not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reads = await readAcrossHosts<ResourceListResult>({
|
||||||
|
userId: authReq.userId,
|
||||||
|
actionId: resource.list,
|
||||||
|
hostIds: hostIdsFrom(req.query.hostIds),
|
||||||
|
refresh: req.query.refresh === "true",
|
||||||
|
origin: originOf(authReq),
|
||||||
|
});
|
||||||
|
res.json(flattenResourceRows(reads, resource.key));
|
||||||
|
} catch (error) {
|
||||||
|
sendError(res, error, "operations_resources");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /operations/{id}/run:
|
||||||
|
* post:
|
||||||
|
* summary: Run an action
|
||||||
|
* description: Goes through permission, host access, input and approval checks. A change returns as soon as it starts unless wait is true, then poll the run. A read always answers inline.
|
||||||
|
* tags:
|
||||||
|
* - Operations
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* hostId:
|
||||||
|
* type: integer
|
||||||
|
* resourceKey:
|
||||||
|
* type: string
|
||||||
|
* input:
|
||||||
|
* type: object
|
||||||
|
* dryRun:
|
||||||
|
* type: boolean
|
||||||
|
* confirmed:
|
||||||
|
* type: boolean
|
||||||
|
* idempotencyKey:
|
||||||
|
* type: string
|
||||||
|
* wait:
|
||||||
|
* type: boolean
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: The outcome, or a run waiting for approval.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid input.
|
||||||
|
* 403:
|
||||||
|
* description: Not allowed.
|
||||||
|
* 409:
|
||||||
|
* description: Needs a confirm, or safe mode is on.
|
||||||
|
*/
|
||||||
|
router.post("/:id/run", authenticateJWT, async (req, res) => {
|
||||||
|
const authReq = req as OperationsRequest;
|
||||||
|
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
res.json(
|
||||||
|
await runOperation({
|
||||||
|
actionId: String(req.params.id),
|
||||||
|
hostId: typeof body.hostId === "number" ? body.hostId : undefined,
|
||||||
|
resourceKey:
|
||||||
|
typeof body.resourceKey === "string" ? body.resourceKey : undefined,
|
||||||
|
input: body.input,
|
||||||
|
dryRun: body.dryRun === true,
|
||||||
|
confirmed: body.confirmed === true,
|
||||||
|
idempotencyKey:
|
||||||
|
typeof body.idempotencyKey === "string"
|
||||||
|
? body.idempotencyKey.slice(0, 128)
|
||||||
|
: undefined,
|
||||||
|
userId: authReq.userId,
|
||||||
|
origin: originOf(authReq),
|
||||||
|
apiKeyScopes: authReq.apiKeyScopes,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
sendError(res, error, "operations_run_action");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -244,7 +244,9 @@ export async function listPreLoginPlugins(): Promise<
|
|||||||
* `assetVersion` is its cache key, `css` says a stylesheet sits beside
|
* `assetVersion` is its cache key, `css` says a stylesheet sits beside
|
||||||
* it, and `locales` lists the languages it ships ("en" and xx_YY).
|
* it, and `locales` lists the languages it ships ("en" and xx_YY).
|
||||||
* `dependencies` and `optionalDependencies` let the loader activate
|
* `dependencies` and `optionalDependencies` let the loader activate
|
||||||
* frontends in the same order the server does.
|
* frontends in the same order the server does. `engine` holds the
|
||||||
|
* manifest's `api` and `termix` ranges, so other clients (the mobile
|
||||||
|
* app) can tell whether they understand the plugin.
|
||||||
*
|
*
|
||||||
* On a desktop linked to a server, `runsOnServer` marks plugins that
|
* On a desktop linked to a server, `runsOnServer` marks plugins that
|
||||||
* only run on that server and stay off here on purpose, and, for
|
* only run on that server and stay off here on purpose, and, for
|
||||||
@@ -303,6 +305,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
let features: string[] = [];
|
let features: string[] = [];
|
||||||
let docs: string | undefined;
|
let docs: string | undefined;
|
||||||
let env: PluginEnvVar[] = [];
|
let env: PluginEnvVar[] = [];
|
||||||
|
const engine: { api?: string; termix?: string } = {};
|
||||||
let desktopMode: unknown = loaded
|
let desktopMode: unknown = loaded
|
||||||
? resolvePluginDesktopMode(loaded.manifest)
|
? resolvePluginDesktopMode(loaded.manifest)
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -322,7 +325,14 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
optionalDependencies?: Record<string, string>;
|
optionalDependencies?: Record<string, string>;
|
||||||
desktop?: unknown;
|
desktop?: unknown;
|
||||||
runtime?: unknown;
|
runtime?: unknown;
|
||||||
|
engine?: { api?: unknown; termix?: unknown };
|
||||||
};
|
};
|
||||||
|
if (typeof manifest?.engine?.api === "string") {
|
||||||
|
engine.api = manifest.engine.api;
|
||||||
|
}
|
||||||
|
if (typeof manifest?.engine?.termix === "string") {
|
||||||
|
engine.termix = manifest.engine.termix;
|
||||||
|
}
|
||||||
desktopMode = loaded
|
desktopMode = loaded
|
||||||
? resolvePluginDesktopMode(loaded.manifest)
|
? resolvePluginDesktopMode(loaded.manifest)
|
||||||
: (manifest?.desktop ??
|
: (manifest?.desktop ??
|
||||||
@@ -365,6 +375,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
? "safe-mode"
|
? "safe-mode"
|
||||||
: (loaded?.state ?? record.state),
|
: (loaded?.state ?? record.state),
|
||||||
runsOnServer: linked && desktopMode === "server",
|
runsOnServer: linked && desktopMode === "server",
|
||||||
|
engine,
|
||||||
contributes,
|
contributes,
|
||||||
icon,
|
icon,
|
||||||
dependencies,
|
dependencies,
|
||||||
@@ -2695,6 +2706,10 @@ router.put(
|
|||||||
* summary: Upload a .tmxplug to install from a file
|
* summary: Upload a .tmxplug to install from a file
|
||||||
* description: >
|
* description: >
|
||||||
* Developer mode only. The body is the raw .tmxplug (at most 50 MB).
|
* Developer mode only. The body is the raw .tmxplug (at most 50 MB).
|
||||||
|
The release's .sig may come base64 encoded in the
|
||||||
|
X-Termix-Plugin-Signature header; an official one makes the file
|
||||||
|
install as an official plugin, and it may then fill in a plugin
|
||||||
|
whose download failed.
|
||||||
* The file is checked (archive, manifest, plugin API) and held for 30
|
* The file is checked (archive, manifest, plugin API) and held for 30
|
||||||
* minutes, but nothing is loaded. The answer lists what it asks for so
|
* minutes, but nothing is loaded. The answer lists what it asks for so
|
||||||
* the admin can agree, then POST /plugins/upload/{token}/install
|
* the admin can agree, then POST /plugins/upload/{token}/install
|
||||||
@@ -2740,7 +2755,13 @@ router.post(
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const { stageUpload } = await import("../../plugins/manage.js");
|
const { stageUpload } = await import("../../plugins/manage.js");
|
||||||
res.json(await stageUpload(req.body));
|
// The release's .sig, base64 encoded, so an official file installs as
|
||||||
|
// official (and may write the tables its 2.8 version left).
|
||||||
|
const header = req.get("x-termix-plugin-signature");
|
||||||
|
const signature = header
|
||||||
|
? Buffer.from(header, "base64").toString("utf8")
|
||||||
|
: null;
|
||||||
|
res.json(await stageUpload(req.body, signature));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sendManageError(
|
sendManageError(
|
||||||
res,
|
res,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { isAuthOverrideProtocol } from "../../hosts/protocol-auth/registry.js";
|
import { isAuthOverrideProtocol } from "../../hosts/protocol-auth/registry.js";
|
||||||
import {
|
import {
|
||||||
assertHostsUnlocked,
|
assertHostsUnlocked,
|
||||||
|
hostLock,
|
||||||
hasHostLocks,
|
hasHostLocks,
|
||||||
requireUnlockedHost,
|
requireUnlockedHost,
|
||||||
sendHostLocked,
|
sendHostLocked,
|
||||||
@@ -36,8 +37,10 @@ import {
|
|||||||
createCurrentHostResolutionRepository,
|
createCurrentHostResolutionRepository,
|
||||||
createCurrentRbacAccessRepository,
|
createCurrentRbacAccessRepository,
|
||||||
createCurrentRoleRepository,
|
createCurrentRoleRepository,
|
||||||
|
createCurrentSharedFolderAuthOverrideRepository,
|
||||||
createCurrentUserRepository,
|
createCurrentUserRepository,
|
||||||
} from "../repositories/factory.js";
|
} from "../repositories/factory.js";
|
||||||
|
import { findUsableCredential } from "../../hosts/usable-credential.js";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -722,14 +725,40 @@ router.get(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** The host grants that went to the same user or role as a folder rule. */
|
||||||
|
export function grantsForFolderRule<
|
||||||
|
T extends { userId?: string | null; roleId?: number | null },
|
||||||
|
>(entries: T[], rule: { userId?: string | null; roleId?: number | null }): T[] {
|
||||||
|
return entries.filter((entry) =>
|
||||||
|
rule.userId
|
||||||
|
? entry.userId === rule.userId
|
||||||
|
: rule.roleId != null && entry.roleId === rule.roleId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
* /rbac/folder/access/{id}:
|
* /rbac/folder/access/{id}:
|
||||||
* delete:
|
* delete:
|
||||||
* summary: Stop a folder share from applying to hosts added later
|
* summary: Stop a folder share from applying to hosts added later
|
||||||
* description: Grants already fanned out to hosts stay; revoke those per host.
|
* description: Grants already fanned out to hosts stay, unless fromHosts=true, which also removes the same user's or role's access from every host in the folder and its subfolders that the caller may manage sharing on.
|
||||||
* tags:
|
* tags:
|
||||||
* - RBAC
|
* - RBAC
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* - in: query
|
||||||
|
* name: fromHosts
|
||||||
|
* schema:
|
||||||
|
* type: boolean
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Removed. removedFromHosts counts the host grants removed.
|
||||||
|
* 404:
|
||||||
|
* description: No such rule.
|
||||||
*/
|
*/
|
||||||
router.delete(
|
router.delete(
|
||||||
"/folder/access/:id",
|
"/folder/access/:id",
|
||||||
@@ -741,11 +770,35 @@ router.delete(
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const repository = createCurrentFolderAccessRepository();
|
const repository = createCurrentFolderAccessRepository();
|
||||||
if (!(await repository.findById(ruleId, req.userId!))) {
|
const rule = await repository.findById(ruleId, req.userId!);
|
||||||
|
if (!rule) {
|
||||||
return res.status(404).json({ error: "Rule not found" });
|
return res.status(404).json({ error: "Rule not found" });
|
||||||
}
|
}
|
||||||
await repository.deleteById(ruleId, req.userId!);
|
await repository.deleteById(ruleId, req.userId!);
|
||||||
res.json({ success: true });
|
|
||||||
|
let removedFromHosts = 0;
|
||||||
|
if (req.query.fromHosts === "true") {
|
||||||
|
const hosts =
|
||||||
|
await createCurrentHostFolderRepository().listHostsInFolder(
|
||||||
|
req.userId!,
|
||||||
|
rule.folder,
|
||||||
|
);
|
||||||
|
const access = createCurrentRbacAccessRepository();
|
||||||
|
for (const host of hosts) {
|
||||||
|
if (hostLock(host.id)) continue;
|
||||||
|
if (!(await canManageHostSharing(req.userId!, host.id)).allowed)
|
||||||
|
continue;
|
||||||
|
let changed = false;
|
||||||
|
const entries = await access.listHostAccess(host.id);
|
||||||
|
for (const entry of grantsForFolderRule(entries, rule)) {
|
||||||
|
await access.revokeHostAccess(entry.id, host.id);
|
||||||
|
removedFromHosts++;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (changed) await pruneHostGrants(host.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.json({ success: true, removedFromHosts });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.error("Failed to delete folder access rule", error, {
|
databaseLogger.error("Failed to delete folder access rule", error, {
|
||||||
operation: "delete_folder_access",
|
operation: "delete_folder_access",
|
||||||
@@ -2174,4 +2227,157 @@ router.get(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** A folder path as hosts store it ("Parent / Child"), or null. */
|
||||||
|
function folderParam(value: unknown): string | null {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const folder = value
|
||||||
|
.split(" / ")
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" / ");
|
||||||
|
return folder && folder.length <= 255 ? folder : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /rbac/folder-auth/{protocol}:
|
||||||
|
* get:
|
||||||
|
* summary: Get the current user's personal login for shared hosts in a folder
|
||||||
|
* tags: [RBAC]
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: protocol
|
||||||
|
* required: true
|
||||||
|
* schema: { type: string }
|
||||||
|
* - in: query
|
||||||
|
* name: folder
|
||||||
|
* required: true
|
||||||
|
* schema: { type: string }
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: The folder, protocol and credential id, or null when none is set.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid folder or protocol.
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
"/folder-auth/:protocol",
|
||||||
|
authenticateJWT,
|
||||||
|
requireDataAccess,
|
||||||
|
async (req: express.Request, res: express.Response) => {
|
||||||
|
try {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId!;
|
||||||
|
const protocol = req.params.protocol;
|
||||||
|
const folder = folderParam(req.query.folder);
|
||||||
|
if (!folder) return res.status(400).json({ error: "Invalid folder" });
|
||||||
|
if (!isAuthOverrideProtocol(protocol)) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Invalid authentication protocol" });
|
||||||
|
}
|
||||||
|
const credentialId =
|
||||||
|
await createCurrentSharedFolderAuthOverrideRepository().findCredentialId(
|
||||||
|
userId,
|
||||||
|
folder,
|
||||||
|
protocol,
|
||||||
|
);
|
||||||
|
res.json({ folder, protocol, credentialId });
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error("Failed to get folder login", error);
|
||||||
|
res.status(500).json({ error: "Failed to fetch credential" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /rbac/folder-auth/{protocol}:
|
||||||
|
* put:
|
||||||
|
* summary: Set the current user's personal login for shared hosts in a folder
|
||||||
|
* description: >
|
||||||
|
* Used for every host shared with the user in this folder and its
|
||||||
|
* subfolders that has no personal login of its own. credentialId null
|
||||||
|
* clears it.
|
||||||
|
* tags: [RBAC]
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: protocol
|
||||||
|
* required: true
|
||||||
|
* schema: { type: string }
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* required: [folder, credentialId]
|
||||||
|
* properties:
|
||||||
|
* folder: { type: string }
|
||||||
|
* credentialId: { type: integer, nullable: true }
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Saved.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid folder, protocol or credential id.
|
||||||
|
* 404:
|
||||||
|
* description: The credential is not one the user can use.
|
||||||
|
*/
|
||||||
|
router.put(
|
||||||
|
"/folder-auth/:protocol",
|
||||||
|
authenticateJWT,
|
||||||
|
requireDataAccess,
|
||||||
|
async (req: express.Request, res: express.Response) => {
|
||||||
|
try {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId!;
|
||||||
|
const protocol = req.params.protocol;
|
||||||
|
const folder = folderParam(req.body?.folder);
|
||||||
|
const credentialId = req.body?.credentialId ?? null;
|
||||||
|
if (!folder) return res.status(400).json({ error: "Invalid folder" });
|
||||||
|
if (!isAuthOverrideProtocol(protocol)) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Invalid authentication protocol" });
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
credentialId !== null &&
|
||||||
|
(!Number.isInteger(credentialId) || credentialId <= 0)
|
||||||
|
) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "credentialId must be a positive integer or null",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const repository = createCurrentSharedFolderAuthOverrideRepository();
|
||||||
|
if (credentialId === null) {
|
||||||
|
await repository.clearCredential(userId, folder, protocol);
|
||||||
|
} else {
|
||||||
|
if (!(await findUsableCredential(credentialId, userId))) {
|
||||||
|
return res.status(404).json({ error: "Credential not found" });
|
||||||
|
}
|
||||||
|
await repository.setCredential(userId, folder, protocol, credentialId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||||
|
await logAudit({
|
||||||
|
userId,
|
||||||
|
username: await getAuditUsername(userId),
|
||||||
|
action:
|
||||||
|
credentialId === null
|
||||||
|
? "clear_shared_folder_auth_override"
|
||||||
|
: "set_shared_folder_auth_override",
|
||||||
|
resourceType: "folder",
|
||||||
|
resourceName: folder,
|
||||||
|
details: JSON.stringify({ protocol, credentialId }),
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
success: true,
|
||||||
|
}).catch(() => undefined);
|
||||||
|
|
||||||
|
res.json({ success: true, folder, protocol, credentialId });
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error("Failed to set folder login", error);
|
||||||
|
res.status(500).json({ error: "Failed to update credential" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { RequestHandler, Router } from "express";
|
import type { RequestHandler, Router } from "express";
|
||||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||||
|
import { API_KEY_SCOPES } from "../../operations/key-scopes.js";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
@@ -42,6 +43,12 @@ export function registerUserApiKeyRoutes(
|
|||||||
* type: string
|
* type: string
|
||||||
* format: date-time
|
* format: date-time
|
||||||
* description: Optional expiration date. Null means the key never expires.
|
* 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:
|
* responses:
|
||||||
* 201:
|
* 201:
|
||||||
* description: API key created. Contains the full token (shown only once).
|
* 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) => {
|
router.post("/api-keys", requireAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { name, userId: targetUserId, expiresAt } = req.body;
|
const { name, userId: targetUserId, expiresAt, scopes } = req.body;
|
||||||
const apiKeyRepository = createCurrentApiKeyRepository();
|
const apiKeyRepository = createCurrentApiKeyRepository();
|
||||||
const userRepository = createCurrentUserRepository();
|
const userRepository = createCurrentUserRepository();
|
||||||
|
|
||||||
@@ -86,6 +93,21 @@ export function registerUserApiKeyRoutes(
|
|||||||
expiresAtValue = parsed.toISOString();
|
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 rawToken = "tmx_" + crypto.randomBytes(32).toString("hex");
|
||||||
const tokenPrefix = rawToken.substring(0, 12);
|
const tokenPrefix = rawToken.substring(0, 12);
|
||||||
const tokenHash = await bcrypt.hash(rawToken, 10);
|
const tokenHash = await bcrypt.hash(rawToken, 10);
|
||||||
@@ -102,6 +124,7 @@ export function registerUserApiKeyRoutes(
|
|||||||
expiresAt: expiresAtValue,
|
expiresAt: expiresAtValue,
|
||||||
lastUsedAt: null,
|
lastUsedAt: null,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
scopes: scopesValue ? JSON.stringify(scopesValue) : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const actorId = (req as AuthenticatedRequest).userId;
|
const actorId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -119,6 +142,7 @@ export function registerUserApiKeyRoutes(
|
|||||||
details: JSON.stringify({
|
details: JSON.stringify({
|
||||||
targetUserId,
|
targetUserId,
|
||||||
targetUsername: targetUser.username,
|
targetUsername: targetUser.username,
|
||||||
|
scopes: scopesValue,
|
||||||
}),
|
}),
|
||||||
ipAddress,
|
ipAddress,
|
||||||
userAgent,
|
userAgent,
|
||||||
@@ -133,6 +157,7 @@ export function registerUserApiKeyRoutes(
|
|||||||
tokenPrefix,
|
tokenPrefix,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
expiresAt: expiresAtValue,
|
expiresAt: expiresAtValue,
|
||||||
|
scopes: scopesValue,
|
||||||
token: rawToken,
|
token: rawToken,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||||
|
import type { RequestHandler, Router } from "express";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { authLogger } from "../../utils/logger.js";
|
||||||
|
import { createCurrentUserRepository } from "../repositories/factory.js";
|
||||||
|
import { deleteUserAndRelatedData } from "./delete-user-data.js";
|
||||||
|
|
||||||
|
function isNonEmptyString(val: unknown): val is string {
|
||||||
|
return typeof val === "string" && val.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerUserDeleteAccountRoutes(
|
||||||
|
router: Router,
|
||||||
|
authenticateJWT: RequestHandler,
|
||||||
|
): void {
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /users/delete-account:
|
||||||
|
* delete:
|
||||||
|
* summary: Delete user account
|
||||||
|
* description: Deletes the authenticated user's account and all of their data.
|
||||||
|
* tags:
|
||||||
|
* - Users
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* password:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Account deleted successfully.
|
||||||
|
* 400:
|
||||||
|
* description: Password is required.
|
||||||
|
* 401:
|
||||||
|
* description: Incorrect password.
|
||||||
|
* 403:
|
||||||
|
* description: Cannot delete external authentication accounts or the last admin user.
|
||||||
|
* 404:
|
||||||
|
* description: User not found.
|
||||||
|
* 500:
|
||||||
|
* description: Failed to delete account.
|
||||||
|
*/
|
||||||
|
router.delete("/delete-account", authenticateJWT, async (req, res) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
const { password } = req.body ?? {};
|
||||||
|
|
||||||
|
if (!isNonEmptyString(password)) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Password is required to delete account" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userRepository = createCurrentUserRepository();
|
||||||
|
const userRecord = await userRepository.findById(userId);
|
||||||
|
if (!userRecord) {
|
||||||
|
return res.status(404).json({ error: "User not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userRecord.isExternal) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error:
|
||||||
|
"Cannot delete external authentication accounts through this endpoint",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMatch = await bcrypt.compare(password, userRecord.passwordHash);
|
||||||
|
if (!isMatch) {
|
||||||
|
authLogger.warn(
|
||||||
|
`Incorrect password provided for account deletion: ${userRecord.username}`,
|
||||||
|
);
|
||||||
|
return res.status(401).json({ error: "Incorrect password" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userRecord.isAdmin && (await userRepository.countAdmins()) <= 1) {
|
||||||
|
return res
|
||||||
|
.status(403)
|
||||||
|
.json({ error: "Cannot delete the last admin user" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same cleanup as an admin delete, so plugins hear user.deleted too.
|
||||||
|
await deleteUserAndRelatedData(userId);
|
||||||
|
|
||||||
|
authLogger.success(`User account deleted: ${userRecord.username}`);
|
||||||
|
res.json({ message: "Account deleted successfully" });
|
||||||
|
} catch (err) {
|
||||||
|
authLogger.error("Failed to delete user account", err);
|
||||||
|
res.status(500).json({ error: "Failed to delete account" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ import { shownIdentity } from "../../sync/shown-identity.js";
|
|||||||
import { registerUserExternalAccountRoutes } from "./user-external-account-routes.js";
|
import { registerUserExternalAccountRoutes } from "./user-external-account-routes.js";
|
||||||
import { registerUserPasswordResetRoutes } from "./user-password-reset-routes.js";
|
import { registerUserPasswordResetRoutes } from "./user-password-reset-routes.js";
|
||||||
import { registerUserAdminRoutes } from "./user-admin-routes.js";
|
import { registerUserAdminRoutes } from "./user-admin-routes.js";
|
||||||
|
import { registerUserDeleteAccountRoutes } from "./user-delete-account-routes.js";
|
||||||
import { registerUserDataAccessRoutes } from "./user-data-access-routes.js";
|
import { registerUserDataAccessRoutes } from "./user-data-access-routes.js";
|
||||||
import { listExternalLoginMethods, registerAuthRoutes } from "./auth-routes.js";
|
import { listExternalLoginMethods, registerAuthRoutes } from "./auth-routes.js";
|
||||||
import { registerAuthCompatRoutes } from "./auth-compat-routes.js";
|
import { registerAuthCompatRoutes } from "./auth-compat-routes.js";
|
||||||
@@ -318,7 +319,7 @@ router.post("/proxy-login", async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
if (!isTrustedProxyAddress(sourceAddress, config.trustedProxies)) {
|
if (!isTrustedProxyAddress(sourceAddress, config.trustedProxies)) {
|
||||||
authLogger.warn(
|
authLogger.warn(
|
||||||
"Rejected proxy authentication from an untrusted source",
|
`Rejected proxy authentication from an untrusted source: ${sourceAddress ?? "unknown"}`,
|
||||||
{
|
{
|
||||||
operation: "trusted_proxy_auth_rejected",
|
operation: "trusted_proxy_auth_rejected",
|
||||||
sourceAddress,
|
sourceAddress,
|
||||||
@@ -1229,86 +1230,7 @@ router.patch("/password-reset-allowed", authenticateJWT, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
registerUserDeleteAccountRoutes(router, authenticateJWT);
|
||||||
* @openapi
|
|
||||||
* /users/delete-account:
|
|
||||||
* delete:
|
|
||||||
* summary: Delete user account
|
|
||||||
* description: Deletes the authenticated user's account.
|
|
||||||
* tags:
|
|
||||||
* - Users
|
|
||||||
* requestBody:
|
|
||||||
* required: true
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* password:
|
|
||||||
* type: string
|
|
||||||
* responses:
|
|
||||||
* 200:
|
|
||||||
* description: Account deleted successfully.
|
|
||||||
* 400:
|
|
||||||
* description: Password is required.
|
|
||||||
* 401:
|
|
||||||
* description: Incorrect password.
|
|
||||||
* 403:
|
|
||||||
* description: Cannot delete external authentication accounts or the last admin user.
|
|
||||||
* 404:
|
|
||||||
* description: User not found.
|
|
||||||
* 500:
|
|
||||||
* description: Failed to delete account.
|
|
||||||
*/
|
|
||||||
router.delete("/delete-account", authenticateJWT, async (req, res) => {
|
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
|
||||||
const { password } = req.body;
|
|
||||||
|
|
||||||
if (!isNonEmptyString(password)) {
|
|
||||||
return res
|
|
||||||
.status(400)
|
|
||||||
.json({ error: "Password is required to delete account" });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const userRecord = await findCurrentUser(userId);
|
|
||||||
if (!userRecord) {
|
|
||||||
return res.status(404).json({ error: "User not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (userRecord.isExternal) {
|
|
||||||
return res.status(403).json({
|
|
||||||
error:
|
|
||||||
"Cannot delete external authentication accounts through this endpoint",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const isMatch = await bcrypt.compare(password, userRecord.passwordHash);
|
|
||||||
if (!isMatch) {
|
|
||||||
authLogger.warn(
|
|
||||||
`Incorrect password provided for account deletion: ${userRecord.username}`,
|
|
||||||
);
|
|
||||||
return res.status(401).json({ error: "Incorrect password" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (userRecord.isAdmin) {
|
|
||||||
const adminCount = await createCurrentUserRepository().countAdmins();
|
|
||||||
if (adminCount <= 1) {
|
|
||||||
return res
|
|
||||||
.status(403)
|
|
||||||
.json({ error: "Cannot delete the last admin user" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await createCurrentUserRepository().delete(userId);
|
|
||||||
|
|
||||||
authLogger.success(`User account deleted: ${userRecord.username}`);
|
|
||||||
res.json({ message: "Account deleted successfully" });
|
|
||||||
} catch (err) {
|
|
||||||
authLogger.error("Failed to delete user account", err);
|
|
||||||
res.status(500).json({ error: "Failed to delete account" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
registerUserPasswordResetRoutes(router, { authManager });
|
registerUserPasswordResetRoutes(router, { authManager });
|
||||||
|
|
||||||
|
|||||||
@@ -99,7 +99,12 @@ async function prepareKey(
|
|||||||
|
|
||||||
if (passphrase) config.passphrase = passphrase;
|
if (passphrase) config.passphrase = passphrase;
|
||||||
// Lets a server that wants key then password (or password only) still work.
|
// Lets a server that wants key then password (or password only) still work.
|
||||||
if (host.password) config.password = host.password;
|
// ssh2 tries password first by default, so it has none left to answer with
|
||||||
|
// once the key was accepted as the first step.
|
||||||
|
if (host.password) {
|
||||||
|
config.password = host.password;
|
||||||
|
config.authHandler = ["publickey", "password", "keyboard-interactive"];
|
||||||
|
}
|
||||||
|
|
||||||
const certPublicKey = host.certPublicKey;
|
const certPublicKey = host.certPublicKey;
|
||||||
if (certPublicKey && certPublicKey.trim()) {
|
if (certPublicKey && certPublicKey.trim()) {
|
||||||
|
|||||||
@@ -7,7 +7,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Duplex } from "node:stream";
|
import type { Duplex } from "node:stream";
|
||||||
import type { PluginSshTransportProvider } from "@termix-ssh/plugin-sdk/backend";
|
import type {
|
||||||
|
PluginSshTransportHost,
|
||||||
|
PluginSshTransportProvider,
|
||||||
|
} from "@termix-ssh/plugin-sdk/backend";
|
||||||
|
|
||||||
interface Entry {
|
interface Entry {
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
@@ -33,15 +36,13 @@ export function clearSshTransports(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Opens a stream to the host if a provider claims it, else null. */
|
/** Opens a stream to the host if a provider claims it, else null. */
|
||||||
export async function openProvidedTransport(host: {
|
export async function openProvidedTransport(
|
||||||
id: number;
|
host: PluginSshTransportHost,
|
||||||
ip: string;
|
): Promise<Duplex | null> {
|
||||||
port: number;
|
|
||||||
}): Promise<Duplex | null> {
|
|
||||||
for (const { provider } of providers.values()) {
|
for (const { provider } of providers.values()) {
|
||||||
let claimed = false;
|
let claimed = false;
|
||||||
try {
|
try {
|
||||||
claimed = provider.claims(host.id);
|
claimed = provider.claims(host.id, host);
|
||||||
} catch {
|
} catch {
|
||||||
claimed = false;
|
claimed = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,6 +99,10 @@ export async function openSshTransport(
|
|||||||
id: host.id,
|
id: host.id,
|
||||||
ip: host.ip,
|
ip: host.ip,
|
||||||
port: host.port || 22,
|
port: host.port || 22,
|
||||||
|
userId: host.userId ?? null,
|
||||||
|
syncId: host.syncId ?? null,
|
||||||
|
connectionOrigin: host.connectionOrigin ?? null,
|
||||||
|
localOnly: Boolean(host.localOnly),
|
||||||
});
|
});
|
||||||
if (provided) {
|
if (provided) {
|
||||||
config.sock = provided;
|
config.sock = provided;
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ export interface SshConnectHost {
|
|||||||
port: number;
|
port: number;
|
||||||
username: string;
|
username: string;
|
||||||
userId?: string | null;
|
userId?: string | null;
|
||||||
|
syncId?: string | null;
|
||||||
|
connectionOrigin?: "local" | "remote" | null;
|
||||||
|
localOnly?: boolean | null;
|
||||||
authType?: string | null;
|
authType?: string | null;
|
||||||
password?: string | null;
|
password?: string | null;
|
||||||
key?: string | Buffer | null;
|
key?: string | Buffer | null;
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ async function exportRow(
|
|||||||
: resolvedHost.tags || [],
|
: resolvedHost.tags || [],
|
||||||
pin: !!resolvedHost.pin,
|
pin: !!resolvedHost.pin,
|
||||||
notes: resolvedHost.notes || null,
|
notes: resolvedHost.notes || null,
|
||||||
|
color: resolvedHost.color || null,
|
||||||
|
icon: resolvedHost.icon || null,
|
||||||
// Every plugin's host settings, secrets redacted, for import to hand back.
|
// Every plugin's host settings, secrets redacted, for import to hand back.
|
||||||
pluginSettings: pluginSettings.get(host.id as number) ?? {},
|
pluginSettings: pluginSettings.get(host.id as number) ?? {},
|
||||||
// Each plugin protocol's login, secrets included like the SSH ones.
|
// Each plugin protocol's login, secrets included like the SSH ones.
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { applyPluginHostImportSettings } from "../../database/routes/host-plugin
|
|||||||
import {
|
import {
|
||||||
isNonEmptyString,
|
isNonEmptyString,
|
||||||
isValidPort,
|
isValidPort,
|
||||||
|
normalizeHostLookFields,
|
||||||
normalizeImportedHost,
|
normalizeImportedHost,
|
||||||
} from "../../database/routes/host-normalizers.js";
|
} from "../../database/routes/host-normalizers.js";
|
||||||
import { hasHostLocks, hostLock, manualCreateBlockedBy } from "../locks.js";
|
import { hasHostLocks, hostLock, manualCreateBlockedBy } from "../locks.js";
|
||||||
@@ -571,6 +572,7 @@ export async function runBulkImport(
|
|||||||
? "true"
|
? "true"
|
||||||
: "false",
|
: "false",
|
||||||
notes: hostData.notes || null,
|
notes: hostData.notes || null,
|
||||||
|
...normalizeHostLookFields(hostData as Record<string, unknown>),
|
||||||
useSocks5: hostData.useSocks5 ? 1 : 0,
|
useSocks5: hostData.useSocks5 ? 1 : 0,
|
||||||
socks5Host: hostData.socks5Host || null,
|
socks5Host: hostData.socks5Host || null,
|
||||||
socks5Port: hostData.socks5Port || null,
|
socks5Port: hostData.socks5Port || null,
|
||||||
|
|||||||
@@ -87,11 +87,18 @@ export async function resolveHostCredentials(
|
|||||||
...recipientHost,
|
...recipientHost,
|
||||||
authOverrides: {
|
authOverrides: {
|
||||||
...authOverrides,
|
...authOverrides,
|
||||||
ssh: {
|
ssh: resolution.folder
|
||||||
credentialId: resolution.credentialId,
|
? {
|
||||||
required: false,
|
required: false,
|
||||||
ownerAuthShared: !!host.shareSshAuth,
|
ownerAuthShared: !!host.shareSshAuth,
|
||||||
},
|
folder: resolution.folder,
|
||||||
|
folderCredentialId: resolution.credentialId,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
credentialId: resolution.credentialId,
|
||||||
|
required: false,
|
||||||
|
ownerAuthShared: !!host.shareSshAuth,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
authType:
|
authType:
|
||||||
credential.key || credential.privateKey
|
credential.key || credential.privateKey
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import net from "node:net";
|
|||||||
import type { Duplex } from "node:stream";
|
import type { Duplex } from "node:stream";
|
||||||
import { createWebSocketStream, type WebSocket } from "ws";
|
import { createWebSocketStream, type WebSocket } from "ws";
|
||||||
import type { PluginWebSocketConnection } from "@termix-ssh/plugin-sdk/backend";
|
import type { PluginWebSocketConnection } from "@termix-ssh/plugin-sdk/backend";
|
||||||
import { resolveHostById } from "./host-resolver.js";
|
import { resolveHostById, resolveHostBySyncId } from "./host-resolver.js";
|
||||||
import { openSshTransport } from "./connect/transport.js";
|
import { openSshTransport } from "./connect/transport.js";
|
||||||
import type { MutableConnectConfig, SshConnectHost } from "./connect/types.js";
|
import type { MutableConnectConfig, SshConnectHost } from "./connect/types.js";
|
||||||
import { sshLogger } from "../utils/logger.js";
|
import { sshLogger } from "../utils/logger.js";
|
||||||
@@ -35,12 +35,13 @@ async function handleRelay(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const ws = connection.socket as WebSocket;
|
const ws = connection.socket as WebSocket;
|
||||||
const url = String((connection.request as { url?: string }).url ?? "");
|
const url = String((connection.request as { url?: string }).url ?? "");
|
||||||
const hostId = Number(
|
const params = new URL(url, "http://relay").searchParams;
|
||||||
new URL(url, "http://relay").searchParams.get("hostId"),
|
const hostId = Number(params.get("hostId"));
|
||||||
);
|
// A linked desktop knows its hosts by sync id, not by this server's id.
|
||||||
|
const syncId = params.get("syncId");
|
||||||
const userId = connection.userId;
|
const userId = connection.userId;
|
||||||
|
|
||||||
if (!userId || !Number.isSafeInteger(hostId) || hostId <= 0) {
|
if (!userId || (!syncId && (!Number.isSafeInteger(hostId) || hostId <= 0))) {
|
||||||
close(ws, 1008, "A host is required");
|
close(ws, 1008, "A host is required");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -49,7 +50,9 @@ async function handleRelay(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const host = await resolveHostById(hostId, userId);
|
const host = syncId
|
||||||
|
? await resolveHostBySyncId(syncId, userId)
|
||||||
|
: await resolveHostById(hostId, userId);
|
||||||
if (!host) {
|
if (!host) {
|
||||||
close(ws, 1008, "Host not found or access denied");
|
close(ws, 1008, "Host not found or access denied");
|
||||||
return;
|
return;
|
||||||
@@ -93,7 +96,8 @@ async function handleRelay(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
sshLogger.warn("A relay could not reach its host", {
|
sshLogger.warn("A relay could not reach its host", {
|
||||||
operation: "ssh_relay_open",
|
operation: "ssh_relay_open",
|
||||||
hostId,
|
hostId: syncId ? undefined : hostId,
|
||||||
|
syncId,
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
});
|
});
|
||||||
finish();
|
finish();
|
||||||
|
|||||||
@@ -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,231 @@
|
|||||||
|
/**
|
||||||
|
* Reads for All hosts tables and status tiles.
|
||||||
|
*
|
||||||
|
* Asking every host over SSH on each page load cannot hold up, so a request
|
||||||
|
* answers right away from memory and refreshes stale hosts behind it. The page
|
||||||
|
* polls and the table fills in as hosts answer. Results stay in memory, never
|
||||||
|
* SQLite, since a save rewrites the whole database.
|
||||||
|
*
|
||||||
|
* Keys include the user: someone a host is shared with can have their own
|
||||||
|
* login there and see different rows.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ResourceListResult,
|
||||||
|
SignalResult,
|
||||||
|
SignalStatus,
|
||||||
|
} from "@termix-ssh/plugin-sdk/operations";
|
||||||
|
import { RESOURCE_FRESH_MS } from "./constants.js";
|
||||||
|
import { coalesce } from "./limits.js";
|
||||||
|
import { getAction } from "./registry.js";
|
||||||
|
import { matchesWhen } from "@termix-ssh/plugin-sdk/operations";
|
||||||
|
import { loadOperationHost } from "./hosts.js";
|
||||||
|
import type { ActorOrigin } from "../plugins/actor.js";
|
||||||
|
import { hostStatus } from "../plugins/host-status-provider.js";
|
||||||
|
import { PermissionManager } from "../utils/permission-manager.js";
|
||||||
|
import { loadProbeTargets } from "../plugins/ctx-probe.js";
|
||||||
|
|
||||||
|
export type HostReadState =
|
||||||
|
"ok" | "loading" | "offline" | "error" | "no-access" | "not-applicable";
|
||||||
|
|
||||||
|
interface CacheEntry {
|
||||||
|
value: unknown;
|
||||||
|
error?: string;
|
||||||
|
fetchedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostRead<T> {
|
||||||
|
hostId: number;
|
||||||
|
state: HostReadState;
|
||||||
|
value?: T;
|
||||||
|
error?: string;
|
||||||
|
fetchedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = new Map<string, CacheEntry>();
|
||||||
|
const MAX_ENTRIES = 20_000;
|
||||||
|
|
||||||
|
function cacheKey(userId: string, actionId: string, hostId: number): string {
|
||||||
|
return `${userId}|${actionId}|${hostId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** After a change, the next read of this host goes back to the host. */
|
||||||
|
export function dropResourceCache(hostId: number): void {
|
||||||
|
const suffix = `|${hostId}`;
|
||||||
|
for (const key of cache.keys()) {
|
||||||
|
if (key.endsWith(suffix)) cache.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function remember(key: string, entry: CacheEntry): void {
|
||||||
|
if (cache.size >= MAX_ENTRIES) {
|
||||||
|
const oldest = cache.keys().next().value;
|
||||||
|
if (oldest) cache.delete(oldest);
|
||||||
|
}
|
||||||
|
cache.delete(key);
|
||||||
|
cache.set(key, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOffline(hostId: number): boolean {
|
||||||
|
const status = hostStatus.get(hostId) as
|
||||||
|
string | { status?: string } | null | undefined;
|
||||||
|
const value = typeof status === "string" ? status : status?.status;
|
||||||
|
return value === "offline";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReadOptions {
|
||||||
|
userId: string;
|
||||||
|
actionId: string;
|
||||||
|
hostIds?: number[];
|
||||||
|
refresh?: boolean;
|
||||||
|
input?: Record<string, unknown>;
|
||||||
|
origin: ActorOrigin;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshHost(
|
||||||
|
options: ReadOptions,
|
||||||
|
hostId: number,
|
||||||
|
key: string,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return coalesce(key, async () => {
|
||||||
|
const { runOperation, OperationError } = await import("./gate.js");
|
||||||
|
try {
|
||||||
|
const outcome = await runOperation({
|
||||||
|
actionId: options.actionId,
|
||||||
|
hostId,
|
||||||
|
input: options.input,
|
||||||
|
internal: true,
|
||||||
|
userId: options.userId,
|
||||||
|
origin: options.origin,
|
||||||
|
});
|
||||||
|
if (outcome.status !== "success") {
|
||||||
|
remember(key, {
|
||||||
|
value: undefined,
|
||||||
|
error: outcome.error ?? outcome.status,
|
||||||
|
fetchedAt: Date.now(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
remember(key, { value: outcome.data, fetchedAt: Date.now() });
|
||||||
|
} catch (error) {
|
||||||
|
remember(key, {
|
||||||
|
value: undefined,
|
||||||
|
error:
|
||||||
|
error instanceof OperationError || error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: String(error),
|
||||||
|
fetchedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One entry per host the user can reach. Never waits on a host. */
|
||||||
|
export async function readAcrossHosts<T>(
|
||||||
|
options: ReadOptions,
|
||||||
|
): Promise<HostRead<T>[]> {
|
||||||
|
const registration = getAction(options.actionId);
|
||||||
|
if (!registration) return [];
|
||||||
|
const { action } = registration;
|
||||||
|
const { getPluginRuntime } = await import("../plugins/index.js");
|
||||||
|
const manifest = getPluginRuntime().loader.get(
|
||||||
|
registration.pluginId,
|
||||||
|
)?.manifest;
|
||||||
|
if (!manifest) return [];
|
||||||
|
|
||||||
|
const permissions = PermissionManager.getInstance();
|
||||||
|
const targets = await loadProbeTargets(options.userId, options.hostIds);
|
||||||
|
const reads: HostRead<T>[] = [];
|
||||||
|
|
||||||
|
for (const target of targets) {
|
||||||
|
const hostId = target.id;
|
||||||
|
const access = await permissions.canAccessHost(
|
||||||
|
options.userId,
|
||||||
|
hostId,
|
||||||
|
action.hostAccess ?? "connect",
|
||||||
|
);
|
||||||
|
if (!access.hasAccess) {
|
||||||
|
reads.push({ hostId, state: "no-access" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (action.when) {
|
||||||
|
const host = await loadOperationHost(hostId, manifest);
|
||||||
|
if (!host || !matchesWhen(action.when, host)) continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = cacheKey(options.userId, options.actionId, hostId);
|
||||||
|
const cached = cache.get(key);
|
||||||
|
const fresh = cached && Date.now() - cached.fetchedAt < RESOURCE_FRESH_MS;
|
||||||
|
|
||||||
|
if (isOffline(hostId)) {
|
||||||
|
reads.push({
|
||||||
|
hostId,
|
||||||
|
state: "offline",
|
||||||
|
value: cached?.value as T | undefined,
|
||||||
|
fetchedAt: cached
|
||||||
|
? new Date(cached.fetchedAt).toISOString()
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!fresh || options.refresh) void refreshHost(options, hostId, key);
|
||||||
|
if (!cached) {
|
||||||
|
reads.push({ hostId, state: "loading" });
|
||||||
|
} else if (cached.error) {
|
||||||
|
reads.push({
|
||||||
|
hostId,
|
||||||
|
state: "error",
|
||||||
|
error: cached.error,
|
||||||
|
fetchedAt: new Date(cached.fetchedAt).toISOString(),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
reads.push({
|
||||||
|
hostId,
|
||||||
|
state: "ok",
|
||||||
|
value: cached.value as T,
|
||||||
|
fetchedAt: new Date(cached.fetchedAt).toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return reads;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResourceRow {
|
||||||
|
hostId: number;
|
||||||
|
key: string;
|
||||||
|
row: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flattens per host reads into table rows, keeping each host's state. */
|
||||||
|
export function flattenResourceRows(
|
||||||
|
reads: HostRead<ResourceListResult>[],
|
||||||
|
keyField: string,
|
||||||
|
): { rows: ResourceRow[]; hosts: Omit<HostRead<unknown>, "value">[] } {
|
||||||
|
const rows: ResourceRow[] = [];
|
||||||
|
const hosts: Omit<HostRead<unknown>, "value">[] = [];
|
||||||
|
for (const read of reads) {
|
||||||
|
const { value, ...state } = read;
|
||||||
|
hosts.push(state);
|
||||||
|
for (const row of value?.rows ?? []) {
|
||||||
|
rows.push({ hostId: read.hostId, key: String(row[keyField]), row });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { rows, hosts };
|
||||||
|
}
|
||||||
|
|
||||||
|
const WORST: SignalStatus[] = ["fail", "warn", "unknown", "ok"];
|
||||||
|
|
||||||
|
/** The worst status across hosts, for a server level tile. */
|
||||||
|
export function worstStatus(
|
||||||
|
results: (SignalResult | undefined)[],
|
||||||
|
): SignalStatus {
|
||||||
|
for (const status of WORST) {
|
||||||
|
if (results.some((result) => result?.status === status)) return status;
|
||||||
|
}
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test seam. */
|
||||||
|
export function clearResourceCache(): void {
|
||||||
|
cache.clear();
|
||||||
|
}
|
||||||
@@ -20,7 +20,28 @@
|
|||||||
|
|
||||||
import { AsyncLocalStorage } from "node:async_hooks";
|
import { AsyncLocalStorage } from "node:async_hooks";
|
||||||
|
|
||||||
interface ActorStore {
|
/** How a call reached core, for run history and approval rules. */
|
||||||
|
export interface ActorOrigin {
|
||||||
|
kind: "ui" | "api" | "plugin";
|
||||||
|
/** Which plugin asked, set by core from the ctx that made the call. */
|
||||||
|
pluginId?: string;
|
||||||
|
/** What the plugin was doing: automation, assistant, mcp, rollout and so on. */
|
||||||
|
via?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Follows a call through plugin work, so nothing loses who started it. */
|
||||||
|
export interface ActorTrail {
|
||||||
|
origin?: ActorOrigin;
|
||||||
|
apiKeyId?: string;
|
||||||
|
/** Null means a key with full access. */
|
||||||
|
apiKeyScopes?: string[] | null;
|
||||||
|
/** The operation run this work belongs to. */
|
||||||
|
runId?: string;
|
||||||
|
/** How many operations or events led here. */
|
||||||
|
depth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActorStore extends ActorTrail {
|
||||||
userId: string;
|
userId: string;
|
||||||
/** Where the identity came from, for the audit line. */
|
/** Where the identity came from, for the audit line. */
|
||||||
source: "request" | "asUser" | "service";
|
source: "request" | "asUser" | "service";
|
||||||
@@ -35,8 +56,34 @@ export function runAsActor<T>(
|
|||||||
source: ActorStore["source"],
|
source: ActorStore["source"],
|
||||||
fn: () => T,
|
fn: () => T,
|
||||||
sessionId?: string,
|
sessionId?: string,
|
||||||
|
trail?: ActorTrail,
|
||||||
): T {
|
): T {
|
||||||
return storage.run({ userId, source, sessionId }, fn);
|
const parent = storage.getStore();
|
||||||
|
return storage.run(
|
||||||
|
{
|
||||||
|
origin: parent?.origin,
|
||||||
|
apiKeyId: parent?.apiKeyId,
|
||||||
|
apiKeyScopes: parent?.apiKeyScopes,
|
||||||
|
runId: parent?.runId,
|
||||||
|
depth: parent?.depth,
|
||||||
|
...trail,
|
||||||
|
userId,
|
||||||
|
source,
|
||||||
|
sessionId,
|
||||||
|
},
|
||||||
|
fn,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActorTrail(): ActorTrail {
|
||||||
|
const store = storage.getStore();
|
||||||
|
return {
|
||||||
|
origin: store?.origin,
|
||||||
|
apiKeyId: store?.apiKeyId,
|
||||||
|
apiKeyScopes: store?.apiKeyScopes,
|
||||||
|
runId: store?.runId,
|
||||||
|
depth: store?.depth,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getActorSessionId(): string | undefined {
|
export function getActorSessionId(): string | undefined {
|
||||||
|
|||||||
Loaded 100 of 228 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user