Compare commits

...
Author SHA1 Message Date
LukeGus 6c265c4bc6 chore: add ping to the docker image 2026-11-05 00:29:26 -06:00
LukeGus 57eb7fab5c chore: add cron, users, packages and certificates to the plugin table 2026-11-05 00:26:50 -06:00
LukeGus a73782e2bc chore: add backups plugin to readme 2026-11-05 00:12:46 -06:00
LukeGus 18e7f5689b fix: match select and segmented heights to inputs 2026-11-04 20:58:33 -06:00
LukeGus a91339f09f fix: widen chart axis labels to fit long units 2026-11-04 20:29:44 -06:00
LukeGus a2308c9d71 fix: plugin page clipping and badges, match select and textarea sizes 2026-11-04 20:03:15 -06:00
LukeGus 2ee31ac9cc fix: honor signal refreshSec when caching signal reads 2026-11-04 19:30:12 -06:00
LukeGus 4aead47049 fix: save every ui pref key changed in one burst so dashboard reset sticks 2026-11-04 19:07:02 -06:00
LukeGus 19bdd589f3 feat: shared tile look for cards and pages, plus new page pieces in SDK 1.1.0 2026-11-04 18:57:18 -06:00
LukeGus 1867be3f58 fix: keep line chart time labels from overlapping 2026-11-04 15:52:42 -06:00
LukeGus 09e04d076a fix: stop the jump host pipeline test failing under load 2026-11-04 15:30:21 -06:00
LukeGus ea4e2d2124 fix: remove the partial file when a desktop download is aborted early 2026-11-04 15:30:17 -06:00
LukeGus c7aa1c64b5 feat: new bundled plugins start on only if recommended, fetch missing shipped ones 2026-11-04 15:30:14 -06:00
LukeGus 9613b787a7 feat: shared tile grid for plugin pages and many-tile dashboard cards (SDK 1.0.26) 2026-11-04 15:29:46 -06:00
LukeGus 1c4fa52de3 fix: remove plugin updates the bundled copy caught up with 2026-11-04 08:11:34 -06:00
LukeGus 2e79bf9d0d feat: limit api keys to actions when they are made 2026-11-04 06:29:49 -06:00
LukeGus 03029cd372 feat: let a request approved in a plugin run by permissions 2026-11-03 23:03:20 -06:00
LukeGus 446596989e chore: add operations center to the plugin table 2026-11-03 22:55:45 -06:00
LukeGus b7f749cc9d feat: let a host click open the host page 2026-11-03 22:52:30 -06:00
LukeGus 3ae56a96d0 chore: bump the plugin sdk to 1.0.24 2026-11-03 22:34:51 -06:00
LukeGus 631f6ac9f7 feat: add host sections, tab embeds and the operations client to the plugin ui 2026-11-03 22:34:29 -06:00
LukeGus cc725c7889 feat: add the operations layer for declared plugin actions 2026-11-03 22:28:54 -06:00
LukeGus b87fe3c6a5 chore: rename vault to ssh certificate signer in the readme 2026-11-03 21:31:19 -06:00
LukeGus b9df36b09a feat: rework default plugins and keep unchosen plugins off in the first run picker 2026-11-03 21:29:39 -06:00
LukeGus 31df4b12e3 fix: drop the empty security step when no sign in plugin offers setup 2026-11-03 21:27:29 -06:00
LukeGus 80612c304b fix: match password field height to other inputs 2026-11-03 21:26:49 -06:00
LukeGus a2aafc11ec chore: add wireguard to the readme plugin table 2026-11-03 20:55:27 -06:00
LukeGus 3ab3022451 fix: stop alerts with no icon squeezing text to one word per line 2026-11-03 20:48:27 -06:00
LukeGus 037b0329bd chore: use the new wake-on-lan description in the readme 2026-11-03 20:38:02 -06:00
LukeGus 78a1e90d93 chore: update wake-on-lan readme description 2026-11-03 20:37:33 -06:00
144 changed files with 26773 additions and 368 deletions

No files matched your search

+35
View File
@@ -2,9 +2,44 @@
## 26.11.1
### Added
- Termix actions: plugins can declare what they do once, and it works from the API with permission checks, dry runs, run history and approvals
- API keys can be limited to reading or running actions, when an admin makes them
- A click on a host can open its host page, when a plugin offers one
- Plugins can build pages of tiles you drag and resize, and add many copies of one dashboard card, each with its own settings
- A plugin a newer release ships is downloaded on its own when your image does not have it, and tried again on the next start when offline
### Changed
- Plugin cards and pages use the dashboard look everywhere, with shared lines between sections instead of separate boxes with gaps
- The Plugins tab and plugin pages use the same look
- Line charts that cover a few minutes show seconds in their times
- Plugins a new release adds to the image start on only when they are recommended. The rest are installed and left off for an admin to turn on
- Host status, import and export, and credential tools now start on for new installs
- Sync now starts on in a new desktop app, so it can link to a server
- The first run plugin picker leaves plugins you did not choose off instead of removing them
- Dropdowns and text areas are the same size as the text fields next to them
- A page's own tabs sit above its search bar
### Fixed
- The first run plugin picker no longer sets the default layout and themes to Remove. They always stay on
- Alerts with no icon no longer squeeze their text to one word per line
- Password fields with the show button are the same height as the other fields next to them
- The Secure your account setup step no longer shows up empty when no passkey or authenticator plugin is on
- An installed plugin update older than the copy in a newer image is removed at startup instead of logging an error every boot
- Canceling a download to the desktop app right as it starts no longer leaves a partial file behind
- Changing two interface settings at once, like resetting the dashboard, now saves both instead of only the last one
- Release notes on a plugin's page are no longer cut off on the left
- The Community badge in Browse is no longer missing its top border
- Official plugin pages no longer show a stray divider before the author
- The version picker on a plugin's page is no longer empty for a plugin installed from a local build
- The plugin video no longer has a second border around it
- The hint at the bottom of Browse and the stored data footer on plugin pages have the same padding as the rest of the page
- Sign in methods on the Security page have a line between them
- Chart axis labels with long units, like KiB/s, are no longer cut off
## 26.11.0
+10 -2
View File
@@ -64,15 +64,18 @@ Plugins add everything else. The official ones ship with Termix, and admins can
| [Alerts](https://github.com/Termix-SSH/Plugin-Alerts) | One inbox for every alert in Termix, with delivery to webhooks, ntfy, Discord and email. |
| [Audit Tools](https://github.com/Termix-SSH/Plugin-Audit-Tools) | Send the audit log to your SIEM and export it as CSV or NDJSON. |
| [Automations](https://github.com/Termix-SSH/Plugin-Automations) | Run steps on your hosts on a schedule or when something happens. |
| [Backups](https://github.com/Termix-SSH/Plugin-Backups) | Back up folders, databases and Docker data on your hosts with restic, then browse, download and restore. |
| [Certificates](https://github.com/Termix-SSH/Plugin-Certificates) | See, issue and renew TLS certificates on your hosts with certbot and acme.sh, and get an alert before they expire. |
| [Classic Layout](https://github.com/Termix-SSH/Plugin-Classic-Layout) | The classic Termix layout with an icon rail, sidebar, tab bar and dock. |
| [Classic Themes](https://github.com/Termix-SSH/Plugin-Classic-Themes) | Light, dark and seven more color themes for Termix. |
| [Cloud Providers](https://github.com/Termix-SSH/Plugin-Cloud-Providers) | Add hosts from AWS, Azure, Google Cloud, DigitalOcean, Hetzner and other clouds, and keep them in sync. |
| [Credential Tools](https://github.com/Termix-SSH/Plugin-Credential-Tools) | Generate SSH keys, check them and copy a public key to a host. |
| [Cron](https://github.com/Termix-SSH/Plugin-Cron) | See, add and run cron jobs on your hosts, with a schedule preview and the system cron files. |
| [Databases](https://github.com/Termix-SSH/Plugin-Databases) | Browse and edit MySQL, MariaDB, PostgreSQL and SQLite databases on your hosts from one tab. |
| [Docker](https://github.com/Termix-SSH/Plugin-Docker) | Manage Docker and Podman containers on your hosts over SSH, with logs, stats and a console. |
| [File Manager](https://github.com/Termix-SSH/Plugin-File-Manager) | Browse, edit and move files over SFTP, including straight from one server to another. |
| [Firewall](https://github.com/Termix-SSH/Plugin-Firewall) | Manage UFW, firewalld, nftables and iptables rules on your hosts over SSH, with lockout protection. |
| [Fleets](https://github.com/Termix-SSH/Plugin-Fleets) | Group hosts into fleets and run commands, package actions and file transfers on all of them at once. |
| [HashiCorp Vault](https://github.com/Termix-SSH/Plugin-Vault) | Connect to hosts with short-lived SSH certificates signed by HashiCorp Vault. |
| [Homepage](https://github.com/Termix-SSH/Plugin-Homepage) | A drag and drop widget page for your servers and services, plus service links for the dashboard. |
| [Host Metrics](https://github.com/Termix-SSH/Plugin-Host-Metrics) | Live CPU, memory, disk, network and GPU stats, plus tools to manage your hosts. |
| [Host Status](https://github.com/Termix-SSH/Plugin-Host-Status) | Shows if each host is online with a dot in the host list, checked by connecting to its port. |
@@ -82,7 +85,9 @@ Plugins add everything else. The official ones ship with Termix, and admins can
| [LDAP](https://github.com/Termix-SSH/Plugin-LDAP) | Sign in with your LDAP or Active Directory username and password. |
| [Login Security](https://github.com/Termix-SSH/Plugin-Login-Security) | Sign people out when idle, limit session length, turn off remember me and require a second factor at login. |
| [Network Topology](https://github.com/Termix-SSH/Plugin-Network-Topology) | Draws your hosts and the links between them as a live, interactive graph. |
| [Operations Center](https://github.com/Termix-SSH/Plugin-Operations-Center) | Run, roll out, approve and track every plugin action across your hosts, with a page per host and tables across all of them. |
| [OPKSSH](https://github.com/Termix-SSH/Plugin-OPKSSH) | Connect to hosts with short-lived OpenPubkey SSH certificates after signing in with your identity provider. |
| [Packages](https://github.com/Termix-SSH/Plugin-Package-Manager) | See and install updates on your hosts, search, install and remove packages, and read the package history. |
| [Passkeys](https://github.com/Termix-SSH/Plugin-WebAuthn) | Sign in with a passkey or security key instead of a password. |
| [Proxmox](https://github.com/Termix-SSH/Plugin-Proxmox) | Import Proxmox VE guests as hosts, keep them in sync and watch node stats. |
| [Remote Desktop](https://github.com/Termix-SSH/Plugin-Remote-Desktop) | RDP, VNC and Telnet sessions in your browser, with clipboard, file transfer, sharing and recording. |
@@ -96,6 +101,7 @@ Plugins add everything else. The official ones ship with Termix, and admins can
| [Simple Layout](https://github.com/Termix-SSH/Plugin-Simple-Layout) | A simple Termix layout for small setups with a top bar, a Home screen and only the basics. |
| [Single sign-on](https://github.com/Termix-SSH/Plugin-SSO) | Sign in with any OpenID Connect provider, GitHub or Google. |
| [Snippets](https://github.com/Termix-SSH/Plugin-Snippets) | Save the commands you run often and run them in a terminal or on many hosts in one click. |
| [SSH Certificate Signer](https://github.com/Termix-SSH/Plugin-Vault) | Connect to hosts with short-lived SSH certificates signed by HashiCorp Vault or OpenBao. |
| [SSH Terminal](https://github.com/Termix-SSH/Plugin-SSH-Terminal) | SSH terminal sessions with tabs, split screen, history, macros and a local terminal in the desktop app. |
| [Step CA](https://github.com/Termix-SSH/Plugin-Step-CA) | Connect to hosts with short-lived SSH certificates from a smallstep step-ca server. |
| [Sync](https://github.com/Termix-SSH/Plugin-Sync) | Sync the desktop app with one or more servers, and manage other Termix servers as remote sites from a master. |
@@ -105,9 +111,11 @@ Plugins add everything else. The official ones ship with Termix, and admins can
| [TOTP](https://github.com/Termix-SSH/Plugin-TOTP) | Two-factor sign in with a code from an authenticator app, with backup codes. |
| [Tunnels](https://github.com/Termix-SSH/Plugin-Tunnels) | SSH port forwarding that reconnects on its own, plus client tunnels from the desktop app. |
| [Usage Statistics](https://github.com/Termix-SSH/Plugin-Telemetry) | Sends a small anonymous daily report so we can see how Termix is used. |
| [Wake-on-LAN](https://github.com/Termix-SSH/Plugin-Wake-On-LAN) | Wake sleeping machines by sending a Wake-on-LAN packet to their MAC address. |
| [Users](https://github.com/Termix-SSH/Plugin-User-Manager) | Manage the user accounts, groups, passwords and SSH keys on your hosts. |
| [Wake-on-LAN](https://github.com/Termix-SSH/Plugin-Wake-On-LAN) | Wake, shut down and track your machines from one power dashboard. |
| [Warpgate](https://github.com/Termix-SSH/Plugin-Warpgate) | Connect to hosts through a Warpgate SSH bastion. |
| [Web Endpoint](https://github.com/Termix-SSH/Plugin-Web-Endpoint) | Open a host's web UI inside Termix, directly or over an SSH tunnel. |
| [WireGuard](https://github.com/Termix-SSH/Plugin-WireGuard) | Install WireGuard on your hosts and manage interfaces, clients and configs from one tab. |
| [Workspaces](https://github.com/Termix-SSH/Plugin-Workspaces) | Save your open tabs and split layouts and reopen them in one click. |
Want to build your own? Start from the [plugin template](https://github.com/Termix-SSH/Termix-Plugin-Template). Plugins you can install are listed in the [registry](https://github.com/Termix-SSH/Termix-Registry). Turn on the community registry in Plugins, Registries to install plugins made by other people, or add your own registry. See [submitting a plugin](https://docs.termix.site/develop/community-registry) and [hosting your own registry](https://docs.termix.site/develop/registries#host-your-own-registry).
+1 -1
View File
@@ -114,7 +114,7 @@ ENV DATA_DIR=/app/data \
PORT=8080 \
NODE_ENV=production
RUN apt-get update && apt-get install -y nginx gettext-base openssl ca-certificates gosu wget && \
RUN apt-get update && apt-get install -y nginx gettext-base openssl ca-certificates gosu wget iputils-ping && \
update-ca-certificates && \
rm -rf /var/lib/apt/lists/* && \
mkdir -p /app/data /app/uploads /app/nginx /tmp/nginx && \
+21 -7
View File
@@ -67,7 +67,11 @@
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Credential-Tools/releases/download/v1.0.0/credential-tools-1.0.0.tmxplug",
"sha256": "413eff710b5c832a2575a890bbab1c90d28ea55f10cd7a7b94b851d51dd77dce",
"tier": "standard"
"tier": "standard",
"onboarding": {
"recommended": true,
"consent": false
}
},
{
"id": "databases",
@@ -128,7 +132,11 @@
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Host-Status/releases/download/v1.0.0/host-status-1.0.0.tmxplug",
"sha256": "1ba62577dc7373a2a2787bab7efdb89b544a30a8003b1de91ce51f372fc4f170",
"tier": "standard"
"tier": "standard",
"onboarding": {
"recommended": true,
"consent": false
}
},
{
"id": "hosts-config",
@@ -142,7 +150,11 @@
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Import-Export/releases/download/v1.0.1/import-export-1.0.1.tmxplug",
"sha256": "d8f2f865e1e6b226f4fe38501bf69b752f47a643bed1d9d127181083e27da2f0",
"tier": "standard"
"tier": "standard",
"onboarding": {
"recommended": true,
"consent": false
}
},
{
"id": "kubernetes",
@@ -288,9 +300,7 @@
"onboarding": {
"recommended": false,
"consent": false,
"enabledByEnv": [
"OIDC_CLIENT_ID"
]
"enabledByEnv": ["OIDC_CLIENT_ID"]
}
},
{
@@ -305,7 +315,11 @@
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Sync/releases/download/v1.1.0/sync-1.1.0.tmxplug",
"sha256": "9023d547f0e0bcef582c6ba7b7a8ab2ef68d8d0b973dde17d9e8c84d92b6429e",
"tier": "standard"
"tier": "standard",
"onboarding": {
"recommended": "desktop",
"consent": false
}
},
{
"id": "tailscale",
+9
View File
@@ -276,6 +276,15 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location ~ ^/operations(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location ~ ^/plugins(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
+9
View File
@@ -245,6 +245,15 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location ~ ^/operations(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location ~ ^/plugins(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
+70
View File
@@ -0,0 +1,70 @@
CREATE TABLE `operation_approvals` (
`id` varchar(255) NOT NULL,
`run_id` varchar(255) NOT NULL,
`requested_by` text,
`reason` text,
`allow_self` boolean NOT NULL DEFAULT false,
`status` varchar(255) NOT NULL,
`decided_by` text,
`comment` text,
`created_at` text NOT NULL,
`expires_at` varchar(255) NOT NULL,
`decided_at` text,
CONSTRAINT `operation_approvals_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `operation_events` (
`id` varchar(255) NOT NULL,
`event_id` text NOT NULL,
`plugin_id` text NOT NULL,
`host_id` int,
`severity` text,
`payload` text,
`run_id` text,
`created_at` varchar(255) NOT NULL,
CONSTRAINT `operation_events_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `operation_runs` (
`id` varchar(255) NOT NULL,
`action_id` text NOT NULL,
`action_version` int NOT NULL DEFAULT 1,
`plugin_id` text NOT NULL,
`plugin_version` text NOT NULL,
`user_id` varchar(255),
`username` text NOT NULL,
`origin` text NOT NULL,
`host_id` int,
`host_name` text,
`batch_id` varchar(255),
`dry_run` boolean NOT NULL DEFAULT false,
`risk` text NOT NULL,
`status` varchar(255) NOT NULL,
`summary` text,
`changed` boolean,
`input` text,
`sealed_input` text,
`output` text,
`undo_input` text,
`error` text,
`error_code` text,
`undo_of` text,
`idempotency_key` text,
`created_at` varchar(255) NOT NULL,
`started_at` text,
`finished_at` text,
CONSTRAINT `operation_runs_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
ALTER TABLE `api_keys` ADD `scopes` text;--> statement-breakpoint
ALTER TABLE `operation_approvals` ADD CONSTRAINT `operation_approvals_run_id_operation_runs_id_fk` FOREIGN KEY (`run_id`) REFERENCES `operation_runs`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `operation_runs` ADD CONSTRAINT `operation_runs_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `idx_operation_approvals_status` ON `operation_approvals` (`status`,`expires_at`);--> statement-breakpoint
CREATE INDEX `idx_operation_approvals_run` ON `operation_approvals` (`run_id`);--> statement-breakpoint
CREATE INDEX `idx_operation_events_created` ON `operation_events` (`created_at`);--> statement-breakpoint
CREATE INDEX `idx_operation_events_host_created` ON `operation_events` (`host_id`,`created_at`);--> statement-breakpoint
CREATE INDEX `idx_operation_runs_created` ON `operation_runs` (`created_at`);--> statement-breakpoint
CREATE INDEX `idx_operation_runs_user_created` ON `operation_runs` (`user_id`,`created_at`);--> statement-breakpoint
CREATE INDEX `idx_operation_runs_host_created` ON `operation_runs` (`host_id`,`created_at`);--> statement-breakpoint
CREATE INDEX `idx_operation_runs_status` ON `operation_runs` (`status`);--> statement-breakpoint
CREATE INDEX `idx_operation_runs_batch` ON `operation_runs` (`batch_id`);
File diff suppressed because it is too large. Load diff
+7
View File
@@ -547,6 +547,13 @@
"when": 1793304929772,
"tag": "0077_shared_folder_auth_overrides",
"breakpoints": true
},
{
"idx": 78,
"version": "5",
"when": 1793765383557,
"tag": "0078_sweet_king_cobra",
"breakpoints": true
}
]
}
@@ -0,0 +1,67 @@
CREATE TABLE "operation_approvals" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"run_id" varchar(255) NOT NULL,
"requested_by" text,
"reason" text,
"allow_self" boolean DEFAULT false NOT NULL,
"status" varchar(255) NOT NULL,
"decided_by" text,
"comment" text,
"created_at" text NOT NULL,
"expires_at" varchar(255) NOT NULL,
"decided_at" text
);
--> statement-breakpoint
CREATE TABLE "operation_events" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"event_id" text NOT NULL,
"plugin_id" text NOT NULL,
"host_id" integer,
"severity" text,
"payload" text,
"run_id" text,
"created_at" varchar(255) NOT NULL
);
--> statement-breakpoint
CREATE TABLE "operation_runs" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"action_id" text NOT NULL,
"action_version" integer DEFAULT 1 NOT NULL,
"plugin_id" text NOT NULL,
"plugin_version" text NOT NULL,
"user_id" varchar(255),
"username" text NOT NULL,
"origin" text NOT NULL,
"host_id" integer,
"host_name" text,
"batch_id" varchar(255),
"dry_run" boolean DEFAULT false NOT NULL,
"risk" text NOT NULL,
"status" varchar(255) NOT NULL,
"summary" text,
"changed" boolean,
"input" text,
"sealed_input" text,
"output" text,
"undo_input" text,
"error" text,
"error_code" text,
"undo_of" text,
"idempotency_key" text,
"created_at" varchar(255) NOT NULL,
"started_at" text,
"finished_at" text
);
--> statement-breakpoint
ALTER TABLE "api_keys" ADD COLUMN "scopes" text;--> statement-breakpoint
ALTER TABLE "operation_approvals" ADD CONSTRAINT "operation_approvals_run_id_operation_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."operation_runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "operation_runs" ADD CONSTRAINT "operation_runs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_operation_approvals_status" ON "operation_approvals" USING btree ("status","expires_at");--> statement-breakpoint
CREATE INDEX "idx_operation_approvals_run" ON "operation_approvals" USING btree ("run_id");--> statement-breakpoint
CREATE INDEX "idx_operation_events_created" ON "operation_events" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX "idx_operation_events_host_created" ON "operation_events" USING btree ("host_id","created_at");--> statement-breakpoint
CREATE INDEX "idx_operation_runs_created" ON "operation_runs" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX "idx_operation_runs_user_created" ON "operation_runs" USING btree ("user_id","created_at");--> statement-breakpoint
CREATE INDEX "idx_operation_runs_host_created" ON "operation_runs" USING btree ("host_id","created_at");--> statement-breakpoint
CREATE INDEX "idx_operation_runs_status" ON "operation_runs" USING btree ("status");--> statement-breakpoint
CREATE INDEX "idx_operation_runs_batch" ON "operation_runs" USING btree ("batch_id");
File diff suppressed because it is too large. Load diff
+7
View File
@@ -547,6 +547,13 @@
"when": 1793304928399,
"tag": "0077_shared_folder_auth_overrides",
"breakpoints": true
},
{
"idx": 78,
"version": "7",
"when": 1793765382955,
"tag": "0078_clean_living_mummy",
"breakpoints": true
}
]
}
@@ -0,0 +1,67 @@
CREATE TABLE `operation_approvals` (
`id` text PRIMARY KEY NOT NULL,
`run_id` text NOT NULL,
`requested_by` text,
`reason` text,
`allow_self` integer DEFAULT false NOT NULL,
`status` text NOT NULL,
`decided_by` text,
`comment` text,
`created_at` text NOT NULL,
`expires_at` text NOT NULL,
`decided_at` text,
FOREIGN KEY (`run_id`) REFERENCES `operation_runs`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `idx_operation_approvals_status` ON `operation_approvals` (`status`,`expires_at`);--> statement-breakpoint
CREATE INDEX `idx_operation_approvals_run` ON `operation_approvals` (`run_id`);--> statement-breakpoint
CREATE TABLE `operation_events` (
`id` text PRIMARY KEY NOT NULL,
`event_id` text NOT NULL,
`plugin_id` text NOT NULL,
`host_id` integer,
`severity` text,
`payload` text,
`run_id` text,
`created_at` text NOT NULL
);
--> statement-breakpoint
CREATE INDEX `idx_operation_events_created` ON `operation_events` (`created_at`);--> statement-breakpoint
CREATE INDEX `idx_operation_events_host_created` ON `operation_events` (`host_id`,`created_at`);--> statement-breakpoint
CREATE TABLE `operation_runs` (
`id` text PRIMARY KEY NOT NULL,
`action_id` text NOT NULL,
`action_version` integer DEFAULT 1 NOT NULL,
`plugin_id` text NOT NULL,
`plugin_version` text NOT NULL,
`user_id` text,
`username` text NOT NULL,
`origin` text NOT NULL,
`host_id` integer,
`host_name` text,
`batch_id` text,
`dry_run` integer DEFAULT false NOT NULL,
`risk` text NOT NULL,
`status` text NOT NULL,
`summary` text,
`changed` integer,
`input` text,
`sealed_input` text,
`output` text,
`undo_input` text,
`error` text,
`error_code` text,
`undo_of` text,
`idempotency_key` text,
`created_at` text NOT NULL,
`started_at` text,
`finished_at` text,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE INDEX `idx_operation_runs_created` ON `operation_runs` (`created_at`);--> statement-breakpoint
CREATE INDEX `idx_operation_runs_user_created` ON `operation_runs` (`user_id`,`created_at`);--> statement-breakpoint
CREATE INDEX `idx_operation_runs_host_created` ON `operation_runs` (`host_id`,`created_at`);--> statement-breakpoint
CREATE INDEX `idx_operation_runs_status` ON `operation_runs` (`status`);--> statement-breakpoint
CREATE INDEX `idx_operation_runs_batch` ON `operation_runs` (`batch_id`);--> statement-breakpoint
ALTER TABLE `api_keys` ADD `scopes` text;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -512,6 +512,13 @@
"when": 1793304926942,
"tag": "0072_shared_folder_auth_overrides",
"breakpoints": true
},
{
"idx": 73,
"version": "6",
"when": 1793765382283,
"tag": "0073_dazzling_amazoness",
"breakpoints": true
}
]
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "termix",
"version": "26.11.0",
"version": "26.11.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "termix",
"version": "26.11.0",
"version": "26.11.1",
"hasInstallScript": true,
"workspaces": [
"packages/*"
+1 -1
View File
@@ -4,7 +4,7 @@
"workspaces": [
"packages/*"
],
"version": "26.11.0",
"version": "26.11.1",
"description": "Self-hosted, plugin-based server management.",
"author": "Karmaa",
"main": "electron/main.cjs",
+47
View File
@@ -1,5 +1,52 @@
# Changelog
## 1.1.0
Plugin API 1.15.0.
### Added
- `/ui` exports `TileGrid`, `TileFrame` and `TilePicker`, the 12 column tile grid the dashboard, the host page and Host Metrics share, plus `appendTile`, `flowTiles`, `removeTile`, `sanitizeTiles` and the `TileSlot` type
- `registerDashboardCard` takes `entries`, so one card can be placed many times with its own `config`, and `defaultSize` for the grid
- Dashboard cards get `config`, `setConfig` and `editMode`
- `/ui` exports `TileWall` (a fixed grid of tiles with shared lines), `TileStack` and `TileColumn` (sections stacked with a line between), `TileEditBar`, and `SeamlessTileContext` with `useInSeamlessTile`
- `/ui` exports `StatTile`, `InfoGrid`, `StatusDot`, `StatusBadge` and `statusTone`, `LoadingState`, `NoticePanel`, `Pager` with `usePaged` and `pageOf`, `DataTable` with `DataTableRow`, `SideNav` with `SideNavItem`, `PanelFrame` and `RefreshButton`
- `InlineView` takes `stacked`, which lays SectionCards out in one column with lines between them
- `GroupHeading` takes `plain`, which drops the rule after the title. Inside a tile layout that is the default
### Changed
- `Card`, `SectionCard`, `MetricCard` and `DataView` use the tile look: no fill, and inside a tile layout no box either. `DataView` grids sit edge to edge with shared lines
## 1.0.25
Plugin API 1.14.0.
### Added
- Host click behavior `hostPage` opens a host page when a plugin offers one
- `createApiKey` takes scopes, to limit a key to actions
## 1.0.24
Plugin API 1.14.0.
### Added
- `app.registerHostSection` adds a card to the host page
- `/ui` exports `HostTabEmbed`, which draws any tab type inside another view, the host section registry, and `operationsApi` for core's `/operations` routes
## 1.0.23
Plugin API 1.14.0.
### Added
- `contributes.operations` declares a plugin's actions, events, resources and signals, so Automations, Fleets, the assistant, the CLI, the phone app and the command palette can find them with no wiring
- `ctx.operations` registers handlers, runs other plugins' declared actions, emits events and provides approval rules, with the new `operations:provide`, `operations:invoke` and `operations:approve` capabilities
- `@termix-ssh/plugin-sdk/operations` has the types, input checks, redaction and JSON Schema for declared actions
- `contributes.hostSections` declares cards for the host page
## 1.0.22
Plugin API 1.13.0.
+91 -4
View File
@@ -1,6 +1,6 @@
# Termix plugin guide
SDK 1.0.22 | Plugin API 1.13.0
SDK 1.1.0 | Plugin API 1.15.0
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
@@ -414,6 +414,70 @@ ctx.schedule.every(60_000, () => refresh(), { jitterMs: 5_000 });
- Use a service: declare it in `requires`, then `ctx.services.get<T>("snippets.access")`. It returns
nothing while no provider runs, so always handle that.
### Operations
Declare what your plugin can do in `contributes.operations`, and it shows up in Automations, Fleets, the
assistant, the CLI, the phone app and the command palette with no other wiring. It also gets per host results,
dry runs, run history, approvals and drift checks.
```json
"capabilities": ["operations:provide"],
"engine": { "termix": ">=26.11.1-0", "api": "^1.13" },
"contributes": {
"operations": {
"actions": [{
"id": "demo.restart",
"titleKey": "ops.restart.title",
"scope": "host",
"inputs": [{ "key": "name", "type": "string", "labelKey": "ops.restart.name", "required": true }],
"risk": "change",
"permission": "use",
"hostAccess": "edit",
"dryRun": "describe"
}]
}
}
```
```ts
ctx.operations.register("demo.restart", {
describe: (input) => `Would restart ${input.name}`,
run: async (input, { hostId, signal, log }) => {
return { summary: `Restarted ${input.name}`, changed: true };
},
});
```
- Actions: `scope` is `host`, `resource` or `server`. `risk` is `read`, `change` or `destructive`. Host actions declare
`hostAccess`. Use `edit` for changes unless people with `connect` already do this today. A destructive action
must use `edit` or `manage` and needs a confirm.
- Inputs use the settings field types, plus `required`, `pattern`, `optionsFrom` (a read action fills the choices)
and type `resource`. Keys you did not declare are refused before your handler runs. `secret` values reach the handler
but are never stored or shown. Mark private outputs `sensitive`.
- `dryRun`: `native` calls `run` with `dryRun: true` and nothing may change. `describe` calls `describe` instead.
- `check` names a read action that proves the result, used for drift. `undo` names the reverse action, and `run` returns
`undoInput`. `idempotent` lets a rollout retry it. `exclusive` allows one at a time per host. `when` limits it to
hosts by setting or OS.
- `surfaces`: the assistant and MCP are off until you set them true. Only do that for actions safe for a model to
ask for. The rest are on unless you set them false.
- Resources describe a kind of thing, such as a container: a read action on one host returns `{ rows }` as `data`, and
core draws the All hosts table and host page section. Attach actions with `scope: "resource"`.
- Signals are status tiles: a read action returns `{ value, status, label }` as `data`, with status `ok`, `warn`, `fail` or
`unknown`. `fix` names the action offered when it is not ok. Keep these reads cheap, they run for every host.
- Events: declare them, then call `ctx.operations.emit(id, payload, { hostId })`.
- Handlers must watch `signal` and stop when it fires. Throw on failure; a failed run never counts against your
plugin's error budget. Throw an error with `code = "NEEDS_INTERACTION"` when a host needs a person to sign in first.
- Handlers have no request and no session. Take what you need from the input and `hostId`.
- Keep your REST routes. Inside one, call `ctx.operations.run(id, { hostId, input })` so a button press goes through
the same checks and lands in the same history.
- To run another plugin's action, add `operations:invoke`, list it in `operations.invokes` and call
`ctx.operations.invoke(id, { hostId, input, via: "automation" })`. It acts as the current user and rules still apply.
- `risk` is your plugin's claim. Plugins run in-process, so this is not a sandbox. Actions from plugins not signed as
official count as `change` when rules decide what needs a person, and stay off the assistant and MCP until an admin
allows them.
- Set `engine.termix` to at least `>=26.11.1-0`. Keep `engine.api` as it was unless you import something new from
`/ui`, or the phone app will ask people to update to see your plugin.
### Shared packages
These come from Termix and are never bundled: `@termix-ssh/plugin-sdk`, `express`, `ssh2`, `ws`, `multer`,
@@ -702,9 +766,28 @@ exports with props is `ui.api.txt` in the Termix repo
- Small type: most text 11 to 13px.
- No new modal dialogs. Ask with `useConfirm()`, open views in place with `InlineView`, and use
`PanePrompt` for small prompts.
- Every tab or panel sits in `PanelShell`. Lists use `PanelList` and `ListRow`, add with `AddButton`,
forms end with `FormFooter`, empty lists show `EmptyState`.
- Card grids use `DataView` with `ViewToggle`. Pass `columns` and `onColumns` to `ViewToggle` and the same
- Every tab or panel sits in `PanelShell` (inside `PanelFrame` when it is a tab). Lists use `PanelList` and
`ListRow`, add with `AddButton`, forms end with `FormFooter`, empty lists show `EmptyState`, loading shows
`LoadingState`, and "not installed" or "no access" shows `NoticePanel`.
- The tile look: page content sits edge to edge on the page background, with one line between sections and
no gaps, padding or card fill around them. The body of a `PanelShell` has no padding. Stack sections with
`TileStack` (or `TileColumn` when cards sit deeper), put numbers and small cards in a `TileWall` of
`StatTile` or `MetricCard`, and use `TileFrame` for a titled section. Cards drop their own box inside
these. Forms keep `PANEL.body` padding inside their section. An `InlineView` form made of `SectionCard`s
takes `stacked`.
- Page views go in `PanelShell`'s `tabs` band as a `TabStrip`. `Segmented` is for filters and view modes.
A page with many sections uses `SideNav` on the left. Put the refresh button in the header with
`RefreshButton`, and the docs link with the `docs` prop.
- States and data: `StatusBadge` and `StatusDot` (pass a state like "running" and the color follows),
`InfoGrid` for label and value pairs, `DataTable` for a plain table, `Pager` and `usePaged` under long lists.
- Tile pages use `TileGrid` with `TileFrame` around each tile and `TilePicker` beside it while editing. Slots are
`{ id, x, y, w, h }` on 12 columns (`h` null for the natural height); save them where your plugin keeps
preferences and run saved ones through `sanitizeTiles`. Add with `appendTile`. While editing, show
`TileEditBar` over the grid. Needs Termix 26.11.1.
- A dashboard card with `entries` can be placed many times: each entry has a title, a group and a `config` the
card gets back as `config` (save changes with `setConfig`, keep it small). Operations Center uses this for
status and action tiles.
- Card grids use `DataView` with `ViewToggle` (the cards share lines like a `TileWall`). Pass `columns` and `onColumns` to `ViewToggle` and the same
value as `size` to `DataView`, so users can pick Auto (as many as fit) or a fixed number of cards per
row. `usePluginPanelView` keeps the choice as `columns`. Older Termix versions ignore both props.
- Forms: `TextField`, `NumberField`, `SelectField`, `SwitchRow`, `SecretField`, `SettingRow` and friends.
@@ -962,6 +1045,7 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
| `@termix-ssh/plugin-sdk/db` | `defineTable` and column builders |
| `@termix-ssh/plugin-sdk/manifest` | `PluginManifest` type and manifest checks |
| `@termix-ssh/plugin-sdk/capabilities` | The capability catalog |
| `@termix-ssh/plugin-sdk/operations` | Types, input checks and JSON Schema for declared actions |
| `@termix-ssh/plugin-sdk/settings` | Settings field validation |
| `@termix-ssh/plugin-sdk/testing` | `createMockCtx`, `createTestDb`, `renderWithApp`, `createFakeContext` |
| `@termix-ssh/plugin-sdk/vitest-preset` | `pluginVitestConfig` |
@@ -993,6 +1077,8 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
| `desktop:local` | high | Use the user's own computer, desktop app only |
| `hosts:export` | high | Export hosts with their saved logins |
| `notify:hub` | high | Receive every alert (the alerts hub only) |
| `operations:invoke` | high | Run other plugins' declared actions, listed in `invokes` |
| `operations:approve` | high | Decide which actions need a person |
| `ui:layout` | high | Replace the whole app layout |
| `hosts:write` | medium | Add and edit hosts |
| `hosts:status-provide` | medium | Be the source of host status dots |
@@ -1008,6 +1094,7 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
| `users:read` | medium | Usernames, roles and UI language |
| `events:core` | medium | Listen to core events |
| `notify:send` | medium | Send alerts |
| `operations:provide` | medium | Offer declared actions and events |
| `audit:read` | medium | Read the audit log |
| `desktop:window` | medium | Open windows in the desktop app |
| `hosts:read` | low | Read hosts the user can see |
+5 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@termix-ssh/plugin-sdk",
"version": "1.0.22",
"version": "1.1.0",
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
"license": "Apache-2.0",
"repository": {
@@ -61,6 +61,10 @@
"types": "./dist/openapi.d.ts",
"default": "./dist/openapi.js"
},
"./operations": {
"types": "./dist/operations.d.ts",
"default": "./dist/operations.js"
},
"./settings": {
"types": "./dist/settings.d.ts",
"default": "./dist/settings.js"
@@ -536,6 +536,31 @@
}
}
},
"hostSections": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "titleKey"],
"properties": {
"id": { "type": "string", "minLength": 1 },
"titleKey": { "type": "string", "minLength": 1 },
"icon": { "type": "string", "minLength": 1 }
}
}
},
"operations": {
"type": "object",
"description": "What this plugin can do. Every surface finds these on its own. See the operations section of PLUGIN-GUIDE.md.",
"additionalProperties": false,
"properties": {
"actions": { "type": "array", "items": { "type": "object" } },
"events": { "type": "array", "items": { "type": "object" } },
"resources": { "type": "array", "items": { "type": "object" } },
"signals": { "type": "array", "items": { "type": "object" } },
"invokes": { "type": "array", "items": { "type": "string" } }
}
},
"dashboardCards": {
"type": "array",
"items": {
+137
View File
@@ -14,6 +14,11 @@ import type { HostSshOptions } from "./ssh-options.js";
export type { HostSshOptions } from "./ssh-options.js";
import type { PluginManifest, PluginSettingsField } from "./manifest.js";
import type { PluginTableDefinition } from "./db.js";
import type {
OperationResult,
OperationRisk,
OperationSurfaces,
} from "./operations.js";
/**
* Thrown when a plugin calls a guarded member without the capability.
@@ -3104,6 +3109,136 @@ export interface PluginPlugins {
list: () => Promise<PluginSummary[]>;
}
/** What a handler gets besides its input. */
export interface PluginOperationContext {
hostId?: number;
/** The row a resource action targets. */
resourceKey?: string;
/** Null for a background read. */
runId: string | null;
/** Nothing may change when this is set. */
dryRun: boolean;
/** Fires when the run is cancelled, times out or the plugin stops. */
signal: AbortSignal;
log: (line: string) => void;
}
export interface PluginOperationHandler {
run: (
input: Record<string, unknown>,
context: PluginOperationContext,
) => Promise<OperationResult> | OperationResult;
/** The dry run text for an action declared with dryRun "describe". */
describe?: (
input: Record<string, unknown>,
context: PluginOperationContext,
) => string | Promise<string>;
}
export interface PluginOperationInvokeOptions {
hostId?: number;
resourceKey?: string;
input?: Record<string, unknown>;
dryRun?: boolean;
/** A person agreed to a destructive action. */
confirmed?: boolean;
idempotencyKey?: string;
batchId?: string;
/** What this plugin is doing: automation, assistant, mcp, rollout and so on. */
via?: string;
}
export interface PluginOperationOutcome {
runId: string | null;
status: string;
summary?: string;
changed?: boolean;
data?: unknown;
error?: string;
errorCode?: string;
approvalId?: string;
}
export interface PluginOperationApprovalInput {
actionId: string;
pluginId: string;
risk: OperationRisk;
/** What rules should use. Plugins not signed as official count as change. */
effectiveRisk: OperationRisk;
origin: { kind: string; pluginId?: string; via?: string };
userId: string;
hostId?: number;
hostTags?: string[];
batchId?: string;
}
export interface PluginOperationApprovalDecision {
decision: "allow" | "approve" | "deny";
reason?: string;
allowSelf?: boolean;
expiresInMinutes?: number;
}
export interface PluginOperationEvent {
id: string;
eventId: string;
pluginId: string;
hostId?: number;
severity?: string;
payload?: unknown;
runId?: string;
depth: number;
at: string;
}
/**
* Declared actions and events. See contributes.operations in the manifest.
* register and emit need operations:provide. invoke, list and onEvent need
* operations:invoke, and invoke only reaches ids in the manifest's invokes list.
*/
export interface PluginOperations {
register: (id: string, handler: PluginOperationHandler) => void;
/**
* Runs one of this plugin's own actions from its route, so a button press
* goes through the same checks and lands in the same history.
*/
run: (
actionId: string,
options?: PluginOperationInvokeOptions,
) => Promise<PluginOperationOutcome>;
invoke: (
actionId: string,
options?: PluginOperationInvokeOptions,
) => Promise<PluginOperationOutcome>;
list: (options?: {
lang?: string;
surface?: keyof OperationSurfaces;
}) => Promise<unknown>;
emit: (
eventId: string,
payload?: unknown,
options?: { hostId?: number },
) => Promise<void>;
onEvent: (subscription: {
pattern: string;
handler: (event: PluginOperationEvent) => void | Promise<void>;
userId?: string;
cooldownMs?: number;
}) => () => void;
/** Rules for what needs a person. Needs operations:approve. */
approvals: {
provide: (
provider: (
input: PluginOperationApprovalInput,
) =>
| PluginOperationApprovalDecision
| null
| undefined
| Promise<PluginOperationApprovalDecision | null | undefined>,
) => () => void;
};
}
export interface PluginContext {
readonly pluginId: string;
readonly manifest: PluginManifest;
@@ -3144,6 +3279,8 @@ export interface PluginContext {
readonly schedule: PluginSchedule;
/** Core's notification channels. Needs notify:send. */
readonly notify: PluginNotify;
/** Declared actions and events, reachable from every surface. */
readonly operations: PluginOperations;
/** Outbound HTTP through core's SSRF guard. Needs network:outbound. */
readonly fetch: PluginFetch;
/** Plain reachability checks. Needs network:probe. */
+3
View File
@@ -52,6 +52,8 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
entry("ssh:transport", "high"),
entry("device:serial", "high"),
entry("notify:hub", "high"),
entry("operations:invoke", "high"),
entry("operations:approve", "high"),
entry("ui:layout", "high"),
entry("hosts:write", "medium"),
@@ -68,6 +70,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
entry("users:read", "medium"),
entry("events:core", "medium"),
entry("notify:send", "medium"),
entry("operations:provide", "medium"),
entry("audit:read", "medium"),
entry("desktop:window", "medium"),
entry("desktop:local", "high"),
+48
View File
@@ -670,6 +670,22 @@ export type HostStatusIndicatorContribution = HostBadgeContribution;
/** A control in the host editor's toolbar. */
export type HostEditorToolbarItemContribution = HostBadgeContribution;
export interface HostSectionProps {
host: PluginHostRecord;
isVisible: boolean;
shell: ShellApi;
}
export interface HostSectionContribution {
/** Declare it in manifest contributes.hostSections too. */
id: string;
titleKey: string;
/** Lower sorts first. */
order?: number;
when?: (host: PluginHostRecord) => boolean;
component: ComponentType<HostSectionProps>;
}
export interface HostContextMenuItemContribution {
id: string;
titleKey: string;
@@ -783,6 +799,26 @@ export interface KeybindingDefaultContribution {
export interface DashboardCardProps {
isVisible: boolean;
shell: ShellApi;
/** This tile's settings, for cards with `entries`. Empty otherwise. Core 26.11.1+. */
config?: Record<string, unknown>;
/** Saves this tile's settings. Keep it small, it is stored in one line. */
setConfig?: (config: Record<string, unknown>) => void;
/** True while the user is arranging the dashboard. */
editMode?: boolean;
}
/** One tile a card with `entries` offers in the dashboard's add list. */
export interface DashboardCardEntry {
/** Stable within the card, used to mark entries already placed. */
key: string;
title: string;
description?: string;
/** Group heading in the add list. Defaults to the card title. */
group?: string;
kind?: "card" | "chart" | "signal" | "action" | "resource" | "session";
/** Passed back as `config`. */
config: Record<string, unknown>;
defaultSize?: { w: number; h?: number | null };
}
export interface DashboardCardContribution {
@@ -797,6 +833,13 @@ export interface DashboardCardContribution {
* edge, for a strip of numbers. Defaults to "framed".
*/
frame?: "framed" | "bare";
/** Size on the 12 column dashboard grid when added. Core 26.11.1+. */
defaultSize?: { w: number; h?: number | null };
/**
* Lets the card be placed many times, once per entry, each with its own
* `config`. Titles come back already translated. Core 26.11.1+.
*/
entries?: (language: string) => Promise<DashboardCardEntry[]>;
component: ComponentType<DashboardCardProps>;
}
@@ -1172,6 +1215,11 @@ export interface TermixApp extends TermixAppInfo {
registerHostContextMenuItem: (
item: HostContextMenuItemContribution,
) => Disposer;
/**
* A card on the host page. Core 26.11.1 and newer; call it as
* app.registerHostSection?.(...) if your plugin allows older cores.
*/
registerHostSection: (section: HostSectionContribution) => Disposer;
registerPaletteEntry: (entry: PaletteEntryContribution) => Disposer;
registerPaletteGroup: (group: PaletteGroupContribution) => Disposer;
registerKeybindingAction: (action: KeybindingActionContribution) => Disposer;
+20 -1
View File
@@ -12,6 +12,11 @@
import semver from "semver";
import { isKnownCapability } from "./capabilities.js";
import {
checkOperationReferences,
validateOperations,
type PluginOperationsContribution,
} from "./operations.js";
import {
ENV_NAME_PATTERN,
MAX_ENV_DESCRIPTION_LENGTH,
@@ -37,7 +42,7 @@ export {
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
* version by the server.
*/
export const PLUGIN_API_VERSION = "1.13.0";
export const PLUGIN_API_VERSION = "1.15.0";
/** The API major, the default engine.api for a new plugin. */
export const SUPPORTED_PLUGIN_API_VERSION = String(
@@ -465,6 +470,10 @@ export interface PluginContributions {
tabs?: PluginTabContribution[];
panels?: PluginViewContribution[];
dashboardCards?: PluginViewContribution[];
/** Cards on the host page, filled with app.registerHostSection. */
hostSections?: PluginViewContribution[];
/** What this plugin can do, for every surface to find on its own. */
operations?: PluginOperationsContribution;
/**
* The frontend also runs on anonymous guest pages (shared-session and
* collab links), where it is activated with app.guest set.
@@ -779,6 +788,8 @@ const ALLOWED_CONTRIBUTES = new Set([
"tabs",
"panels",
"dashboardCards",
"hostSections",
"operations",
"guest",
"guestViews",
"actions",
@@ -1500,6 +1511,8 @@ function validateContributes(
validateTabs(contributes.tabs, errors);
validateViews(contributes.panels, "panels", errors);
validateViews(contributes.dashboardCards, "dashboardCards", errors);
validateViews(contributes.hostSections, "hostSections", errors);
validateOperations(contributes.operations, pluginId, errors);
if (
contributes.guest !== undefined &&
typeof contributes.guest !== "boolean"
@@ -2622,6 +2635,12 @@ export function parseManifest(raw: unknown): ParsedManifest {
);
}
}
checkOperationReferences(
manifest.contributes?.operations,
manifest.capabilities,
(name) => declared.has(qualifyPermission(manifest.id, name)),
errors,
);
// A settings field may gate on one of this plugin's own permissions, or on
// a core/other-plugin id used as given. Only the first form is checkable
// here, and an undeclared one would hide the field from every admin.
+951
View File
@@ -0,0 +1,951 @@
/**
* The operations contract: what a plugin declares it can do, in one shape every
* surface reads (Automations, Fleets, AI, CLI, Mobile, palette, API, MCP).
*
* Declarations are data in the manifest, so a surface can list and describe an
* action without loading the plugin's code. Handlers are registered at
* activation with ctx.operations.register and must match a declaration.
*
* No Node imports: the frontend, the CLI and Mobile read this file too.
*/
import { coerceSettingValue, validateSettingValue } from "./settings.js";
import type { PluginSettingsField, PluginSettingsOption } from "./manifest.js";
export const OPERATION_RISKS = ["read", "change", "destructive"] as const;
export type OperationRisk = (typeof OPERATION_RISKS)[number];
export const OPERATION_SCOPES = ["host", "resource", "server"] as const;
export type OperationScope = (typeof OPERATION_SCOPES)[number];
export const OPERATION_HOST_ACCESS = [
"connect",
"view",
"edit",
"manage",
] as const;
export type OperationHostAccess = (typeof OPERATION_HOST_ACCESS)[number];
export const OPERATION_DRY_RUN = ["native", "describe"] as const;
/** native: the handler checks ctx.dryRun. describe: core reports its text. */
export type OperationDryRun = (typeof OPERATION_DRY_RUN)[number];
export const OPERATION_FIELD_TYPES = [
"boolean",
"string",
"number",
"select",
"multiselect",
"secret",
"textarea",
"json",
"resource",
] as const;
export type OperationFieldType = (typeof OPERATION_FIELD_TYPES)[number];
export const OPERATION_SEVERITIES = ["info", "warning", "critical"] as const;
export type OperationSeverity = (typeof OPERATION_SEVERITIES)[number];
export const SIGNAL_KINDS = ["count", "gauge", "status", "list"] as const;
export type SignalKind = (typeof SIGNAL_KINDS)[number];
export const SIGNAL_STATUSES = ["ok", "warn", "fail", "unknown"] as const;
export type SignalStatus = (typeof SIGNAL_STATUSES)[number];
export const RESOURCE_COLUMN_TYPES = [
"text",
"status",
"bytes",
"percent",
"time",
"tags",
] as const;
export type ResourceColumnType = (typeof RESOURCE_COLUMN_TYPES)[number];
/** One input or output of an action. A subset of a settings field, plus rules. */
export interface OperationField {
key: string;
type: OperationFieldType;
labelKey?: string;
descriptionKey?: string;
placeholderKey?: string;
default?: unknown;
options?: PluginSettingsOption[];
min?: number;
max?: number;
/** Key of a boolean field in the same list that must be on. */
requires?: string;
group?: string;
required?: boolean;
/** string and textarea only. A regular expression the whole value matches. */
pattern?: string;
/** Read action whose rows fill the choices, for a list known only at run time. */
optionsFrom?: string;
/** type "resource" only: the resource id whose list fills the picker. */
resource?: string;
/** Outputs only: dropped before a run is stored or shown. */
sensitive?: boolean;
}
export interface OperationWhen {
/** Host setting values that must match, such as { enableDocker: true }. */
settings?: Record<string, boolean | string | number>;
/** Lowercase OS names. A host with no known OS passes. */
os?: string[];
}
export interface OperationSurfaces {
/** Offered to the AI assistant. Off unless set. */
assistant?: boolean;
/** Offered over MCP. Off unless set. */
mcp?: boolean;
/** Set false to keep out of the command palette. */
palette?: boolean;
/** Set false to keep out of Automations. */
automations?: boolean;
/** Set false to keep out of the phone app. */
mobile?: boolean;
/** Set false to keep out of the CLI. */
cli?: boolean;
}
export interface OperationAction {
/** `<pluginId>.<name>`. */
id: string;
/** Bump when inputs change in a way saved references cannot follow. */
version?: number;
titleKey: string;
descriptionKey?: string;
icon?: string;
scope: OperationScope;
/** Required for scope "resource": the resource this acts on. */
resource?: string;
inputs?: OperationField[];
outputs?: OperationField[];
risk: OperationRisk;
/** One of this plugin's own permissions, short or full. */
permission: string;
/** Lowest share level. Required for host and resource scopes. */
hostAccess?: OperationHostAccess;
dryRun?: OperationDryRun;
/** A read action of this plugin that proves the result. */
check?: string;
/** An action of this plugin that reverses this one. */
undo?: string;
/** Safe to run twice, so a rollout may retry it. */
idempotent?: boolean;
/** Only one such action per host at a time. */
exclusive?: boolean;
timeoutMs?: number;
when?: OperationWhen;
surfaces?: OperationSurfaces;
}
export interface OperationEvent {
id: string;
titleKey: string;
descriptionKey?: string;
severity?: OperationSeverity;
scope: "host" | "server";
payload?: OperationField[];
}
export interface ResourceColumn {
key: string;
titleKey: string;
type?: ResourceColumnType;
}
export interface OperationResource {
id: string;
titleKey: string;
icon?: string;
/** Read action that returns { rows: [...] } for one host. */
list: string;
/** Row field that is unique on a host. */
key: string;
/** Row field shown as the name. */
name: string;
/** Row field holding a status word. */
status?: string;
/** Row field holding a link, for auto links on the homepage. */
url?: string;
/** Tab type that opens this on a host. */
tab?: string;
columns?: ResourceColumn[];
filters?: string[];
}
export interface OperationSignal {
id: string;
titleKey: string;
/** Read action that returns a SignalResult. */
read: string;
scope: "host" | "server";
kind: SignalKind;
refreshSec?: number;
/** Action offered as the fix when this signal is not ok. */
fix?: string;
}
export interface PluginOperationsContribution {
actions?: OperationAction[];
events?: OperationEvent[];
resources?: OperationResource[];
signals?: OperationSignal[];
/**
* Actions this plugin may run through ctx.operations.invoke: exact ids,
* `<pluginId>.*`, or `*`.
*/
invokes?: string[];
}
/** What a signal's read action returns. */
export interface SignalResult {
value?: number | string | null;
status: SignalStatus;
label?: string;
items?: { label: string; status?: SignalStatus }[];
link?: { tab?: string; data?: Record<string, unknown> };
}
/** What a resource's list action returns. */
export interface ResourceListResult {
rows: Record<string, unknown>[];
}
/** What a handler returns. */
export interface OperationResult {
/** One plain line for history and per host results. */
summary: string;
/** Whether anything actually changed. */
changed?: boolean;
data?: unknown;
/** Input for the `undo` action that reverses this exact run. */
undoInput?: Record<string, unknown>;
}
export const OPERATION_ID_PATTERN = /^[a-z0-9][a-z0-9-]*\.[a-z0-9][a-z0-9-]*$/;
const FIELD_KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
const INVOKE_PATTERN = /^(\*|[a-z0-9][a-z0-9-]*\.(\*|[a-z0-9][a-z0-9-]*))$/;
export const MAX_OPERATION_TIMEOUT_MS = 3_600_000;
export const REDACTED_VALUE = "[redacted]";
const ALLOWED_OPERATIONS = [
"actions",
"events",
"resources",
"signals",
"invokes",
];
const ALLOWED_ACTION = [
"id",
"version",
"titleKey",
"descriptionKey",
"icon",
"scope",
"resource",
"inputs",
"outputs",
"risk",
"permission",
"hostAccess",
"dryRun",
"check",
"undo",
"idempotent",
"exclusive",
"timeoutMs",
"when",
"surfaces",
];
const ALLOWED_FIELD = [
"key",
"type",
"labelKey",
"descriptionKey",
"placeholderKey",
"default",
"options",
"min",
"max",
"requires",
"group",
"required",
"pattern",
"optionsFrom",
"resource",
"sensitive",
];
const ALLOWED_EVENT = [
"id",
"titleKey",
"descriptionKey",
"severity",
"scope",
"payload",
];
const ALLOWED_RESOURCE = [
"id",
"titleKey",
"icon",
"list",
"key",
"name",
"status",
"url",
"tab",
"columns",
"filters",
];
const ALLOWED_SIGNAL = [
"id",
"titleKey",
"read",
"scope",
"kind",
"refreshSec",
"fix",
];
const ALLOWED_SURFACES = [
"assistant",
"mcp",
"palette",
"automations",
"mobile",
"cli",
];
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function rejectUnknown(
raw: Record<string, unknown>,
allowed: string[],
where: string,
errors: string[],
): void {
for (const key of Object.keys(raw)) {
if (!allowed.includes(key))
errors.push(`Unknown field "${key}" in ${where}`);
}
}
function requireEnum(
value: unknown,
allowed: readonly string[],
where: string,
errors: string[],
): void {
if (typeof value !== "string" || !allowed.includes(value)) {
errors.push(`${where} must be one of: ${allowed.join(", ")}`);
}
}
function requireText(value: unknown, where: string, errors: string[]): void {
if (typeof value !== "string" || value.trim().length === 0) {
errors.push(`${where} must be a non-empty string`);
}
}
function optionalText(value: unknown, where: string, errors: string[]): void {
if (value !== undefined) requireText(value, where, errors);
}
function optionalBoolean(
value: unknown,
where: string,
errors: string[],
): void {
if (value !== undefined && typeof value !== "boolean") {
errors.push(`${where} must be a boolean`);
}
}
function requireId(
value: unknown,
pluginId: string | undefined,
where: string,
seen: Set<string>,
errors: string[],
): void {
if (typeof value !== "string" || !OPERATION_ID_PATTERN.test(value)) {
errors.push(`${where}.id must look like "<pluginId>.<name>" in lowercase`);
return;
}
if (pluginId && !value.startsWith(`${pluginId}.`)) {
errors.push(`${where}.id must start with "${pluginId}."`);
}
if (seen.has(value)) errors.push(`${where}.id "${value}" is declared twice`);
seen.add(value);
}
function eachObject(
value: unknown,
where: string,
errors: string[],
each: (raw: Record<string, unknown>, at: string) => void,
): void {
if (value === undefined) return;
if (!Array.isArray(value)) {
errors.push(`${where} must be an array`);
return;
}
value.forEach((raw, index) => {
const at = `${where}[${index}]`;
if (!isObject(raw)) {
errors.push(`${at} must be an object`);
return;
}
each(raw, at);
});
}
function validateFields(value: unknown, where: string, errors: string[]): void {
const keys = new Set<string>();
eachObject(value, where, errors, (raw, at) => {
rejectUnknown(raw, ALLOWED_FIELD, at, errors);
if (typeof raw.key !== "string" || !FIELD_KEY_PATTERN.test(raw.key)) {
errors.push(`${at}.key must be a short name starting with a letter`);
} else if (keys.has(raw.key)) {
errors.push(`${at}.key "${raw.key}" is declared twice`);
} else {
keys.add(raw.key);
}
requireEnum(raw.type, OPERATION_FIELD_TYPES, `${at}.type`, errors);
optionalText(raw.labelKey, `${at}.labelKey`, errors);
optionalText(raw.descriptionKey, `${at}.descriptionKey`, errors);
optionalText(raw.placeholderKey, `${at}.placeholderKey`, errors);
optionalText(raw.group, `${at}.group`, errors);
optionalText(raw.requires, `${at}.requires`, errors);
optionalText(raw.optionsFrom, `${at}.optionsFrom`, errors);
optionalBoolean(raw.required, `${at}.required`, errors);
optionalBoolean(raw.sensitive, `${at}.sensitive`, errors);
if (raw.type === "resource" && typeof raw.resource !== "string") {
errors.push(`${at}.resource is required for type "resource"`);
}
if (raw.resource !== undefined && raw.type !== "resource") {
errors.push(`${at}.resource needs type "resource"`);
}
if (raw.type === "secret" && raw.default !== undefined) {
errors.push(`${at}.default is not allowed on a secret`);
}
if (
(raw.type === "select" || raw.type === "multiselect") &&
!raw.optionsFrom &&
(!Array.isArray(raw.options) || raw.options.length === 0)
) {
errors.push(`${at} needs options or optionsFrom`);
}
for (const bound of ["min", "max"] as const) {
if (raw[bound] !== undefined && typeof raw[bound] !== "number") {
errors.push(`${at}.${bound} must be a number`);
}
}
if (raw.pattern !== undefined) {
if (typeof raw.pattern !== "string") {
errors.push(`${at}.pattern must be a string`);
} else {
try {
new RegExp(raw.pattern);
} catch {
errors.push(`${at}.pattern is not a valid regular expression`);
}
}
}
});
}
function validateWhen(value: unknown, at: string, errors: string[]): void {
if (value === undefined) return;
if (!isObject(value)) {
errors.push(`${at} must be an object`);
return;
}
rejectUnknown(value, ["settings", "os"], at, errors);
if (value.settings !== undefined && !isObject(value.settings)) {
errors.push(`${at}.settings must be an object`);
}
if (
value.os !== undefined &&
(!Array.isArray(value.os) ||
value.os.some((entry) => typeof entry !== "string"))
) {
errors.push(`${at}.os must be an array of strings`);
}
}
function validateAction(
raw: Record<string, unknown>,
at: string,
errors: string[],
): void {
rejectUnknown(raw, ALLOWED_ACTION, at, errors);
requireText(raw.titleKey, `${at}.titleKey`, errors);
optionalText(raw.descriptionKey, `${at}.descriptionKey`, errors);
optionalText(raw.icon, `${at}.icon`, errors);
requireEnum(raw.scope, OPERATION_SCOPES, `${at}.scope`, errors);
requireEnum(raw.risk, OPERATION_RISKS, `${at}.risk`, errors);
requireText(raw.permission, `${at}.permission`, errors);
validateFields(raw.inputs, `${at}.inputs`, errors);
validateFields(raw.outputs, `${at}.outputs`, errors);
validateWhen(raw.when, `${at}.when`, errors);
optionalBoolean(raw.idempotent, `${at}.idempotent`, errors);
optionalBoolean(raw.exclusive, `${at}.exclusive`, errors);
if (
raw.version !== undefined &&
(!Number.isInteger(raw.version) || (raw.version as number) < 1)
) {
errors.push(`${at}.version must be a whole number from 1`);
}
if (raw.dryRun !== undefined) {
requireEnum(raw.dryRun, OPERATION_DRY_RUN, `${at}.dryRun`, errors);
}
if (
raw.timeoutMs !== undefined &&
(typeof raw.timeoutMs !== "number" ||
raw.timeoutMs <= 0 ||
raw.timeoutMs > MAX_OPERATION_TIMEOUT_MS)
) {
errors.push(`${at}.timeoutMs must be above 0 and at most 1 hour`);
}
if (raw.scope === "resource" && typeof raw.resource !== "string") {
errors.push(`${at}.resource is required for scope "resource"`);
}
if (raw.scope !== "resource" && raw.resource !== undefined) {
errors.push(`${at}.resource needs scope "resource"`);
}
if (raw.scope === "server" && raw.hostAccess !== undefined) {
errors.push(`${at}.hostAccess does not apply to scope "server"`);
}
if (raw.scope === "host" || raw.scope === "resource") {
requireEnum(
raw.hostAccess,
OPERATION_HOST_ACCESS,
`${at}.hostAccess`,
errors,
);
if (
raw.risk === "destructive" &&
(raw.hostAccess === "connect" || raw.hostAccess === "view")
) {
errors.push(`${at}.hostAccess must be edit or manage when destructive`);
}
}
if (raw.surfaces !== undefined) {
if (!isObject(raw.surfaces)) {
errors.push(`${at}.surfaces must be an object`);
} else {
rejectUnknown(raw.surfaces, ALLOWED_SURFACES, `${at}.surfaces`, errors);
for (const [key, value] of Object.entries(raw.surfaces)) {
optionalBoolean(value, `${at}.surfaces.${key}`, errors);
}
}
}
}
function validateResource(
raw: Record<string, unknown>,
at: string,
errors: string[],
): void {
rejectUnknown(raw, ALLOWED_RESOURCE, at, errors);
requireText(raw.titleKey, `${at}.titleKey`, errors);
requireText(raw.list, `${at}.list`, errors);
requireText(raw.key, `${at}.key`, errors);
requireText(raw.name, `${at}.name`, errors);
optionalText(raw.icon, `${at}.icon`, errors);
optionalText(raw.status, `${at}.status`, errors);
optionalText(raw.url, `${at}.url`, errors);
optionalText(raw.tab, `${at}.tab`, errors);
eachObject(raw.columns, `${at}.columns`, errors, (column, where) => {
rejectUnknown(column, ["key", "titleKey", "type"], where, errors);
requireText(column.key, `${where}.key`, errors);
requireText(column.titleKey, `${where}.titleKey`, errors);
if (column.type !== undefined) {
requireEnum(column.type, RESOURCE_COLUMN_TYPES, `${where}.type`, errors);
}
});
if (
raw.filters !== undefined &&
(!Array.isArray(raw.filters) ||
raw.filters.some((entry) => typeof entry !== "string"))
) {
errors.push(`${at}.filters must be an array of strings`);
}
}
/** Shape checks for contributes.operations, run by validateManifest. */
export function validateOperations(
value: unknown,
pluginId: string | undefined,
errors: string[],
): void {
if (value === undefined) return;
const root = "contributes.operations";
if (!isObject(value)) {
errors.push(`Field "${root}" must be an object`);
return;
}
rejectUnknown(value, ALLOWED_OPERATIONS, `"${root}"`, errors);
const ids = new Set<string>();
eachObject(value.actions, `${root}.actions`, errors, (raw, at) => {
requireId(raw.id, pluginId, at, ids, errors);
validateAction(raw, at, errors);
});
const eventIds = new Set<string>();
eachObject(value.events, `${root}.events`, errors, (raw, at) => {
requireId(raw.id, pluginId, at, eventIds, errors);
rejectUnknown(raw, ALLOWED_EVENT, at, errors);
requireText(raw.titleKey, `${at}.titleKey`, errors);
optionalText(raw.descriptionKey, `${at}.descriptionKey`, errors);
requireEnum(raw.scope, ["host", "server"], `${at}.scope`, errors);
if (raw.severity !== undefined) {
requireEnum(raw.severity, OPERATION_SEVERITIES, `${at}.severity`, errors);
}
validateFields(raw.payload, `${at}.payload`, errors);
});
const resourceIds = new Set<string>();
eachObject(value.resources, `${root}.resources`, errors, (raw, at) => {
requireId(raw.id, pluginId, at, resourceIds, errors);
validateResource(raw, at, errors);
});
const signalIds = new Set<string>();
eachObject(value.signals, `${root}.signals`, errors, (raw, at) => {
requireId(raw.id, pluginId, at, signalIds, errors);
rejectUnknown(raw, ALLOWED_SIGNAL, at, errors);
requireText(raw.titleKey, `${at}.titleKey`, errors);
requireText(raw.read, `${at}.read`, errors);
requireEnum(raw.scope, ["host", "server"], `${at}.scope`, errors);
requireEnum(raw.kind, SIGNAL_KINDS, `${at}.kind`, errors);
optionalText(raw.fix, `${at}.fix`, errors);
if (
raw.refreshSec !== undefined &&
(typeof raw.refreshSec !== "number" || raw.refreshSec < 10)
) {
errors.push(`${at}.refreshSec must be at least 10`);
}
});
if (value.invokes !== undefined) {
if (
!Array.isArray(value.invokes) ||
value.invokes.some(
(entry) => typeof entry !== "string" || !INVOKE_PATTERN.test(entry),
)
) {
errors.push(
`${root}.invokes must list action ids, "<pluginId>.*" or "*"`,
);
}
}
}
/**
* The cross-field rules: every id one declaration names must exist in this
* plugin, or a surface would offer a button that goes nowhere.
*/
export function checkOperationReferences(
operations: PluginOperationsContribution | undefined,
capabilities: readonly string[],
hasPermission: (name: string) => boolean,
errors: string[],
): void {
if (!operations) return;
const root = "contributes.operations";
const actions = new Map(
(operations.actions ?? []).map((action) => [action.id, action]),
);
const resources = new Set((operations.resources ?? []).map((r) => r.id));
const declaresAnything =
actions.size > 0 ||
(operations.events?.length ?? 0) > 0 ||
resources.size > 0 ||
(operations.signals?.length ?? 0) > 0;
if (declaresAnything && !capabilities.includes("operations:provide")) {
errors.push(`${root} needs the "operations:provide" capability`);
}
if (
operations.invokes?.length &&
!capabilities.includes("operations:invoke")
) {
errors.push(`${root}.invokes needs the "operations:invoke" capability`);
}
const readAction = (id: string, where: string) => {
const target = actions.get(id);
if (!target) errors.push(`${where} "${id}" is not a declared action`);
else if (target.risk !== "read") {
errors.push(`${where} "${id}" must be a read action`);
}
};
for (const action of actions.values()) {
const at = `${root}.actions["${action.id}"]`;
if (!hasPermission(action.permission)) {
errors.push(
`${at}.permission "${action.permission}" is not declared in contributes.permissions`,
);
}
if (action.resource && !resources.has(action.resource)) {
errors.push(`${at}.resource "${action.resource}" is not declared`);
}
if (action.check) readAction(action.check, `${at}.check`);
if (action.undo && !actions.has(action.undo)) {
errors.push(`${at}.undo "${action.undo}" is not a declared action`);
}
for (const field of action.inputs ?? []) {
if (field.optionsFrom) {
readAction(field.optionsFrom, `${at} input "${field.key}" optionsFrom`);
}
if (field.resource && !resources.has(field.resource)) {
errors.push(`${at} input "${field.key}" names an unknown resource`);
}
}
}
for (const resource of operations.resources ?? []) {
readAction(resource.list, `${root}.resources["${resource.id}"].list`);
}
for (const signal of operations.signals ?? []) {
const at = `${root}.signals["${signal.id}"]`;
readAction(signal.read, `${at}.read`);
if (signal.fix && !actions.has(signal.fix)) {
errors.push(`${at}.fix "${signal.fix}" is not a declared action`);
}
}
}
/** Whether a plugin's invokes list lets it run an action. */
export function invokeAllowed(
invokes: readonly string[] | undefined,
actionId: string,
): boolean {
for (const entry of invokes ?? []) {
if (entry === "*" || entry === actionId) return true;
if (entry.endsWith(".*") && actionId.startsWith(entry.slice(0, -1))) {
return true;
}
}
return false;
}
const RISK_RANK: Record<OperationRisk, number> = {
read: 0,
change: 1,
destructive: 2,
};
export function riskRank(risk: OperationRisk): number {
return RISK_RANK[risk];
}
/** A surface's own switch. The assistant and MCP are opt in, the rest opt out. */
export function onSurface(
action: Pick<OperationAction, "surfaces">,
surface: keyof OperationSurfaces,
): boolean {
const value = action.surfaces?.[surface];
if (surface === "assistant" || surface === "mcp") return value === true;
return value !== false;
}
/** Whether an action applies to a host, from fields any client can read. */
export function matchesWhen(
when: OperationWhen | undefined,
host: { settings?: Record<string, unknown>; os?: string | null },
): boolean {
if (!when) return true;
for (const [key, expected] of Object.entries(when.settings ?? {})) {
if (host.settings?.[key] !== expected) return false;
}
if (when.os?.length && host.os) {
if (!when.os.includes(host.os.toLowerCase())) return false;
}
return true;
}
function asSettingsField(field: OperationField): PluginSettingsField {
const type = field.type === "resource" ? "string" : field.type;
return { ...field, type } as PluginSettingsField;
}
function isEmpty(value: unknown): boolean {
return value === undefined || value === null || value === "";
}
export interface OperationInputResult {
values: Record<string, unknown>;
errors: string[];
}
/**
* Turns raw input into validated values. Keys no field declares are refused,
* so nothing a handler did not ask for ever reaches it.
*/
export function parseOperationInput(
fields: readonly OperationField[] | undefined,
raw: unknown,
): OperationInputResult {
const errors: string[] = [];
const values: Record<string, unknown> = {};
const list = fields ?? [];
if (raw !== undefined && raw !== null && !isObject(raw)) {
return { values, errors: ["Input must be an object"] };
}
const input = (raw ?? {}) as Record<string, unknown>;
for (const key of Object.keys(input)) {
if (!list.some((field) => field.key === key)) {
errors.push(`"${key}" is not an input of this action`);
}
}
for (const field of list) {
if (field.requires && input[field.requires] !== true) continue;
let value = input[field.key];
if (isEmpty(value)) {
if (field.default !== undefined) {
values[field.key] = field.default;
} else if (field.required) {
errors.push(`"${field.key}" is required`);
}
continue;
}
const settingsField = asSettingsField(field);
value = coerceSettingValue(settingsField, value);
if (field.type === "secret" && typeof value !== "string") {
errors.push(`"${field.key}" must be a string`);
continue;
}
if (field.type === "json" && typeof value === "string") {
try {
value = JSON.parse(value);
} catch {
errors.push(`"${field.key}" must be valid JSON`);
continue;
}
}
// A list known only at run time is checked by the handler.
const skipChoices =
field.optionsFrom &&
(field.type === "select" || field.type === "multiselect");
const problem = skipChoices
? field.type === "select"
? typeof value === "string"
? null
: `"${field.key}" must be a string`
: Array.isArray(value)
? null
: `"${field.key}" must be an array`
: validateSettingValue(settingsField, value);
if (problem) {
errors.push(problem);
continue;
}
if (
field.pattern &&
typeof value === "string" &&
!new RegExp(`^(?:${field.pattern})$`).test(value)
) {
errors.push(`"${field.key}" has the wrong format`);
continue;
}
values[field.key] = value;
}
return { values, errors };
}
/** A copy safe to store or show: secret and sensitive values are replaced. */
export function redactFields(
fields: readonly OperationField[] | undefined,
values: unknown,
): unknown {
if (!isObject(values)) return values;
const copy: Record<string, unknown> = { ...values };
for (const field of fields ?? []) {
if (field.type !== "secret" && !field.sensitive) continue;
if (field.key in copy) copy[field.key] = REDACTED_VALUE;
}
return copy;
}
/** Trims text to a byte-friendly length with a plain marker. */
export function capText(text: string, maxLength: number): string {
return text.length <= maxLength
? text
: `${text.slice(0, maxLength)}\n[cut at ${maxLength} characters]`;
}
function fieldSchema(field: OperationField): Record<string, unknown> {
const schema: Record<string, unknown> = {};
if (field.default !== undefined) schema.default = field.default;
const values = field.optionsFrom
? undefined
: field.options?.map((option) => option.value);
switch (field.type) {
case "boolean":
schema.type = "boolean";
break;
case "number":
schema.type = "number";
if (field.min !== undefined) schema.minimum = field.min;
if (field.max !== undefined) schema.maximum = field.max;
break;
case "select":
schema.type = "string";
if (values) schema.enum = values;
break;
case "multiselect":
schema.type = "array";
schema.items = values
? { type: "string", enum: values }
: { type: "string" };
break;
case "json":
break;
default:
schema.type = "string";
if (field.pattern) schema.pattern = `^(?:${field.pattern})$`;
}
if (field.type === "resource" && field.resource) {
schema["x-termix-resource"] = field.resource;
}
if (field.type === "secret") schema.writeOnly = true;
return schema;
}
/** JSON Schema for a field list, for AI tools, MCP and OpenAPI. */
export function toJsonSchema(
fields: readonly OperationField[] | undefined,
describe?: (field: OperationField) => string | undefined,
): Record<string, unknown> {
const properties: Record<string, unknown> = {};
const required: string[] = [];
for (const field of fields ?? []) {
const schema = fieldSchema(field);
const description = describe?.(field);
if (description) schema.description = description;
properties[field.key] = schema;
if (field.required && field.default === undefined && !field.requires) {
required.push(field.key);
}
}
return { type: "object", properties, required, additionalProperties: false };
}
+53 -1
View File
@@ -68,7 +68,11 @@ import type {
PluginSshKeyCredential,
PluginSshKeyCredentialInput,
} from "./backend.js";
import type { PluginSyncHost, SyncEntityRegistration } from "./backend.js";
import type {
PluginOperationHandler,
PluginSyncHost,
SyncEntityRegistration,
} from "./backend.js";
import type { PluginDatabase } from "./backend.js";
import type {
PluginExternalClientRequest,
@@ -355,6 +359,10 @@ export interface FakePluginContext {
}>;
/** The hub the plugin registered with ctx.notify.serve, if any. */
notifyHub: { current: PluginNotifyHub | null };
/** Handlers from ctx.operations.register, by action id. */
operationHandlers: Map<string, PluginOperationHandler>;
/** Every ctx.operations.emit call. */
operationEvents: { eventId: string; payload: unknown; hostId?: number }[];
/** Backing store behind ctx.secrets.get/set, keyed "<userId>:<key>". */
secretStore: Map<string, string>;
/** Every ctx.fetch call, in order. */
@@ -566,6 +574,8 @@ export function createFakeContext(
const createdSshKeys: FakePluginContext["createdSshKeys"] = [];
const notifications: FakePluginContext["notifications"] = [];
const notifyHub: FakePluginContext["notifyHub"] = { current: null };
const operationHandlers: FakePluginContext["operationHandlers"] = new Map();
const operationEvents: FakePluginContext["operationEvents"] = [];
const fetches: FakePluginContext["fetches"] = [];
const processRuns: FakePluginContext["processRuns"] = [];
const binaries: FakePluginContext["binaries"] = [];
@@ -1391,6 +1401,46 @@ export function createFakeContext(
},
},
operations: {
register: (id, handler) => {
operationHandlers.set(id, handler);
},
run: async (actionId, options = {}) => {
const handler = operationHandlers.get(actionId);
if (!handler) throw new Error(`No action "${actionId}"`);
const result = await handler.run(options.input ?? {}, {
hostId: options.hostId,
resourceKey: options.resourceKey,
runId: null,
dryRun: options.dryRun === true,
signal: new AbortController().signal,
log: () => {},
});
return { runId: null, status: "success", ...result };
},
invoke: async (actionId, options = {}) => {
const handler = operationHandlers.get(actionId);
if (!handler) throw new Error(`No action "${actionId}"`);
const result = await handler.run(options.input ?? {}, {
hostId: options.hostId,
resourceKey: options.resourceKey,
runId: null,
dryRun: options.dryRun === true,
signal: new AbortController().signal,
log: () => {},
});
return { runId: null, status: "success", ...result };
},
list: async () => ({
actions: manifest.contributes?.operations?.actions ?? [],
}),
emit: async (eventId, payload, options) => {
operationEvents.push({ eventId, payload, hostId: options?.hostId });
},
onEvent: () => () => {},
approvals: { provide: () => () => {} },
},
notify: {
channels: async () => {
if (!actor) throw new Error("ctx.notify needs an acting user");
@@ -1554,6 +1604,8 @@ export function createFakeContext(
createdSshKeys,
notifications,
notifyHub,
operationHandlers,
operationEvents,
fetches,
processRuns,
binaries,
+47 -4
View File
@@ -80,6 +80,8 @@ DEFAULT_FOLDER_COLOR (value): "#f59145"
DEFAULT_FOLDER_ICON (value): "folder"
DashboardCardId (type): DashboardCardId
DataColumn (type): DataColumn<T>
DataTable (value): ({ className, containerClassName, children, ...props }: ClassAttributes<HTMLTableElement> & TableHTMLAttributes<HTMLTableElement> & { containerClassName?: string; }) => Element
DataTableRow (value): ({ active, className, onClick, ...props }: ClassAttributes<HTMLTableRowElement> & HTMLAttributes<HTMLTableRowElement> & { active?: boolean; }) => Element
DataView (value): <T>({ items, view, density, getKey, renderCard, columns, size, minCardWidth, listColumns, onRowClick, isRowActive, empty, className, }: { items: T[]; view: PanelViewMode; density?: PanelDensity; getKey: (item: T) => string; renderCard: (item: T) => ReactNode; columns: GridColumns; size?: GridSize; minCardWidth?: number; listColumns: DataColumn<T>[]; onRowClick?: (item: T) => void; isRowActive?: (item: T) => boolean; empty: ReactNode; className?: string; }) => Element
DefaultOverrides (type): DefaultOverrides
DefaultsOnly (value): ({ settingKey, children, }: { settingKey: string; children: ReactNode; }) => Element
@@ -133,7 +135,7 @@ GridColumns (type): GridColumns
GridLayout (type): GridLayout
GridSize (type): GridSize
GridSlot (type): GridSlot
GroupHeading (value): ({ title, count, action, className, }: { title: string; count?: number; action?: ReactNode; className?: string; }) => Element
GroupHeading (value): ({ title, count, action, plain, className, }: { title: string; count?: number; action?: ReactNode; plain?: boolean; className?: string; }) => Element
HexColorPicker (value): ({ value, onChange, }: { value: string; onChange: (hex: string) => void; }) => Element
Host (type): Host
HostActionDef (type): HostActionDef
@@ -159,12 +161,16 @@ HostProtocolAuthInput (type): HostProtocolAuthInput
HostProtocolDef (type): HostProtocolDef
HostProtocols (type): HostProtocols
HostRowFields (type): HostRowFields
HostSectionDef (type): HostSectionDef
HostSidebarFilterState (type): HostSidebarFilterState
HostSidebarPreferences (type): HostSidebarPreferences
HostTabEmbed (value): ({ host, type, label, data, isVisible, shell, }: { host: Host; type: string; label?: string; data?: Record<string, unknown>; isVisible?: boolean; shell: TabShellCallbacks; }) => Element
HostTrayTrigger (type): HostTrayTrigger
HttpsSettings (type): HttpsSettings
IconPicker (value): ({ value, color, onChange, }: { value: string; color?: string; onChange: (icon: string) => void; }) => Element
InlineView (value): ({ open, onOpenChange, title, icon, status, actions, toolbar, footer, width, onBeforeClose, scroll, bare, className, children, }: { open: boolean; onOpenChange: (open: boolean) => void; title: ReactNode; icon?: ReactNode; status?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; footer?: ReactNode; width?: EditingWidth; onBeforeClose?: () => boolean | Promise<boolean>; scroll?: boolean; bare?: boolean; className?: string; children: ReactNode; }) => ReactPortal
InfoGrid (value): ({ items, className, }: { items: InfoItem[]; className?: string; }) => Element
InfoItem (type): InfoItem
InlineView (value): ({ open, onOpenChange, title, icon, status, actions, toolbar, footer, width, onBeforeClose, scroll, bare, stacked, className, children, }: { open: boolean; onOpenChange: (open: boolean) => void; title: ReactNode; icon?: ReactNode; status?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; footer?: ReactNode; width?: EditingWidth; onBeforeClose?: () => boolean | Promise<boolean>; scroll?: boolean; bare?: boolean; stacked?: boolean; className?: string; children: ReactNode; }) => ReactPortal
Input (value): ({ className, type, ...props }: DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>) => Element
Kbd (value): ForwardRefExoticComponent<KbdProps & RefAttributes<HTMLElement>>
KbdKey (value): { ({ className, ...props }: HTMLAttributes<HTMLSpanElement>): Element; displayName: string; }
@@ -178,11 +184,12 @@ Label (value): ({ className, ...props }: LabelProps & RefAttributes<HTMLLabelEle
LineChart (value): ({ series, timestamps, domain, yFormatter, height, className, }: LineChartProps) => Element
LineChartSeries (type): LineChartSeries
LinkedAccountInfo (type): LinkedAccountInfo
ListBadge (value): ({ tone, className, children, }: { tone?: "destructive" | "muted" | "brand" | "warning" | "success"; className?: string; children: ReactNode; }) => Element
ListBadge (value): ({ tone, className, children, }: { tone?: "destructive" | "warning" | "muted" | "brand" | "success"; className?: string; children: ReactNode; }) => Element
ListRow (value): ({ title, icon, badges, meta, stripe, tone, color, selected, active, dimmed, leading, trailing, actions, onClick, className, children, ...rest }: { title: ReactNode; icon?: ReactNode; badges?: ReactNode; meta?: ReactNode; stripe?: number; tone?: ListRowTone; color?: string; selected?: boolean; active?: boolean; dimmed?: boolean; leading?: ReactNode; trailing?: ReactNode; actions?: ReactNode; onClick?: () => void; className?: string; children?: ReactNode; } & Omit<HTMLAttributes<HTMLDivElement>, "title" | "children" | "onClick">) => Element
ListRowAction (value): ({ label, tone, className, children, ...rest }: { label: string; tone?: "default" | "destructive" | "brand"; className?: string; children: ReactNode; } & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & { ref?: Ref<HTMLButtonElement>; }) => Element
ListRowFolder (value): ({ name, icon, count, open, onToggle, stripe, actions, dropActive, onDropItem, emptyText, className, children, }: { name: ReactNode; icon?: ReactNode; count?: number; open: boolean; onToggle: () => void; stripe?: number; actions?: ReactNode; dropActive?: boolean; onDropItem?: () => void; emptyText?: ReactNode; className?: string; children?: ReactNode; }) => Element
ListRowTone (type): ListRowTone
LoadingState (value): ({ label, compact, className, }: { label?: string; compact?: boolean; className?: string; }) => Element
LogContext (type): LogContext
LogEntry (type): LogEntry
LookSettings (value): ({ isAdmin }: { isAdmin: boolean; }) => Element
@@ -199,6 +206,7 @@ MetricCard (value): ({ title, icon, action, children, bodyClassName, scroll, scr
MiniStat (value): ({ caption, value, valueClassName, className, }: { caption: ReactNode; value: ReactNode; valueClassName?: string; className?: string; }) => Element
NativeView (value): ({ url, ignoreCert, hidden, fallback, className, style, }: NativeViewProps) => Element
NativeViewProps (type): NativeViewProps
NoticePanel (value): ({ icon: Icon, title, children, action, tone, className, }: { icon?: LucideIcon; title: ReactNode; children?: ReactNode; action?: ReactNode; tone?: "default" | "destructive" | "warning"; className?: string; }) => Element
NumberField (value): ({ label, value, onChange, placeholder, hint, error, min, max, disabled, className, }: { label: string; value: number; onChange: (value: number) => void; placeholder?: string; hint?: ReactNode; error?: string; min?: number; max?: number; disabled?: boolean; className?: string; }) => Element
OPEN_PLUGINS_EVENT (value): "termix:open-plugins"
OpenTabRecord (type): OpenTabRecord
@@ -207,9 +215,11 @@ PRESETS (value): Record<"simple" | "balanced" | "advanced", UiAreaPreferences>
PROMPT_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 border border-border px-3 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
PROMPT_DESTRUCTIVE_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 bg-destructive px-3 text-xs font-medium text-white transition-colors hover:bg-destructive/90 focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
PROMPT_PRIMARY_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 border border-accent-brand/40 px-3 text-xs font-medium text-accent-brand transition-colors hover:bg-accent-brand/10 focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
Pager (value): ({ page, pages, hasNext, onPage, className, }: { page: number; pages?: number; hasNext?: boolean; onPage: (next: number) => void; className?: string; }) => Element
PaletteItemDef (type): PaletteItemDef
PanePrompt (value): ({ open, title, titleId, description, icon, tone, actions, onCancel, role, labelledBy, layer, backgroundColor, className, children, }: { open: boolean; title: ReactNode; titleId?: string; description?: ReactNode; icon?: ReactNode; tone?: "default" | "destructive"; actions?: ReactNode; onCancel?: () => void; role?: "dialog" | "alertdialog"; labelledBy?: string; layer?: "connection" | "pane"; backgroundColor?: string; className?: string; children?: ReactNode; }) => Element
PanelDensity (type): PanelDensity
PanelFrame (value): ({ className, children, }: { className?: string; children: ReactNode; }) => Element
PanelList (value): ({ empty, className, children, }: { empty?: ReactNode; className?: string; children?: ReactNode; }) => Element
PanelSearch (value): ({ value, onChange, placeholder, fill, autoFocus, disabled, title, onEnter, inputRef, className, }: { value: string; onChange: (next: string) => void; placeholder?: string; fill?: boolean; autoFocus?: boolean; disabled?: boolean; title?: string; onEnter?: () => void; inputRef?: Ref<HTMLInputElement>; className?: string; }) => Element
PanelShell (value): ({ chrome, icon, title, status, leading, actions, toolbar, tabs, footer, scroll, docs, className, children, }: { chrome?: boolean; icon?: ReactNode; title?: ReactNode; status?: ReactNode; leading?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; tabs?: ReactNode; footer?: ReactNode; scroll?: boolean; docs?: string; className?: string; children: ReactNode; }) => Element
@@ -234,6 +244,7 @@ RadialGaugeProps (type): RadialGaugeProps
RailGroup (type): RailGroup
RailItemDef (type): RailItemDef
RecentActivityItem (type): RecentActivityItem
RefreshButton (value): ({ onClick, loading, disabled, label, className, }: { onClick: () => void; loading?: boolean; disabled?: boolean; label?: string; className?: string; }) => Element
ReleaseInfo (type): ReleaseInfo
Repeater (value): <T>({ label, items, onChange, makeItem, empty, addLabel, hint, renderItem, }: { label: string; items: T[]; onChange: (items: T[]) => void; makeItem: () => T; empty: string; addLabel: string; hint?: ReactNode; renderItem: (item: T, update: (next: T) => void, index: number) => ReactNode; }) => Element
ResolvedHostDefault (type): ResolvedHostDefault
@@ -246,6 +257,7 @@ SSHHostWithStatus (type): SSHHostWithStatus
SSH_AUTH_PROTOCOL (value): "ssh"
ScrollArea (value): ({ className, children, ...props }: ScrollAreaProps & RefAttributes<HTMLDivElement>) => Element
ScrollBar (value): ({ className, orientation, ...props }: ScrollAreaScrollbarProps & RefAttributes<HTMLDivElement>) => Element
SeamlessTileContext (value): Context<boolean>
SecretField (value): ({ label, value, onChange, placeholder, hint, error, disabled, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; hint?: ReactNode; error?: string; disabled?: boolean; className?: string; }) => Element
SectionCard (value): ({ title, icon, action, children, className, rowId, }: { title: string; icon: ReactNode; action?: ReactNode; children: ReactNode; className?: string; rowId?: string; }) => Element
Segmented (value): <T extends string>({ value, onChange, options, className, }: { value: T; onChange: (next: T) => void; options: readonly { value: NoInfer<T>; label: string; icon?: ReactNode; count?: number; title?: string; }[]; className?: string; }) => Element
@@ -275,6 +287,9 @@ SheetHeader (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<
SheetTitle (value): ({ className, ...props }: DialogTitleProps & RefAttributes<HTMLHeadingElement>) => Element
SheetTrigger (value): ({ ...props }: DialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
ShellConfirmCapture (value): () => null
SideNav (value): ({ entries, active, onSelect, footer, label, className, }: { entries: SideNavEntry[]; label?: string; active: string; onSelect: (id: string) => void; footer?: ReactNode; className?: string; }) => Element
SideNavEntry (type): SideNavEntry
SideNavItem (value): ({ entry, active, onClick, }: { entry: SideNavEntry; active: boolean; onClick: () => void; }) => Element
Skeleton (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
Slider (value): ({ className, defaultValue, value, min, max, ...props }: SliderProps & RefAttributes<HTMLSpanElement>) => Element
SortKey (type): SortKey
@@ -284,11 +299,16 @@ SplitLayoutMenu (value): ({ activeIsSplit, canAddPane, onSplit, onPreset, }: { a
SplitOpenTarget (type): SplitOpenTarget
SplitSummary (type): SplitSummary
StatRow (value): ({ label, value, mono, className, }: { label: ReactNode; value: ReactNode; mono?: boolean; className?: string; }) => Element
StatTile (value): ({ label, icon, value, unit, percent, hint, tone, actions, onClick, children, className, }: { label: ReactNode; icon?: ReactNode; value: ReactNode; unit?: ReactNode; percent?: number; hint?: ReactNode; tone?: "default" | "ok" | "warn" | "bad"; actions?: ReactNode; onClick?: () => void; children?: ReactNode; className?: string; }) => Element
StatusBadge (value): ({ tone, state, className, children, }: { tone?: StatusTone; state?: string; className?: string; children?: ReactNode; }) => Element
StatusDot (value): ({ tone, state, pulse, className, title, }: { tone?: StatusTone; state?: string; pulse?: boolean; className?: string; title?: string; }) => Element
StatusTone (type): StatusTone
StatusValue (type): StatusValue
SurfaceKind (type): SurfaceKind
SurfaceScope (value): ({ kind, onEditingChange, onClose, className, children, }: { kind?: SurfaceKind; onClose?: () => void; onEditingChange?: (editing: EditingWidth) => void; className?: string; children: ReactNode; }) => Element
Switch (value): ({ className, ...props }: SwitchProps & RefAttributes<HTMLButtonElement>) => Element
SwitchRow (value): ({ label, description, badge, checked, onChange, disabled, defaultKey, rowId, }: { label: string; description?: ReactNode; badge?: string; checked: boolean; onChange: (value: boolean) => void; disabled?: boolean; defaultKey?: string; rowId?: string; }) => Element
TILE_COLUMNS (value): 12
TOTPDialog (value): ({ isOpen, prompt, mode, waiting, onSubmit, onCancel, backgroundColor, }: TOTPDialogProps) => Element
Tab (type): Tab
TabShellCallbacks (type): TabShellCallbacks
@@ -303,6 +323,17 @@ TextField (value): ({ label, value, onChange, placeholder, hint, error, aside, d
Textarea (value): ForwardRefExoticComponent<TextareaProps & RefAttributes<HTMLTextAreaElement>>
TextareaProps (type): TextareaProps
ThemeId (type): string
TileColumn (value): ({ className, children, }: { className?: string; children: ReactNode; }) => Element
TileEditBar (value): ({ children, onReset, onAdd, onDone, className, }: { children?: ReactNode; onReset?: () => void; onAdd?: () => void; onDone: () => void; className?: string; }) => Element
TileFrame (value): ({ title, icon, actions, bare, onTitleClick, className, bodyClassName, children, }: { title?: ReactNode; icon?: ReactNode; actions?: ReactNode; bare?: boolean; onTitleClick?: () => void; className?: string; bodyClassName?: string; children: ReactNode; }) => Element
TileGrid (value): ({ slots, editMode, renderTile, tileLabel, onChange, onSettings, gap, stackBelow, minColumns, minHeight, empty, className, }: TileGridProps) => Element
TileGridProps (type): TileGridProps
TileKind (type): TileKind
TilePicker (value): ({ entries, onAdd, onClose, className, }: { entries: TilePickerEntry[]; onAdd: (entry: TilePickerEntry) => void; onClose?: () => void; className?: string; }) => Element
TilePickerEntry (type): TilePickerEntry
TileSlot (type): TileSlot
TileStack (value): ({ className, children, }: { className?: string; children: ReactNode; }) => Element
TileWall (value): ({ size, minWidth, gridClassName, className, children, }: { size?: GridSize; minWidth?: number; gridClassName?: string; className?: string; children: ReactNode; }) => Element
TlsStatus (type): TlsStatus
ToolsTab (type): ToolsTab
Tooltip (value): ({ ...props }: TooltipProps) => Element
@@ -339,6 +370,7 @@ adminGetUserHosts (value): (targetUserId: string) => Promise<Host[]>
adminResetUserPassword (value): (userId: string, newPassword: string, confirmDataWipe?: boolean) => Promise<{ message: string; dataWiped?: boolean; }>
adminUpdateUserCredential (value): (targetUserId: string, credentialId: number, credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
adminUpdateUserHost (value): (targetUserId: string, hostId: number, hostData: HostData) => Promise<Host>
appendTile (value): (slots: TileSlot[], tile: { id: string; w?: number; h?: number; }) => TileSlot[]
applyAccentColor (value): (colorValue: string) => void
applyFontSize (value): (id: FontSizeId) => void
applyPresetSideEffects (value): (preset: "simple" | "balanced" | "advanced") => Promise<void>
@@ -354,7 +386,7 @@ changePassword (value): (oldPassword: string, newPassword: string) => Promise<an
clearLocalAdaptivePreferences (value): () => void
cn (value): (...inputs: ClassValue[]) => string
copyToClipboard (value): (text: string) => Promise<boolean>
createApiKey (value): (name: string, userId: string, expiresAt?: string) => Promise<CreatedApiKey>
createApiKey (value): (name: string, userId: string, expiresAt?: string, scopes?: string[]) => Promise<CreatedApiKey>
createCredential (value): (credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
createFrontendLogger (value): (serviceName: string, serviceIcon?: string, serviceColor?: string) => FrontendLogger
createQuickConnectHost (value): (input: QuickConnectInput) => Host
@@ -382,6 +414,7 @@ endSplitDrag (value): (commit: boolean) => void
filterPaletteItems (value): (items: PaletteItemDef[], query: string, showWhenEmpty?: boolean) => PaletteItemDef[]
findCoreHostDefault (value): (key: string) => CoreHostDefault
findMatchingKeybinding (value): (e: KeyboardEvent, bindings: CustomKeybinding[]) => CustomKeybinding
flowTiles (value): (tiles: { id: string; w: number; h?: number; }[], rowHeight?: number) => TileSlot[]
getActiveSessions (value): () => Promise<ActiveSessionInfo[]>
getAdaptiveResourceBudget (value): (kind: AdaptiveTaskKind) => AdaptiveResourceBudget
getApiKeys (value): () => Promise<{ apiKeys: ApiKey[]; }>
@@ -457,6 +490,7 @@ hostBadgesFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[]
hostEditorToolbarItemsFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[]
hostMenuItemsFor (value): (all: HostContextMenuItemDef[], host: Host) => HostContextMenuItemDef[]
hostProtocolFlags (value): (host: { enableSsh?: boolean; pluginSettings?: Record<string, Record<string, unknown>>; }, list?: HostProtocolDef[]) => HostProtocols
hostSectionsFor (value): (all: HostSectionDef[], host: Host) => HostSectionDef[]
hostStatusIndicatorsFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[]
hydrateLocalSharedHostAuth (value): <T>(host: T) => Promise<T>
invalidateHostsAndStatusCaches (value): () => void
@@ -475,6 +509,7 @@ linkExternalToPasswordAccount (value): (externalUserId: string, targetUsername:
linkedServerUrl (value): () => Promise<string>
listHostActions (value): () => HostActionDef[]
listHostProtocols (value): () => HostProtocolDef[]
listHostSections (value): () => HostSectionDef[]
loadPaletteGroup (value): (group: PaletteGroupDef) => Promise<PaletteItemDef[]>
logoutUser (value): () => Promise<{ success: boolean; message: string; }>
makeHostSshSubTabs (value): (t: (key: string, options?: { defaultValue?: string; }) => string, protocols?: Record<string, boolean>, defaultsMode?: boolean) => HostTab[]
@@ -488,6 +523,8 @@ moveSplitDrag (value): (x: number, y: number) => void
normalizeLanguageCode (value): (language?: string) => string
notifySyncChanged (value): () => void
openInSplit (value): (target: SplitOpenTarget, open: () => void) => void
operationsApi (value): AxiosInstance
pageOf (value): <T>(items: T[], page: number, size: number) => { rows: T[]; page: number; pages: number; }
paletteEntriesFor (value): (all: PaletteEntryDef[], scope: "global" | "host", host?: Host) => PaletteEntryDef[]
parseCustomKeybindings (value): (raw?: string) => CustomKeybinding[]
parseServerTime (value): (ts: string) => Date
@@ -509,6 +546,7 @@ releaseUrlFrom (value): (info: VersionInfo) => string
rem (value): (px: number) => string
removeAdminStatus (value): (userId: string) => Promise<Record<string, unknown>>
removeRoleFromUser (value): (userId: string, roleId: number) => Promise<{ success: boolean; }>
removeTile (value): (slots: TileSlot[], id: string) => TileSlot[]
renameCredentialFolder (value): (oldName: string, newName: string) => Promise<Record<string, unknown>>
renameFolder (value): (oldName: string, newName: string) => Promise<Record<string, unknown>>
reorderCredentials (value): (positions: { id: number; sortOrder: number; }[]) => Promise<{ updated: number; }>
@@ -538,6 +576,7 @@ runHostAction (value): (action: HostActionDef, host: Host, shell: TabShellCallba
runVisibleInterval (value): (tick: () => void, intervalMs: number) => () => void
sanitizeCredentialSidebarPreferences (value): (input: unknown) => CredentialSidebarPreferences
sanitizeHostSidebarPreferences (value): (input: unknown) => HostSidebarPreferences
sanitizeTiles (value): (value: unknown, min?: number) => TileSlot[]
saveCredentialSidebarPreferences (value): (preferences: CredentialSidebarPreferences) => Promise<void>
saveHostDefaultsLevel (value): (target: HostDefaultsTarget, change: HostDefaultsChange) => Promise<{ changedHosts?: number; jobId?: string; }>
saveHostSidebarPreferences (value): (preferences: HostSidebarPreferences) => Promise<void>
@@ -556,6 +595,7 @@ shareHost (value): (hostId: number, shareData: { targets: ShareTarget[]; permiss
shell (value): TabShellCallbacks
splitDefaultKey (value): (fullKey: string) => [string, string]
sshHostToHost (value): (h: SSHHostWithStatus) => Host
statusTone (value): (state: string) => StatusTone
tabIcon (value): (type: TabType) => Element
tabJumpHotkeyKeys (value): () => string[]
takePendingManageRequest (value): () => ManageRequest
@@ -598,8 +638,10 @@ useHostContextMenuItems (value): () => HostContextMenuItemDef[]
useHostEditorSections (value): () => HostEditorSectionDef[]
useHostEditorToolbarItems (value): () => HostBadgeDef[]
useHostProtocols (value): () => HostProtocolDef[]
useHostSections (value): () => HostSectionDef[]
useHostStatus (value): (hostId: number, statusCheckEnabled?: boolean) => StatusValue
useHostStatusIndicators (value): () => HostBadgeDef[]
useInSeamlessTile (value): () => boolean
useInstalledPlugins (value): () => PluginSummary[]
useIsDefaultsEditor (value): () => boolean
useIsMobile (value): () => boolean
@@ -607,6 +649,7 @@ useKeybindingActions (value): () => KeybindingActionDef[]
useKeybindingDefaults (value): () => KeybindingDefaultDef[]
useOptionalConnectionLog (value): () => ConnectionLogContextType
usePageVisibleInterval (value): (callback: () => void, intervalMs: number, enabled?: boolean, options?: Options) => void
usePaged (value): <T>(items: T[], size: number, reset?: unknown) => { setPage: Dispatch<SetStateAction<number>>; rows: T[]; page: number; pages: number; }
usePaletteEntries (value): () => PaletteEntryDef[]
usePaletteGroups (value): () => PaletteGroupDef[]
usePanels (value): () => PanelDef[]
+1 -1
View File
@@ -74,7 +74,7 @@ async function main() {
const staged = [...pinned, ...extra];
fs.writeFileSync(
path.join(destination, "bundled-index.json"),
`${JSON.stringify(buildBundledIndex(plugins, staged), null, 2)}
`${JSON.stringify(buildBundledIndex(plugins, staged, profile), null, 2)}
`,
);
+60 -1
View File
@@ -221,7 +221,34 @@ describe("buildBundledIndex", () => {
];
expect(buildBundledIndex(plugins, ["a", "c", "local"])).toEqual({
version: 1,
profile: "standard",
plugins: { a: { onboarding: { recommended: true, consent: false } } },
catalog: {
a: { tier: "full" },
b: { tier: "full" },
c: { tier: "full" },
},
});
});
it("lists every pin with its tier and version", () => {
const plugins = [
{
id: "a",
tier: "standard",
url: "https://example.com/v1.2.0/a-1.2.0.tmxplug",
onboarding: null,
},
{
id: "b",
tier: "minimal",
path: "x/b-2.0.0-beta.1.tmxplug",
onboarding: null,
},
];
expect(buildBundledIndex(plugins, [], "minimal").catalog).toEqual({
a: { tier: "standard", version: "1.2.0" },
b: { tier: "minimal", version: "2.0.0-beta.1" },
});
});
});
@@ -520,7 +547,7 @@ describe("default look", () => {
],
["look", "plain"],
),
).toEqual({ version: 1, plugins: { look: { defaultLook: true } } });
).toMatchObject({ version: 1, plugins: { look: { defaultLook: true } } });
const index = {
plugins: [
@@ -537,6 +564,38 @@ describe("default look", () => {
).toMatchObject({ id: "look", defaultLook: true });
});
it("leaves out new plugins with no usable release when pinning all", () => {
const stable = {
version: "1.0.0",
url: "https://x/a.tmxplug",
sha256: SHA,
};
const index = {
plugins: [
{ id: "ready", versions: [stable] },
{
id: "beta-only",
versions: [],
prereleases: [{ ...stable, version: "1.0.0-beta.1" }],
},
{
id: "too-new",
versions: [],
gated: [{ ...stable, engine: { termix: ">=99.0.0" } }],
},
],
};
expect(
pinsFromIndex(index, null, [], "26.11.1").map(
(pin: { id: string }) => pin.id,
),
).toEqual(["ready"]);
expect(() =>
pinsFromIndex(index, null, [{ id: "beta-only" }], "26.11.1"),
).toThrow(/beta-only/);
expect(() => pinsFromIndex(index, ["beta-only"])).toThrow(/beta-only/);
});
it("copies a local build's theme files", () => {
const repo = tempDir();
fs.mkdirSync(path.join(repo, "dist"), { recursive: true });
+43
View File
@@ -19,6 +19,49 @@ const SDK_UI = path.join(ROOT, "src", "ui", "plugin-host", "sdk-ui.ts");
const TSCONFIG = path.join(ROOT, "tsconfig.app.json");
const ALLOWED = [
"SeamlessTileContext",
"TileEditBar",
"TileStack",
"TileColumn",
"TileWall",
"useInSeamlessTile",
"NoticePanel",
"LoadingState",
"StatusBadge",
"StatusDot",
"statusTone",
"StatusTone",
"StatTile",
"InfoGrid",
"InfoItem",
"Pager",
"pageOf",
"usePaged",
"DataTable",
"DataTableRow",
"SideNav",
"SideNavItem",
"SideNavEntry",
"PanelFrame",
"RefreshButton",
"TileFrame",
"TileGrid",
"TilePicker",
"appendTile",
"flowTiles",
"removeTile",
"sanitizeTiles",
"TILE_COLUMNS",
"TileGridProps",
"TileKind",
"TilePickerEntry",
"TileSlot",
"useHostSections",
"operationsApi",
"listHostSections",
"hostSectionsFor",
"HostTabEmbed",
"HostSectionDef",
"ACCENT_PRESET_COLORS",
"APP_VERSION",
"AccessRecord",
+21 -6
View File
@@ -236,12 +236,22 @@ async function extractArtifact(buffer, outDir, id) {
/**
* What dist/plugins/bundled-index.json holds: the onboarding defaults of each
* staged plugin, read by the backend when it seeds a fresh install.
* staged plugin, read by the backend when it seeds a fresh install, plus the
* profile and every pin it ships, so an existing install can fetch a shipped
* plugin it finds missing.
*/
function buildBundledIndex(plugins, stagedIds) {
function buildBundledIndex(plugins, stagedIds, profile = DEFAULT_PROFILE) {
const staged = new Set(stagedIds);
const out = {};
const catalog = {};
for (const plugin of plugins) {
const version = /-(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\.tmxplug$/.exec(
plugin.url ?? plugin.path ?? "",
)?.[1];
catalog[plugin.id] = {
tier: plugin.tier ?? "full",
...(version ? { version } : {}),
};
if (!staged.has(plugin.id)) continue;
if (!plugin.onboarding && !plugin.defaultLook) continue;
out[plugin.id] = {
@@ -249,7 +259,7 @@ function buildBundledIndex(plugins, stagedIds) {
...(plugin.defaultLook ? { defaultLook: true } : {}),
};
}
return { version: 1, plugins: out };
return { version: 1, profile, plugins: out, catalog };
}
/** The profile to build: --profile, then TERMIX_PLUGIN_PROFILE, then standard. */
@@ -278,14 +288,15 @@ function pluginsForProfile(plugins, profile) {
* pin list, so fields the registry does not know (onboarding, tier) carry over;
* a plugin new to the list starts in the full tier. coreVersion, when given,
* skips releases whose engine.termix range does not include it, so the image
* never ships a plugin its own core cannot run.
* never ships a plugin its own core cannot run. With ids null, a plugin new to
* the list that has no usable release (only betas, say) is left out.
*/
function pinsFromIndex(index, ids, current = [], coreVersion = null) {
const plugins = Array.isArray(index?.plugins) ? index.plugins : [];
const byId = new Map(plugins.map((plugin) => [plugin.id, plugin]));
const currentById = new Map(current.map((entry) => [entry.id, entry]));
const wanted = ids ?? plugins.map((plugin) => plugin.id);
return [...wanted].sort().map((id) => {
const pins = [...wanted].sort().map((id) => {
const listed = byId.get(id);
// gated holds releases that need a newer Termix than old builds check for.
const latest = [...(listed?.versions ?? []), ...(listed?.gated ?? [])]
@@ -300,7 +311,10 @@ function pinsFromIndex(index, ids, current = [], coreVersion = null) {
}),
)
.sort((a, b) => semver.rcompare(a.version, b.version))[0];
if (!latest) throw new Error(`${id} is not in the registry index`);
if (!latest) {
if (ids === null && !currentById.has(id)) return null;
throw new Error(`${id} is not in the registry index`);
}
const onboarding = currentById.get(id)?.onboarding;
const defaultLook = currentById.get(id)?.defaultLook === true;
const tier = currentById.get(id)?.tier ?? "full";
@@ -314,6 +328,7 @@ function pinsFromIndex(index, ids, current = [], coreVersion = null) {
...(defaultLook ? { defaultLook } : {}),
};
});
return pins.filter(Boolean);
}
const LOCAL_FILES = [
+1 -1
View File
@@ -4,7 +4,7 @@
* re-released.
*
* node scripts/pin-bundled-plugins.cjs refresh the plugins already listed
* node scripts/pin-bundled-plugins.cjs --all also add plugins new to the index (tier full)
* node scripts/pin-bundled-plugins.cjs --all also add plugins new to the index that have a stable release (tier full)
* --core-version X skip releases whose engine.termix excludes X
*
* The release workflow runs it with --all and the version being released.
+2
View File
@@ -30,6 +30,7 @@ import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
import { syncForwarder } from "../sync/provider.js";
import { syncChangeWatcher } from "../sync/server/change-watcher.js";
import dashboardRoutes from "./routes/dashboard-routes.js";
import operationsRoutes from "./routes/operations-routes.js";
import {
mountPluginApi,
mountPluginLegacyPaths,
@@ -1627,6 +1628,7 @@ app.use("/ui-preferences", uiPreferencesRoutes);
registerAuditLogRoutes(app, authenticateJWT);
app.use("/sync", syncForwarder);
app.use("/dashboard", dashboardRoutes);
app.use("/operations", operationsRoutes);
app.use("/plugins", pluginRoutes);
app.use(
"/plugin-assets",
+65
View File
@@ -599,6 +599,70 @@ async function initializeCompleteDatabase(): Promise<void> {
UNIQUE (role_name, permission)
);
CREATE TABLE IF NOT EXISTS operation_runs (
id TEXT PRIMARY KEY,
action_id TEXT NOT NULL,
action_version INTEGER NOT NULL DEFAULT 1,
plugin_id TEXT NOT NULL,
plugin_version TEXT NOT NULL,
user_id TEXT REFERENCES users (id) ON DELETE SET NULL,
username TEXT NOT NULL,
origin TEXT NOT NULL,
host_id INTEGER,
host_name TEXT,
batch_id TEXT,
dry_run INTEGER NOT NULL DEFAULT 0,
risk TEXT NOT NULL,
status TEXT NOT NULL,
summary TEXT,
changed INTEGER,
input TEXT,
sealed_input TEXT,
output TEXT,
undo_input TEXT,
error TEXT,
error_code TEXT,
undo_of TEXT,
idempotency_key TEXT,
created_at TEXT NOT NULL,
started_at TEXT,
finished_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_operation_runs_created ON operation_runs (created_at);
CREATE INDEX IF NOT EXISTS idx_operation_runs_user_created ON operation_runs (user_id, created_at);
CREATE INDEX IF NOT EXISTS idx_operation_runs_host_created ON operation_runs (host_id, created_at);
CREATE INDEX IF NOT EXISTS idx_operation_runs_status ON operation_runs (status);
CREATE INDEX IF NOT EXISTS idx_operation_runs_batch ON operation_runs (batch_id);
CREATE TABLE IF NOT EXISTS operation_approvals (
id TEXT PRIMARY KEY,
run_id TEXT NOT NULL REFERENCES operation_runs (id) ON DELETE CASCADE,
requested_by TEXT,
reason TEXT,
allow_self INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL,
decided_by TEXT,
comment TEXT,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
decided_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_operation_approvals_status ON operation_approvals (status, expires_at);
CREATE INDEX IF NOT EXISTS idx_operation_approvals_run ON operation_approvals (run_id);
CREATE TABLE IF NOT EXISTS operation_events (
id TEXT PRIMARY KEY,
event_id TEXT NOT NULL,
plugin_id TEXT NOT NULL,
host_id INTEGER,
severity TEXT,
payload TEXT,
run_id TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_operation_events_created ON operation_events (created_at);
CREATE INDEX IF NOT EXISTS idx_operation_events_host_created ON operation_events (host_id, created_at);
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
@@ -961,6 +1025,7 @@ const enforceUniqueUsernames = () => {
const migrateSchema = () => {
repairSnippetsColumns(sqlite);
addColumnIfNotExists("api_keys", "scopes", "TEXT");
addColumnIfNotExists("user_preferences", "theme", "TEXT");
addColumnIfNotExists("user_preferences", "font_size", "TEXT");
addColumnIfNotExists("user_preferences", "accent_color", "TEXT");
+92
View File
@@ -673,10 +673,102 @@ export const apiKeys = mysqlTable(
expiresAt: text("expires_at"),
lastUsedAt: text("last_used_at"),
isActive: boolean("is_active").notNull().default(true),
// JSON list. Null means full access, which every older key has.
scopes: text("scopes"),
},
(table) => [index("idx_api_keys_user_id").on(table.userId)],
);
export const operationRuns = mysqlTable(
"operation_runs",
{
id: varchar("id", { length: 255 }).primaryKey(),
actionId: text("action_id").notNull(),
actionVersion: int("action_version").notNull().default(1),
pluginId: text("plugin_id").notNull(),
pluginVersion: text("plugin_version").notNull(),
// Nullable on purpose: history outlives the account, like audit_logs.
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "set null" }),
username: text("username").notNull(),
origin: text("origin").notNull(),
hostId: int("host_id"),
hostName: text("host_name"),
batchId: varchar("batch_id", { length: 255 }),
dryRun: boolean("dry_run").notNull().default(false),
risk: text("risk").notNull(),
status: varchar("status", { length: 255 }).notNull(),
summary: text("summary"),
changed: boolean("changed"),
input: text("input"),
// Only until the run ends, then wiped.
sealedInput: text("sealed_input"),
output: text("output"),
undoInput: text("undo_input"),
error: text("error"),
errorCode: text("error_code"),
undoOf: text("undo_of"),
idempotencyKey: text("idempotency_key"),
createdAt: varchar("created_at", { length: 255 }).notNull(),
startedAt: text("started_at"),
finishedAt: text("finished_at"),
},
(table) => [
index("idx_operation_runs_created").on(table.createdAt),
index("idx_operation_runs_user_created").on(table.userId, table.createdAt),
index("idx_operation_runs_host_created").on(table.hostId, table.createdAt),
index("idx_operation_runs_status").on(table.status),
index("idx_operation_runs_batch").on(table.batchId),
],
);
export const operationApprovals = mysqlTable(
"operation_approvals",
{
id: varchar("id", { length: 255 }).primaryKey(),
runId: varchar("run_id", { length: 255 })
.notNull()
.references(() => operationRuns.id, { onDelete: "cascade" }),
requestedBy: text("requested_by"),
reason: text("reason"),
allowSelf: boolean("allow_self")
.notNull()
.default(false),
status: varchar("status", { length: 255 }).notNull(),
decidedBy: text("decided_by"),
comment: text("comment"),
createdAt: text("created_at").notNull(),
expiresAt: varchar("expires_at", { length: 255 }).notNull(),
decidedAt: text("decided_at"),
},
(table) => [
index("idx_operation_approvals_status").on(table.status, table.expiresAt),
index("idx_operation_approvals_run").on(table.runId),
],
);
export const operationEvents = mysqlTable(
"operation_events",
{
id: varchar("id", { length: 255 }).primaryKey(),
eventId: text("event_id").notNull(),
pluginId: text("plugin_id").notNull(),
hostId: int("host_id"),
severity: text("severity"),
payload: text("payload"),
runId: text("run_id"),
createdAt: varchar("created_at", { length: 255 }).notNull(),
},
(table) => [
index("idx_operation_events_created").on(table.createdAt),
index("idx_operation_events_host_created").on(
table.hostId,
table.createdAt,
),
],
);
export const userOpenTabs = mysqlTable(
"user_open_tabs",
{
+92
View File
@@ -673,10 +673,102 @@ export const apiKeys = pgTable(
expiresAt: text("expires_at"),
lastUsedAt: text("last_used_at"),
isActive: boolean("is_active").notNull().default(true),
// JSON list. Null means full access, which every older key has.
scopes: text("scopes"),
},
(table) => [index("idx_api_keys_user_id").on(table.userId)],
);
export const operationRuns = pgTable(
"operation_runs",
{
id: varchar("id", { length: 255 }).primaryKey(),
actionId: text("action_id").notNull(),
actionVersion: integer("action_version").notNull().default(1),
pluginId: text("plugin_id").notNull(),
pluginVersion: text("plugin_version").notNull(),
// Nullable on purpose: history outlives the account, like audit_logs.
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "set null" }),
username: text("username").notNull(),
origin: text("origin").notNull(),
hostId: integer("host_id"),
hostName: text("host_name"),
batchId: varchar("batch_id", { length: 255 }),
dryRun: boolean("dry_run").notNull().default(false),
risk: text("risk").notNull(),
status: varchar("status", { length: 255 }).notNull(),
summary: text("summary"),
changed: boolean("changed"),
input: text("input"),
// Only until the run ends, then wiped.
sealedInput: text("sealed_input"),
output: text("output"),
undoInput: text("undo_input"),
error: text("error"),
errorCode: text("error_code"),
undoOf: text("undo_of"),
idempotencyKey: text("idempotency_key"),
createdAt: varchar("created_at", { length: 255 }).notNull(),
startedAt: text("started_at"),
finishedAt: text("finished_at"),
},
(table) => [
index("idx_operation_runs_created").on(table.createdAt),
index("idx_operation_runs_user_created").on(table.userId, table.createdAt),
index("idx_operation_runs_host_created").on(table.hostId, table.createdAt),
index("idx_operation_runs_status").on(table.status),
index("idx_operation_runs_batch").on(table.batchId),
],
);
export const operationApprovals = pgTable(
"operation_approvals",
{
id: varchar("id", { length: 255 }).primaryKey(),
runId: varchar("run_id", { length: 255 })
.notNull()
.references(() => operationRuns.id, { onDelete: "cascade" }),
requestedBy: text("requested_by"),
reason: text("reason"),
allowSelf: boolean("allow_self")
.notNull()
.default(false),
status: varchar("status", { length: 255 }).notNull(),
decidedBy: text("decided_by"),
comment: text("comment"),
createdAt: text("created_at").notNull(),
expiresAt: varchar("expires_at", { length: 255 }).notNull(),
decidedAt: text("decided_at"),
},
(table) => [
index("idx_operation_approvals_status").on(table.status, table.expiresAt),
index("idx_operation_approvals_run").on(table.runId),
],
);
export const operationEvents = pgTable(
"operation_events",
{
id: varchar("id", { length: 255 }).primaryKey(),
eventId: text("event_id").notNull(),
pluginId: text("plugin_id").notNull(),
hostId: integer("host_id"),
severity: text("severity"),
payload: text("payload"),
runId: text("run_id"),
createdAt: varchar("created_at", { length: 255 }).notNull(),
},
(table) => [
index("idx_operation_events_created").on(table.createdAt),
index("idx_operation_events_host_created").on(
table.hostId,
table.createdAt,
),
],
);
export const userOpenTabs = pgTable(
"user_open_tabs",
{
+92
View File
@@ -663,10 +663,102 @@ export const apiKeys = sqliteTable(
expiresAt: text("expires_at"),
lastUsedAt: text("last_used_at"),
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
// JSON list. Null means full access, which every older key has.
scopes: text("scopes"),
},
(table) => [index("idx_api_keys_user_id").on(table.userId)],
);
export const operationRuns = sqliteTable(
"operation_runs",
{
id: text("id").primaryKey(),
actionId: text("action_id").notNull(),
actionVersion: integer("action_version").notNull().default(1),
pluginId: text("plugin_id").notNull(),
pluginVersion: text("plugin_version").notNull(),
// Nullable on purpose: history outlives the account, like audit_logs.
userId: text("user_id").references(() => users.id, { onDelete: "set null" }),
username: text("username").notNull(),
origin: text("origin").notNull(),
hostId: integer("host_id"),
hostName: text("host_name"),
batchId: text("batch_id"),
dryRun: integer("dry_run", { mode: "boolean" }).notNull().default(false),
risk: text("risk").notNull(),
status: text("status").notNull(),
summary: text("summary"),
changed: integer("changed", { mode: "boolean" }),
input: text("input"),
// Only until the run ends, then wiped.
sealedInput: text("sealed_input"),
output: text("output"),
undoInput: text("undo_input"),
error: text("error"),
errorCode: text("error_code"),
undoOf: text("undo_of"),
idempotencyKey: text("idempotency_key"),
createdAt: text("created_at").notNull(),
startedAt: text("started_at"),
finishedAt: text("finished_at"),
},
(table) => [
index("idx_operation_runs_created").on(table.createdAt),
index("idx_operation_runs_user_created").on(table.userId, table.createdAt),
index("idx_operation_runs_host_created").on(table.hostId, table.createdAt),
index("idx_operation_runs_status").on(table.status),
index("idx_operation_runs_batch").on(table.batchId),
],
);
export const operationApprovals = sqliteTable(
"operation_approvals",
{
id: text("id").primaryKey(),
runId: text("run_id")
.notNull()
.references(() => operationRuns.id, { onDelete: "cascade" }),
requestedBy: text("requested_by"),
reason: text("reason"),
allowSelf: integer("allow_self", { mode: "boolean" })
.notNull()
.default(false),
status: text("status").notNull(),
decidedBy: text("decided_by"),
comment: text("comment"),
createdAt: text("created_at").notNull(),
expiresAt: text("expires_at").notNull(),
decidedAt: text("decided_at"),
},
(table) => [
index("idx_operation_approvals_status").on(table.status, table.expiresAt),
index("idx_operation_approvals_run").on(table.runId),
],
);
export const operationEvents = sqliteTable(
"operation_events",
{
id: text("id").primaryKey(),
eventId: text("event_id").notNull(),
pluginId: text("plugin_id").notNull(),
hostId: integer("host_id"),
severity: text("severity"),
payload: text("payload"),
runId: text("run_id"),
createdAt: text("created_at").notNull(),
},
(table) => [
index("idx_operation_events_created").on(table.createdAt),
index("idx_operation_events_host_created").on(
table.hostId,
table.createdAt,
),
],
);
export const userOpenTabs = sqliteTable(
"user_open_tabs",
{
@@ -17,6 +17,8 @@ export interface ApiKeyListRecord {
expiresAt: string | null;
lastUsedAt: string | null;
isActive: boolean;
/** JSON list, or null for full access. */
scopes: string | null;
}
export class ApiKeyRepository {
@@ -43,6 +45,7 @@ export class ApiKeyRepository {
expiresAt: apiKeys.expiresAt,
lastUsedAt: apiKeys.lastUsedAt,
isActive: apiKeys.isActive,
scopes: apiKeys.scopes,
})
.from(apiKeys)
.leftJoin(users, eq(apiKeys.userId, users.id))
@@ -13,6 +13,7 @@ import { HostSidebarPreferenceRepository } from "./host-sidebar-preference-repos
import { CredentialSidebarPreferenceRepository } from "./credential-sidebar-preference-repository.js";
import { UiPreferenceRepository } from "./ui-preference-repository.js";
import { OpenTabRepository } from "./open-tab-repository.js";
import { OperationRepository } from "./operation-repository.js";
import { PluginRepository } from "./plugin-repository.js";
import { PluginStorageRepository } from "./plugin-storage-repository.js";
import { PluginSettingsRepository } from "./plugin-settings-repository.js";
@@ -134,6 +135,14 @@ export function createCurrentAuditLogRepository(): AuditLogRepository {
);
}
export function createCurrentOperationRepository(): OperationRepository {
return new OperationRepository(
createCurrentRepositoryContext(),
// Rows land on every run. A rollout saves once on its own when it starts.
createCurrentRepositoryLazyWriteHook("operation_repository_write"),
);
}
export function createCurrentCredentialRepository(): CredentialRepository {
return new CredentialRepository(
createCurrentRepositoryContext(),
@@ -0,0 +1,255 @@
import { and, desc, eq, inArray, lt, or, sql } from "drizzle-orm";
import {
operationApprovals,
operationEvents,
operationRuns,
} from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { countValue, rowsAffected } from "./mutation-result.js";
export type OperationRunRecord = typeof operationRuns.$inferSelect;
export type NewOperationRunRecord = typeof operationRuns.$inferInsert;
export type OperationApprovalRecord = typeof operationApprovals.$inferSelect;
export type NewOperationApprovalRecord = typeof operationApprovals.$inferInsert;
export type OperationEventRecord = typeof operationEvents.$inferSelect;
export type NewOperationEventRecord = typeof operationEvents.$inferInsert;
export interface OperationRunFilters {
userId?: string;
/** Rows also visible because the viewer owns the host. */
orHostIds?: number[];
hostId?: number;
actionId?: string;
status?: string;
batchId?: string;
before?: string;
}
const MAX_PAGE = 200;
export class OperationRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async insertRun(run: NewOperationRunRecord): Promise<void> {
await this.context.drizzle.insert(operationRuns).values(run);
await this.onWrite?.();
}
async findRun(id: string): Promise<OperationRunRecord | null> {
const rows = await this.context.drizzle
.select()
.from(operationRuns)
.where(eq(operationRuns.id, id))
.limit(1);
return rows[0] ?? null;
}
async findByIdempotencyKey(
userId: string,
key: string,
): Promise<OperationRunRecord | null> {
const rows = await this.context.drizzle
.select()
.from(operationRuns)
.where(
and(
eq(operationRuns.userId, userId),
eq(operationRuns.idempotencyKey, key),
),
)
.limit(1);
return rows[0] ?? null;
}
async listRuns(
filters: OperationRunFilters,
limit = 50,
): Promise<OperationRunRecord[]> {
const owner = filters.userId
? filters.orHostIds?.length
? or(
eq(operationRuns.userId, filters.userId),
inArray(operationRuns.hostId, filters.orHostIds),
)
: eq(operationRuns.userId, filters.userId)
: undefined;
const conditions = [
owner,
filters.hostId !== undefined
? eq(operationRuns.hostId, filters.hostId)
: undefined,
filters.actionId
? eq(operationRuns.actionId, filters.actionId)
: undefined,
filters.status ? eq(operationRuns.status, filters.status) : undefined,
filters.batchId ? eq(operationRuns.batchId, filters.batchId) : undefined,
filters.before ? lt(operationRuns.createdAt, filters.before) : undefined,
].filter((condition) => condition !== undefined);
return this.context.drizzle
.select()
.from(operationRuns)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(desc(operationRuns.createdAt))
.limit(Math.min(Math.max(limit, 1), MAX_PAGE));
}
/** Moves a run on only from the status given, so two writers cannot race. */
async updateRunFrom(
id: string,
fromStatus: string,
patch: Partial<NewOperationRunRecord>,
): Promise<boolean> {
const result = await this.context.drizzle
.update(operationRuns)
.set(patch)
.where(
and(eq(operationRuns.id, id), eq(operationRuns.status, fromStatus)),
);
await this.onWrite?.();
return rowsAffected(result) > 0;
}
/** Anything still running when the server stopped has an unknown outcome. */
async markRunningInterrupted(finishedAt: string): Promise<number> {
const result = await this.context.drizzle
.update(operationRuns)
.set({
status: "interrupted",
finishedAt,
sealedInput: null,
error: "The server stopped during this run. Check the host.",
})
.where(eq(operationRuns.status, "running"));
await this.onWrite?.();
return rowsAffected(result);
}
async insertApproval(approval: NewOperationApprovalRecord): Promise<void> {
await this.context.drizzle.insert(operationApprovals).values(approval);
await this.onWrite?.();
}
async findApproval(id: string): Promise<OperationApprovalRecord | null> {
const rows = await this.context.drizzle
.select()
.from(operationApprovals)
.where(eq(operationApprovals.id, id))
.limit(1);
return rows[0] ?? null;
}
async findApprovalForRun(
runId: string,
): Promise<OperationApprovalRecord | null> {
const rows = await this.context.drizzle
.select()
.from(operationApprovals)
.where(eq(operationApprovals.runId, runId))
.limit(1);
return rows[0] ?? null;
}
async listPendingApprovals(limit = 100): Promise<OperationApprovalRecord[]> {
return this.context.drizzle
.select()
.from(operationApprovals)
.where(eq(operationApprovals.status, "pending"))
.orderBy(desc(operationApprovals.createdAt))
.limit(Math.min(Math.max(limit, 1), MAX_PAGE));
}
/** One shot: only the first decision on a pending row wins. */
async decideApproval(
id: string,
status: "approved" | "rejected" | "expired",
decidedBy: string | null,
comment: string | null,
decidedAt: string,
): Promise<boolean> {
const result = await this.context.drizzle
.update(operationApprovals)
.set({ status, decidedBy, comment, decidedAt })
.where(
and(
eq(operationApprovals.id, id),
eq(operationApprovals.status, "pending"),
),
);
await this.onWrite?.();
return rowsAffected(result) > 0;
}
async listExpiredApprovals(now: string): Promise<OperationApprovalRecord[]> {
return this.context.drizzle
.select()
.from(operationApprovals)
.where(
and(
eq(operationApprovals.status, "pending"),
lt(operationApprovals.expiresAt, now),
),
)
.limit(MAX_PAGE);
}
async insertEvent(event: NewOperationEventRecord): Promise<void> {
await this.context.drizzle.insert(operationEvents).values(event);
await this.onWrite?.();
}
async listEvents(
filters: { hostId?: number; before?: string },
limit = 50,
): Promise<OperationEventRecord[]> {
const conditions = [
filters.hostId !== undefined
? eq(operationEvents.hostId, filters.hostId)
: undefined,
filters.before
? lt(operationEvents.createdAt, filters.before)
: undefined,
].filter((condition) => condition !== undefined);
return this.context.drizzle
.select()
.from(operationEvents)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(desc(operationEvents.createdAt))
.limit(Math.min(Math.max(limit, 1), MAX_PAGE));
}
/** Drops rows older than the cutoff, finished runs only. */
async prune(before: string): Promise<number> {
const runs = await this.context.drizzle
.delete(operationRuns)
.where(
and(
lt(operationRuns.createdAt, before),
sql`${operationRuns.status} NOT IN ('running', 'pending_approval')`,
),
);
const events = await this.context.drizzle
.delete(operationEvents)
.where(lt(operationEvents.createdAt, before));
await this.onWrite?.();
return rowsAffected(runs) + rowsAffected(events);
}
async countRuns(): Promise<number> {
const rows = await this.context.drizzle
.select({ value: sql<number>`count(*)` })
.from(operationRuns);
return countValue(rows[0]?.value);
}
/** History outlives the account, but the name goes with it. */
async anonymizeUser(userId: string): Promise<void> {
await this.context.drizzle
.update(operationRuns)
.set({ username: "deleted user" })
.where(eq(operationRuns.userId, userId));
await this.onWrite?.();
}
}
@@ -3,6 +3,7 @@ import { recentActivity } from "../../plugins/activity-provider.js";
import {
createCurrentApiKeyRepository,
createCurrentAuditLogRepository,
createCurrentOperationRepository,
createCurrentCredentialRepository,
createCurrentHostFolderRepository,
createCurrentHostRepository,
@@ -61,6 +62,7 @@ export async function deleteUserAndRelatedData(
await createCurrentRoleRepository().removeAllRolesFromUser(userId);
await createCurrentAuditLogRepository().anonymizeByUserId(userId);
await createCurrentOperationRepository().anonymizeUser(userId);
await createCurrentSshCredentialUsageRepository().deleteByUserId(userId);
@@ -0,0 +1,761 @@
import express, { type Request, type Response } from "express";
import type {
ResourceListResult,
SignalResult,
} from "@termix-ssh/plugin-sdk/operations";
import { AuthManager } from "../../utils/auth-manager.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import { databaseLogger } from "../../utils/logger.js";
import { getErrorMessage } from "../../utils/error-message.js";
import { createCurrentOperationRepository } from "../repositories/factory.js";
import type { OperationRunRecord } from "../repositories/operation-repository.js";
import { buildCatalog } from "../../operations/catalog.js";
import {
cancelOperationRun,
decideOperationApproval,
OperationError,
runOperation,
} from "../../operations/gate.js";
import { getAction } from "../../operations/registry.js";
import {
flattenResourceRows,
readAcrossHosts,
} from "../../operations/resources.js";
import type { ActorOrigin } from "../../plugins/actor.js";
const router = express.Router();
const authenticateJWT = AuthManager.getInstance().createAuthMiddleware();
const permissions = PermissionManager.getInstance();
/** Sees every run on the server, not only their own. */
export const VIEW_ALL_RUNS = "admin.operations.view";
type OperationsRequest = Request & {
userId: string;
apiKeyId?: string;
apiKeyScopes?: string[] | null;
actingAdminUserId?: string;
};
function originOf(req: OperationsRequest): ActorOrigin {
return { kind: req.apiKeyId ? "api" : "ui" };
}
function sendError(res: Response, error: unknown, operation: string): void {
if (error instanceof OperationError) {
res.status(error.status).json({
error: error.message,
code: error.code,
details: error.details,
});
return;
}
databaseLogger.error("Operations route failed", error, { operation });
res.status(500).json({ error: getErrorMessage(error, "Request failed") });
}
function parseJson(text: string | null): unknown {
if (!text) return undefined;
try {
return JSON.parse(text);
} catch {
return undefined;
}
}
/** Never includes the sealed input. */
function publicRun(run: OperationRunRecord) {
return {
id: run.id,
actionId: run.actionId,
pluginId: run.pluginId,
pluginVersion: run.pluginVersion,
userId: run.userId,
username: run.username,
origin: parseJson(run.origin),
hostId: run.hostId,
hostName: run.hostName,
batchId: run.batchId,
dryRun: run.dryRun,
risk: run.risk,
status: run.status,
summary: run.summary,
changed: run.changed,
input: parseJson(run.input),
output: parseJson(run.output),
undoAvailable: run.undoInput !== null,
error: run.error,
errorCode: run.errorCode,
undoOf: run.undoOf,
createdAt: run.createdAt,
startedAt: run.startedAt,
finishedAt: run.finishedAt,
};
}
async function canSeeRun(
run: OperationRunRecord,
userId: string,
): Promise<boolean> {
if (run.userId === userId) return true;
if (await permissions.hasPermission(userId, VIEW_ALL_RUNS)) return true;
if (run.hostId === null) return false;
const access = await permissions.canAccessHost(userId, run.hostId, "manage");
return access.hasAccess;
}
function hostIdsFrom(value: unknown): number[] | undefined {
if (typeof value !== "string" || value === "") return undefined;
const ids = value
.split(",")
.map(Number)
.filter((id) => Number.isInteger(id));
return ids.length > 0 ? ids : undefined;
}
function limitFrom(value: unknown): number | undefined {
const limit = Number(value);
return Number.isInteger(limit) && limit > 0 ? limit : undefined;
}
/**
* @openapi
* /operations/catalog:
* get:
* summary: List what this user can do
* description: Every action, event, resource and signal from running plugins that the caller has permission for, with titles in the requested language.
* tags:
* - Operations
* parameters:
* - in: query
* name: lang
* schema:
* type: string
* - in: query
* name: surface
* schema:
* type: string
* enum: [assistant, mcp, palette, automations, mobile, cli]
* responses:
* 200:
* description: The catalog.
*/
router.get("/catalog", authenticateJWT, async (req, res) => {
const { userId } = req as OperationsRequest;
try {
res.json(
await buildCatalog({
userId,
lang: typeof req.query.lang === "string" ? req.query.lang : undefined,
surface:
typeof req.query.surface === "string"
? (req.query.surface as never)
: undefined,
}),
);
} catch (error) {
sendError(res, error, "operations_catalog");
}
});
/**
* @openapi
* /operations/catalog/{id}:
* get:
* summary: Describe one action
* tags:
* - Operations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The action.
* 404:
* description: No such action, or the caller may not use it.
*/
router.get("/catalog/:id", authenticateJWT, async (req, res) => {
const { userId } = req as OperationsRequest;
try {
const catalog = await buildCatalog({
userId,
lang: typeof req.query.lang === "string" ? req.query.lang : undefined,
});
const action = catalog.actions.find((entry) => entry.id === req.params.id);
if (!action) {
res.status(404).json({ error: "Action not found" });
return;
}
res.json(action);
} catch (error) {
sendError(res, error, "operations_catalog_action");
}
});
/**
* @openapi
* /operations/runs:
* get:
* summary: List runs
* description: The caller's own runs, runs on hosts they manage, or every run with the view permission.
* tags:
* - Operations
* parameters:
* - in: query
* name: hostId
* schema:
* type: integer
* - in: query
* name: actionId
* schema:
* type: string
* - in: query
* name: status
* schema:
* type: string
* - in: query
* name: batchId
* schema:
* type: string
* - in: query
* name: before
* schema:
* type: string
* - in: query
* name: limit
* schema:
* type: integer
* responses:
* 200:
* description: Runs, newest first.
*/
router.get("/runs", authenticateJWT, async (req, res) => {
const { userId } = req as OperationsRequest;
try {
const viewAll = await permissions.hasPermission(userId, VIEW_ALL_RUNS);
const hostId = limitFrom(req.query.hostId);
let managed = false;
if (!viewAll && hostId !== undefined) {
managed = (await permissions.canAccessHost(userId, hostId, "manage"))
.hasAccess;
}
const runs = await createCurrentOperationRepository().listRuns(
{
userId: viewAll || managed ? undefined : userId,
hostId,
actionId:
typeof req.query.actionId === "string"
? req.query.actionId
: undefined,
status:
typeof req.query.status === "string" ? req.query.status : undefined,
batchId:
typeof req.query.batchId === "string" ? req.query.batchId : undefined,
before:
typeof req.query.before === "string" ? req.query.before : undefined,
},
limitFrom(req.query.limit),
);
res.json({ runs: runs.map(publicRun) });
} catch (error) {
sendError(res, error, "operations_runs");
}
});
/**
* @openapi
* /operations/runs/{id}:
* get:
* summary: Get a run
* tags:
* - Operations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The run.
* 404:
* description: Not found, or not visible to the caller.
*/
router.get("/runs/:id", authenticateJWT, async (req, res) => {
const { userId } = req as OperationsRequest;
try {
const run = await createCurrentOperationRepository().findRun(
String(req.params.id),
);
if (!run || !(await canSeeRun(run, userId))) {
res.status(404).json({ error: "Run not found" });
return;
}
res.json(publicRun(run));
} catch (error) {
sendError(res, error, "operations_run");
}
});
/**
* @openapi
* /operations/runs/{id}/cancel:
* post:
* summary: Cancel a run
* description: Stops a running action, or withdraws one waiting for approval.
* tags:
* - Operations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Whether anything was cancelled.
* 404:
* description: Not found, or not visible to the caller.
*/
router.post("/runs/:id/cancel", authenticateJWT, async (req, res) => {
const { userId } = req as OperationsRequest;
try {
const run = await createCurrentOperationRepository().findRun(
String(req.params.id),
);
if (!run || !(await canSeeRun(run, userId))) {
res.status(404).json({ error: "Run not found" });
return;
}
res.json({ cancelled: await cancelOperationRun(run) });
} catch (error) {
sendError(res, error, "operations_cancel");
}
});
/**
* @openapi
* /operations/runs/{id}/undo:
* post:
* summary: Undo a run
* description: Runs the action's undo with the input the run recorded. It goes through the same checks as any run.
* tags:
* - Operations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The new run.
* 404:
* description: Not found, or nothing to undo.
*/
router.post("/runs/:id/undo", authenticateJWT, async (req, res) => {
const authReq = req as OperationsRequest;
try {
const run = await createCurrentOperationRepository().findRun(
String(req.params.id),
);
const undo = run ? getAction(run.actionId)?.action.undo : undefined;
if (
!run ||
!undo ||
!run.undoInput ||
!(await canSeeRun(run, authReq.userId))
) {
res.status(404).json({ error: "Nothing to undo" });
return;
}
res.json(
await runOperation({
actionId: undo,
hostId: run.hostId ?? undefined,
input: parseJson(run.undoInput),
confirmed: req.body?.confirmed === true,
undoOf: run.id,
userId: authReq.userId,
origin: originOf(authReq),
apiKeyScopes: authReq.apiKeyScopes,
}),
);
} catch (error) {
sendError(res, error, "operations_undo");
}
});
/**
* @openapi
* /operations/approvals:
* get:
* summary: List waiting approvals
* description: Requests the caller could decide, with the exact input that would run.
* tags:
* - Operations
* responses:
* 200:
* description: Waiting approvals.
*/
router.get("/approvals", authenticateJWT, async (req, res) => {
const { userId } = req as OperationsRequest;
try {
const repository = createCurrentOperationRepository();
const approvals = [];
for (const approval of await repository.listPendingApprovals()) {
const run = await repository.findRun(approval.runId);
if (run && (await canSeeRun(run, userId))) {
approvals.push({ ...approval, run: publicRun(run) });
}
}
res.json({ approvals });
} catch (error) {
sendError(res, error, "operations_approvals");
}
});
/**
* @openapi
* /operations/approvals/{id}:
* post:
* summary: Approve or reject a request
* description: One shot. What runs is the sealed request, never anything sent here.
* tags:
* - Operations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [approve]
* properties:
* approve:
* type: boolean
* comment:
* type: string
* responses:
* 200:
* description: The run's outcome.
* 403:
* description: The caller may not decide this.
* 409:
* description: Already decided, expired, or the host changed.
*/
router.post("/approvals/:id", authenticateJWT, async (req, res) => {
const authReq = req as OperationsRequest;
if (typeof req.body?.approve !== "boolean") {
res.status(400).json({ error: "approve must be true or false" });
return;
}
// A key could be handed to a script, so a person decides from a session.
if (authReq.apiKeyScopes) {
res.status(403).json({ error: "This API key may not approve" });
return;
}
try {
res.json(
await decideOperationApproval({
approvalId: String(req.params.id),
approverId: authReq.userId,
approve: req.body.approve,
comment:
typeof req.body.comment === "string" ? req.body.comment : undefined,
impersonated: !!authReq.actingAdminUserId,
}),
);
} catch (error) {
sendError(res, error, "operations_approve");
}
});
/**
* @openapi
* /operations/events:
* get:
* summary: List declared events
* tags:
* - Operations
* responses:
* 200:
* description: Declared events from running plugins, plus core's run events.
*/
router.get("/events", authenticateJWT, async (req, res) => {
const { userId } = req as OperationsRequest;
try {
const catalog = await buildCatalog({
userId,
lang: typeof req.query.lang === "string" ? req.query.lang : undefined,
});
res.json({ events: catalog.events });
} catch (error) {
sendError(res, error, "operations_events");
}
});
/**
* @openapi
* /operations/timeline:
* get:
* summary: Recent events
* description: Events the caller may see, newest first. Events tied to a host need access to it.
* tags:
* - Operations
* parameters:
* - in: query
* name: hostId
* schema:
* type: integer
* - in: query
* name: before
* schema:
* type: string
* - in: query
* name: limit
* schema:
* type: integer
* responses:
* 200:
* description: Events.
*/
router.get("/timeline", authenticateJWT, async (req, res) => {
const { userId } = req as OperationsRequest;
try {
const rows = await createCurrentOperationRepository().listEvents(
{
hostId: limitFrom(req.query.hostId),
before:
typeof req.query.before === "string" ? req.query.before : undefined,
},
limitFrom(req.query.limit),
);
const viewAll = await permissions.hasPermission(userId, VIEW_ALL_RUNS);
const access = new Map<number, boolean>();
const events = [];
for (const row of rows) {
if (row.hostId === null) {
if (viewAll) events.push(row);
continue;
}
let allowed = access.get(row.hostId);
if (allowed === undefined) {
allowed = (
await permissions.canAccessHost(userId, row.hostId, "connect")
).hasAccess;
access.set(row.hostId, allowed);
}
if (allowed) events.push(row);
}
res.json({
events: events.map((event) => ({
...event,
payload: parseJson(event.payload),
})),
});
} catch (error) {
sendError(res, error, "operations_timeline");
}
});
/**
* @openapi
* /operations/signals:
* get:
* summary: Read status tiles
* description: Answers from memory right away and refreshes stale hosts behind it, so a page should poll. Reads hosts the caller can reach.
* tags:
* - Operations
* parameters:
* - in: query
* name: hostIds
* description: Comma separated. Leave out for every host.
* schema:
* type: string
* - in: query
* name: ids
* description: Comma separated signal ids. Leave out for all.
* schema:
* type: string
* responses:
* 200:
* description: One entry per signal with per host results.
*/
router.get("/signals", authenticateJWT, async (req, res) => {
const authReq = req as OperationsRequest;
try {
const catalog = await buildCatalog({ userId: authReq.userId });
const wanted =
typeof req.query.ids === "string" && req.query.ids
? new Set(req.query.ids.split(","))
: null;
const hostIds = hostIdsFrom(req.query.hostIds);
const signals = [];
for (const signal of catalog.signals) {
if (wanted && !wanted.has(signal.id)) continue;
if (signal.scope === "server") {
const outcome = await runOperation({
actionId: signal.read,
internal: true,
userId: authReq.userId,
origin: originOf(authReq),
}).catch(() => null);
signals.push({
id: signal.id,
hosts: [],
result: outcome?.data as SignalResult | undefined,
});
continue;
}
signals.push({
id: signal.id,
hosts: await readAcrossHosts<SignalResult>({
userId: authReq.userId,
actionId: signal.read,
hostIds,
freshMs: signal.refreshSec ? signal.refreshSec * 1000 : undefined,
origin: originOf(authReq),
}),
});
}
res.json({ signals });
} catch (error) {
sendError(res, error, "operations_signals");
}
});
/**
* @openapi
* /operations/resources/{id}:
* get:
* summary: Read one kind of thing across hosts
* description: Rows from cache plus each host's state (ok, loading, offline, error, no-access). Answers right away and refreshes stale hosts behind it, so a page should poll until no host is loading.
* tags:
* - Operations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* - in: query
* name: hostIds
* description: Comma separated. Leave out for every host.
* schema:
* type: string
* - in: query
* name: refresh
* schema:
* type: boolean
* responses:
* 200:
* description: Rows and per host state.
* 404:
* description: No such resource, or the caller may not read it.
*/
router.get("/resources/:id", authenticateJWT, async (req, res) => {
const authReq = req as OperationsRequest;
try {
const catalog = await buildCatalog({ userId: authReq.userId });
const resource = catalog.resources.find(
(entry) => entry.id === req.params.id,
);
if (!resource) {
res.status(404).json({ error: "Resource not found" });
return;
}
const reads = await readAcrossHosts<ResourceListResult>({
userId: authReq.userId,
actionId: resource.list,
hostIds: hostIdsFrom(req.query.hostIds),
refresh: req.query.refresh === "true",
origin: originOf(authReq),
});
res.json(flattenResourceRows(reads, resource.key));
} catch (error) {
sendError(res, error, "operations_resources");
}
});
/**
* @openapi
* /operations/{id}/run:
* post:
* summary: Run an action
* description: Goes through permission, host access, input and approval checks. A change returns as soon as it starts unless wait is true, then poll the run. A read always answers inline.
* tags:
* - Operations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* resourceKey:
* type: string
* input:
* type: object
* dryRun:
* type: boolean
* confirmed:
* type: boolean
* idempotencyKey:
* type: string
* wait:
* type: boolean
* responses:
* 200:
* description: The outcome, or a run waiting for approval.
* 400:
* description: Invalid input.
* 403:
* description: Not allowed.
* 409:
* description: Needs a confirm, or safe mode is on.
*/
router.post("/:id/run", authenticateJWT, async (req, res) => {
const authReq = req as OperationsRequest;
const body = (req.body ?? {}) as Record<string, unknown>;
try {
res.json(
await runOperation({
actionId: String(req.params.id),
hostId: typeof body.hostId === "number" ? body.hostId : undefined,
resourceKey:
typeof body.resourceKey === "string" ? body.resourceKey : undefined,
input: body.input,
dryRun: body.dryRun === true,
confirmed: body.confirmed === true,
idempotencyKey:
typeof body.idempotencyKey === "string"
? body.idempotencyKey.slice(0, 128)
: undefined,
userId: authReq.userId,
origin: originOf(authReq),
apiKeyScopes: authReq.apiKeyScopes,
}),
);
} catch (error) {
sendError(res, error, "operations_run_action");
}
});
export default router;
@@ -1,5 +1,6 @@
import type { RequestHandler, Router } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { API_KEY_SCOPES } from "../../operations/key-scopes.js";
import crypto from "crypto";
import bcrypt from "bcryptjs";
import { nanoid } from "nanoid";
@@ -42,6 +43,12 @@ export function registerUserApiKeyRoutes(
* type: string
* format: date-time
* description: Optional expiration date. Null means the key never expires.
* scopes:
* type: array
* items:
* type: string
* enum: [operations:read, operations:run]
* description: Limits the key to actions only. Leave out for full access.
* responses:
* 201:
* description: API key created. Contains the full token (shown only once).
@@ -56,7 +63,7 @@ export function registerUserApiKeyRoutes(
*/
router.post("/api-keys", requireAdmin, async (req, res) => {
try {
const { name, userId: targetUserId, expiresAt } = req.body;
const { name, userId: targetUserId, expiresAt, scopes } = req.body;
const apiKeyRepository = createCurrentApiKeyRepository();
const userRepository = createCurrentUserRepository();
@@ -86,6 +93,21 @@ export function registerUserApiKeyRoutes(
expiresAtValue = parsed.toISOString();
}
let scopesValue: string[] | null = null;
if (scopes !== undefined && scopes !== null) {
if (
!Array.isArray(scopes) ||
scopes.some(
(scope) =>
!(API_KEY_SCOPES as readonly string[]).includes(String(scope)),
)
) {
return res.status(400).json({ error: "Unknown API key scope" });
}
scopesValue =
scopes.length > 0 ? [...new Set(scopes as string[])] : null;
}
const rawToken = "tmx_" + crypto.randomBytes(32).toString("hex");
const tokenPrefix = rawToken.substring(0, 12);
const tokenHash = await bcrypt.hash(rawToken, 10);
@@ -102,6 +124,7 @@ export function registerUserApiKeyRoutes(
expiresAt: expiresAtValue,
lastUsedAt: null,
isActive: true,
scopes: scopesValue ? JSON.stringify(scopesValue) : null,
});
const actorId = (req as AuthenticatedRequest).userId;
@@ -119,6 +142,7 @@ export function registerUserApiKeyRoutes(
details: JSON.stringify({
targetUserId,
targetUsername: targetUser.username,
scopes: scopesValue,
}),
ipAddress,
userAgent,
@@ -133,6 +157,7 @@ export function registerUserApiKeyRoutes(
tokenPrefix,
createdAt: now,
expiresAt: expiresAtValue,
scopes: scopesValue,
token: rawToken,
});
} catch (err) {
+130
View File
@@ -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);
}
+251
View File
@@ -0,0 +1,251 @@
/**
* What a user can do right now, in their language.
*
* Built from the manifests of running plugins, never from GET /plugins, which
* hands every signed-in user the raw manifest. A plugin's locale file loads
* here on the server, because its frontend may not be loaded on the client:
* the CLI has no frontend, and a linked desktop may not have the plugin at all.
*/
import fs from "node:fs";
import path from "node:path";
import {
onSurface,
toJsonSchema,
type OperationAction,
type OperationEvent,
type OperationField,
type OperationResource,
type OperationSignal,
type OperationSurfaces,
} from "@termix-ssh/plugin-sdk/operations";
import { getAction } from "./registry.js";
import { qualifyPermission, type PluginManifest } from "../plugins/manifest.js";
import { PermissionManager } from "../utils/permission-manager.js";
interface CatalogPlugin {
id: string;
dir: string;
manifest: PluginManifest;
state: string;
official: boolean;
}
export interface CatalogField extends OperationField {
label: string;
description?: string;
placeholder?: string;
optionLabels?: Record<string, string>;
}
export interface CatalogAction extends Omit<
OperationAction,
"inputs" | "outputs"
> {
pluginId: string;
pluginName: string;
official: boolean;
title: string;
description?: string;
inputs: CatalogField[];
outputs: CatalogField[];
inputSchema: Record<string, unknown>;
available: boolean;
}
export interface CatalogEvent extends OperationEvent {
pluginId: string;
title: string;
}
export interface CatalogResource extends OperationResource {
pluginId: string;
title: string;
columns: (NonNullable<OperationResource["columns"]>[number] & {
title: string;
})[];
}
export interface CatalogSignal extends OperationSignal {
pluginId: string;
title: string;
}
export interface OperationCatalog {
actions: CatalogAction[];
events: CatalogEvent[];
resources: CatalogResource[];
signals: CatalogSignal[];
}
type Strings = Record<string, string>;
const localeCache = new Map<string, Strings>();
function flatten(value: unknown, prefix: string, out: Strings): void {
if (typeof value === "string") {
out[prefix] = value;
return;
}
if (typeof value !== "object" || value === null) return;
for (const [key, child] of Object.entries(value)) {
flatten(child, prefix ? `${prefix}.${key}` : key, out);
}
}
function readLocale(file: string): Strings {
const out: Strings = {};
try {
flatten(JSON.parse(fs.readFileSync(file, "utf8")), "", out);
} catch {
// A missing or broken file falls back to the key.
}
return out;
}
/** Locale names reach a path, so only a plain language tag is used. */
function safeLanguage(lang: string | undefined): string {
return lang && /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})?$/.test(lang) ? lang : "en";
}
function stringsFor(plugin: CatalogPlugin, lang: string): Strings {
const cacheKey = `${plugin.id}@${plugin.manifest.version}:${lang}`;
const cached = localeCache.get(cacheKey);
if (cached) return cached;
const dir = path.resolve(plugin.dir, plugin.manifest.locales ?? "locales");
const strings = readLocale(path.join(dir, "en.json"));
if (lang !== "en") {
Object.assign(
strings,
readLocale(path.join(dir, "translated", `${lang}.json`)),
readLocale(path.join(dir, `${lang}.json`)),
);
}
localeCache.set(cacheKey, strings);
return strings;
}
function localizedField(field: OperationField, strings: Strings): CatalogField {
const text = (key?: string) => (key ? (strings[key] ?? key) : undefined);
const optionLabels: Record<string, string> = {};
for (const option of field.options ?? []) {
optionLabels[option.value] = text(option.labelKey) ?? option.value;
}
return {
...field,
label: text(field.labelKey) ?? field.key,
description: text(field.descriptionKey),
placeholder: text(field.placeholderKey),
optionLabels: field.options ? optionLabels : undefined,
};
}
async function runningPlugins(): Promise<CatalogPlugin[]> {
const { getPluginRuntime } = await import("../plugins/index.js");
return getPluginRuntime()
.loader.list()
.filter((plugin) => plugin.state === "active")
.map((plugin) => ({
id: plugin.id,
dir: plugin.dir,
manifest: plugin.manifest,
state: plugin.state,
official: plugin.source === "bundled" || plugin.signedTier === "official",
}));
}
export async function buildCatalog(options: {
userId: string;
lang?: string;
surface?: keyof OperationSurfaces;
}): Promise<OperationCatalog> {
const lang = safeLanguage(options.lang);
const permissions = PermissionManager.getInstance();
const catalog: OperationCatalog = {
actions: [],
events: [],
resources: [],
signals: [],
};
for (const plugin of await runningPlugins()) {
const operations = plugin.manifest.contributes?.operations;
if (!operations) continue;
const strings = stringsFor(plugin, lang);
const text = (key: string) => strings[key] ?? key;
for (const action of operations.actions ?? []) {
if (options.surface && !onSurface(action, options.surface)) continue;
// Community plugins stay away from the assistant and MCP until an admin allows them.
if (
!plugin.official &&
(options.surface === "assistant" || options.surface === "mcp")
) {
continue;
}
const permission = qualifyPermission(plugin.id, action.permission);
if (!(await permissions.hasPermission(options.userId, permission))) {
continue;
}
const inputs = (action.inputs ?? []).map((field) =>
localizedField(field, strings),
);
catalog.actions.push({
...action,
pluginId: plugin.id,
pluginName: plugin.manifest.name,
official: plugin.official,
title: text(action.titleKey),
description: action.descriptionKey
? text(action.descriptionKey)
: undefined,
inputs,
outputs: (action.outputs ?? [])
.filter((field) => !field.sensitive)
.map((field) => localizedField(field, strings)),
inputSchema: toJsonSchema(
action.inputs,
(field) =>
inputs.find((input) => input.key === field.key)?.description,
),
available: getAction(action.id) !== undefined,
});
}
const visible = new Set(catalog.actions.map((action) => action.id));
for (const event of operations.events ?? []) {
catalog.events.push({
...event,
pluginId: plugin.id,
title: text(event.titleKey),
});
}
for (const resource of operations.resources ?? []) {
if (!visible.has(resource.list)) continue;
catalog.resources.push({
...resource,
pluginId: plugin.id,
title: text(resource.titleKey),
columns: (resource.columns ?? []).map((column) => ({
...column,
title: text(column.titleKey),
})),
});
}
for (const signal of operations.signals ?? []) {
if (!visible.has(signal.read)) continue;
catalog.signals.push({
...signal,
pluginId: plugin.id,
title: text(signal.titleKey),
});
}
}
return catalog;
}
/** Test seam. */
export function clearCatalogCache(): void {
localeCache.clear();
}
+8
View File
@@ -0,0 +1,8 @@
/** How many events or actions may lead to one another before the chain stops. */
export const MAX_EVENT_DEPTH = 3;
/** How long finished runs and events stay in history. */
export const RETENTION_DAYS = 90;
/** How long a cached table row or tile counts as fresh. */
export const RESOURCE_FRESH_MS = 60_000;
+155
View File
@@ -0,0 +1,155 @@
/**
* Declared events, separate from the plugin.* bus any plugin can read.
*
* Each event is kept in a capped timeline. A subscriber bound to a user only
* hears about hosts that user can reach, checked at delivery. Events carry a
* depth, so a rule that fires an action that fires the same rule stops.
*/
import { randomUUID } from "node:crypto";
import { MAX_EVENT_DEPTH } from "./constants.js";
import { getActorTrail, runAsActor } from "../plugins/actor.js";
import { PermissionManager } from "../utils/permission-manager.js";
import { pluginLogger } from "../utils/logger.js";
import { createCurrentOperationRepository } from "../database/repositories/factory.js";
export interface OperationEventInput {
eventId: string;
pluginId: string;
hostId?: number;
severity?: string;
payload?: unknown;
runId?: string;
depth?: number;
}
export interface DeliveredEvent {
id: string;
eventId: string;
pluginId: string;
hostId?: number;
severity?: string;
payload?: unknown;
runId?: string;
depth: number;
at: string;
}
export interface OperationEventSubscription {
/** Exact id, `<pluginId>.*` or `*`. */
pattern: string;
handler: (event: DeliveredEvent) => void | Promise<void>;
/** Only events for hosts this user can reach, and runs the handler as them. */
userId?: string;
/** Skips repeats of one event and host inside this window. */
cooldownMs?: number;
}
interface Subscriber extends OperationEventSubscription {
pluginId: string;
lastFired: Map<string, number>;
}
const subscribers = new Set<Subscriber>();
function matches(pattern: string, eventId: string): boolean {
if (pattern === "*" || pattern === eventId) return true;
return pattern.endsWith(".*") && eventId.startsWith(pattern.slice(0, -1));
}
export function subscribeOperationEvents(
pluginId: string,
subscription: OperationEventSubscription,
): () => void {
const entry = { ...subscription, pluginId, lastFired: new Map() };
subscribers.add(entry);
return () => {
subscribers.delete(entry);
};
}
async function deliver(subscriber: Subscriber, event: DeliveredEvent) {
const key = `${event.eventId}|${event.hostId ?? ""}`;
if (subscriber.cooldownMs) {
const last = subscriber.lastFired.get(key) ?? 0;
if (Date.now() - last < subscriber.cooldownMs) return;
subscriber.lastFired.set(key, Date.now());
}
const { userId } = subscriber;
if (userId && event.hostId !== undefined) {
const access = await PermissionManager.getInstance().canAccessHost(
userId,
event.hostId,
"connect",
);
if (!access.hasAccess) return;
}
const fire = () => Promise.resolve(subscriber.handler(event));
await (userId
? runAsActor(userId, "service", fire, undefined, { depth: event.depth })
: fire());
}
/** Never throws. An event must not be able to fail what caused it. */
export async function emitOperationEvent(
input: OperationEventInput,
): Promise<void> {
try {
const trail = getActorTrail();
const depth = input.depth ?? trail.depth ?? 0;
const event: DeliveredEvent = {
id: randomUUID(),
eventId: input.eventId,
pluginId: input.pluginId,
hostId: input.hostId,
severity: input.severity,
payload: input.payload,
runId: input.runId ?? trail.runId,
depth,
at: new Date().toISOString(),
};
await createCurrentOperationRepository().insertEvent({
id: event.id,
eventId: event.eventId,
pluginId: event.pluginId,
hostId: event.hostId ?? null,
severity: event.severity ?? null,
payload:
event.payload === undefined ? null : JSON.stringify(event.payload),
runId: event.runId ?? null,
createdAt: event.at,
});
if (depth >= MAX_EVENT_DEPTH) {
pluginLogger.warn("Event chain stopped at the depth limit", {
operation: "operation_event_depth",
eventId: event.eventId,
});
return;
}
for (const subscriber of subscribers) {
if (!matches(subscriber.pattern, event.eventId)) continue;
void deliver(subscriber, event).catch((error) => {
pluginLogger.warn("Event subscriber failed", {
operation: "operation_event_delivery",
pluginId: subscriber.pluginId,
error: error instanceof Error ? error.message : String(error),
});
});
}
} catch (error) {
pluginLogger.warn("Could not record an event", {
operation: "operation_event",
error: error instanceof Error ? error.message : String(error),
});
}
}
/** Test seam. */
export function clearOperationEvents(): void {
subscribers.clear();
}
+891
View File
@@ -0,0 +1,891 @@
/**
* The one path every action takes, from any surface.
*
* In order: plugin up, grant still held, input valid, role permission, host
* access, key scope, destructive confirm, approval, limits, run row, audit.
* Handlers run in-process, so this is an access check for callers, not a
* sandbox around plugins.
*
* Handler failures stay here. They never reach the plugin error budget, or five
* failed hosts in a rollout would switch the plugin off.
*/
import { randomUUID } from "node:crypto";
import {
capText,
matchesWhen,
parseOperationInput,
redactFields,
riskRank,
type OperationAction,
type OperationRisk,
} from "@termix-ssh/plugin-sdk/operations";
import { MAX_EVENT_DEPTH } from "./constants.js";
import { getAction, type OperationRegistration } from "./registry.js";
import {
canSelfApprove,
evaluateApproval,
type ApprovalDecision,
} from "./approvals.js";
import { withLimits } from "./limits.js";
import { emitOperationEvent } from "./events.js";
import { dropResourceCache } from "./resources.js";
import { loadOperationHost, type OperationHost } from "./hosts.js";
import { runAsActor, type ActorOrigin } from "../plugins/actor.js";
import { hasCapability } from "../plugins/permissions.js";
import { qualifyPermission } from "../plugins/manifest.js";
import { safeModeOn } from "../plugins/safe-mode.js";
import { PermissionManager } from "../utils/permission-manager.js";
import { logAudit } from "../utils/audit-logger.js";
import {
decryptSystemSecret,
encryptSystemSecret,
isSystemEncrypted,
} from "../utils/system-secret-crypto.js";
import { pluginLogger } from "../utils/logger.js";
import {
createCurrentOperationRepository,
createCurrentUserRepository,
} from "../database/repositories/factory.js";
import type {
OperationApprovalRecord,
OperationRunRecord,
} from "../database/repositories/operation-repository.js";
const MAX_OUTPUT = 32_000;
const DEFAULT_TIMEOUT_MS = 300_000;
const READ_TIMEOUT_MS = 60_000;
const DEFAULT_APPROVAL_MINUTES = 24 * 60;
const DRAIN_WAIT_MS = 10_000;
export type OperationErrorCode =
| "NOT_FOUND"
| "UNAVAILABLE"
| "INVALID_INPUT"
| "FORBIDDEN"
| "SCOPE"
| "NEEDS_CONFIRM"
| "DENIED"
| "TOO_DEEP"
| "STALE"
| "CONFLICT"
| "SAFE_MODE";
const STATUS: Record<OperationErrorCode, number> = {
NOT_FOUND: 404,
UNAVAILABLE: 503,
INVALID_INPUT: 400,
FORBIDDEN: 403,
SCOPE: 403,
NEEDS_CONFIRM: 409,
DENIED: 403,
TOO_DEEP: 409,
STALE: 409,
CONFLICT: 409,
SAFE_MODE: 409,
};
/** Refused before anything ran. Carries a status so routes can pass it on. */
export class OperationError extends Error {
readonly status: number;
constructor(
readonly code: OperationErrorCode,
message: string,
readonly details?: unknown,
) {
super(message);
this.name = "OperationError";
this.status = STATUS[code];
}
}
/** A handler throws this when a host needs a person to sign in first. */
export class OperationNeedsInteraction extends Error {
readonly code = "NEEDS_INTERACTION";
}
export interface OperationRequest {
actionId: string;
hostId?: number;
resourceKey?: string;
input?: unknown;
dryRun?: boolean;
/** A person agreed to a destructive action. */
confirmed?: boolean;
idempotencyKey?: string;
batchId?: string;
undoOf?: string;
/** Background reads for tables and tiles. Writes no run row. */
internal?: boolean;
userId: string;
origin: ActorOrigin;
apiKeyScopes?: string[] | null;
depth?: number;
}
export interface OperationOutcome {
runId: string | null;
status: string;
summary?: string;
changed?: boolean;
data?: unknown;
error?: string;
errorCode?: string;
approvalId?: string;
}
interface Prepared {
registration: OperationRegistration;
action: OperationAction;
pluginVersion: string;
input: Record<string, unknown>;
host: OperationHost | null;
effectiveRisk: OperationRisk;
}
/** The sealed copy an approval is bound to. */
interface ApprovalSnapshot {
runId: string;
actionId: string;
actionVersion: number;
pluginVersion: string;
hostId: number | null;
hostUpdatedAt: string | null;
resourceKey: string | null;
input: Record<string, unknown>;
}
const active = new Map<
string,
{ pluginId: string; controller: AbortController; done: Promise<unknown> }
>();
const draining = new Set<string>();
function now(): string {
return new Date().toISOString();
}
function errorText(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
async function loadedPlugin(pluginId: string) {
const { getPluginRuntime } = await import("../plugins/index.js");
return getPluginRuntime().loader.get(pluginId);
}
async function usernameOf(userId: string): Promise<string> {
try {
const user = await createCurrentUserRepository().findById(userId);
return user?.username ?? userId;
} catch {
return userId;
}
}
/** Every check that does not depend on who approved it. */
async function prepare(request: OperationRequest): Promise<Prepared> {
if (safeModeOn()) {
throw new OperationError("SAFE_MODE", "Actions are off in safe mode");
}
if ((request.depth ?? 0) > MAX_EVENT_DEPTH) {
throw new OperationError("TOO_DEEP", "Too many actions led to this one");
}
const registration = getAction(request.actionId);
if (!registration) {
throw new OperationError(
"NOT_FOUND",
`No action "${request.actionId}" is available`,
);
}
const { action, pluginId } = registration;
const plugin = await loadedPlugin(pluginId);
if (plugin?.state !== "active" || draining.has(pluginId)) {
throw new OperationError("UNAVAILABLE", `${pluginId} is not running`);
}
// Rechecked at use, so revoking the grant stops it on the next call.
if (
!(await hasCapability(
pluginId,
"operations:provide",
plugin.manifest.capabilities,
))
) {
throw new OperationError("UNAVAILABLE", `${pluginId} may not run actions`);
}
const parsed = parseOperationInput(action.inputs, request.input);
if (parsed.errors.length > 0) {
throw new OperationError(
"INVALID_INPUT",
parsed.errors.join("; "),
parsed.errors,
);
}
const permissions = PermissionManager.getInstance();
const permission = qualifyPermission(pluginId, action.permission);
if (!(await permissions.hasPermission(request.userId, permission))) {
throw new OperationError(
"FORBIDDEN",
`This needs the ${permission} permission`,
);
}
let host: OperationHost | null = null;
if (action.scope !== "server") {
if (request.hostId === undefined) {
throw new OperationError("INVALID_INPUT", "This action needs a host");
}
if (action.scope === "resource" && !request.resourceKey) {
throw new OperationError("INVALID_INPUT", "This action needs a target");
}
const access = await permissions.canAccessHost(
request.userId,
request.hostId,
action.hostAccess ?? "edit",
);
if (!access.hasAccess) {
throw new OperationError(
"FORBIDDEN",
`You need ${action.hostAccess ?? "edit"} access to this host`,
);
}
host = await loadOperationHost(request.hostId, plugin.manifest);
if (!host) throw new OperationError("NOT_FOUND", "Host not found");
if (!matchesWhen(action.when, host)) {
throw new OperationError(
"UNAVAILABLE",
"This action does not apply to this host",
);
}
}
// A claim by the plugin, so only signed official plugins are taken at
// their word when rules decide what needs a person.
const official =
plugin.source === "bundled" || plugin.signedTier === "official";
const effectiveRisk: OperationRisk =
!official && action.risk === "read" ? "change" : action.risk;
return {
registration,
action,
pluginVersion: plugin.manifest.version,
input: parsed.values,
host,
effectiveRisk,
};
}
function checkKeyScope(request: OperationRequest, action: OperationAction) {
const scopes = request.apiKeyScopes;
if (scopes === undefined || scopes === null) return;
if (scopes.includes("operations:run")) return;
if (scopes.includes("operations:read") && action.risk === "read") return;
throw new OperationError("SCOPE", "This API key may not run this action");
}
function originText(origin: ActorOrigin): string {
return JSON.stringify(origin);
}
function auditRun(
request: OperationRequest,
username: string,
action: OperationAction,
host: OperationHost | null,
success: boolean,
errorMessage?: string,
verb = "operation_run",
): void {
void logAudit({
userId: request.userId,
username,
action: verb,
resourceType: "operation",
resourceId: action.id,
resourceName: host?.name ?? undefined,
details: originText(request.origin),
success,
errorMessage,
}).catch(() => {});
}
async function execute(
prepared: Prepared,
request: OperationRequest,
runId: string | null,
username: string,
): Promise<OperationOutcome> {
const { action, registration, input, host } = prepared;
const repository = createCurrentOperationRepository();
const controller = new AbortController();
const dryRun = request.dryRun === true;
const timeoutMs =
action.timeoutMs ??
(action.risk === "read" ? READ_TIMEOUT_MS : DEFAULT_TIMEOUT_MS);
const lines: string[] = [];
const context = {
hostId: request.hostId,
resourceKey: request.resourceKey,
runId,
dryRun,
signal: controller.signal,
log: (line: string) => {
if (lines.length < 200) lines.push(line);
},
};
const work = async () => {
if (dryRun && action.dryRun !== "native") {
const summary = registration.handler.describe
? await registration.handler.describe(input, context)
: `Would run ${action.id}`;
return { summary, changed: false };
}
return registration.handler.run(input, context);
};
const timer = setTimeout(() => controller.abort(), timeoutMs);
timer.unref?.();
const aborted = new Promise<never>((_, reject) => {
controller.signal.addEventListener("abort", () =>
reject(new Error("The action was stopped or timed out")),
);
});
aborted.catch(() => {});
// Settles when the handler really stops, which can be after the run gives
// up on it. A plugin going down waits on this, not on the run.
let handlerStopped: () => void = () => {};
const stopped = new Promise<void>((resolve) => {
handlerStopped = resolve;
});
const startWork = () => {
// Cancelled while waiting for a slot, so it never starts.
if (controller.signal.aborted) {
handlerStopped();
return aborted;
}
const handling = Promise.resolve().then(work);
void handling.finally(handlerStopped).catch(() => {});
return Promise.race([handling, aborted]);
};
const running = withLimits(
{ hostId: request.hostId, exclusive: action.exclusive },
() =>
runAsActor(request.userId, "service", startWork, undefined, {
origin: request.origin,
apiKeyScopes: request.apiKeyScopes,
runId: runId ?? undefined,
depth: (request.depth ?? 0) + 1,
}),
);
if (runId) {
active.set(runId, {
pluginId: registration.pluginId,
controller,
done: stopped,
});
}
let outcome: OperationOutcome;
try {
const result = await running;
const data = redactFields(action.outputs, result.data);
outcome = {
runId,
status: "success",
summary: result.summary,
changed: result.changed,
data,
};
if (runId) {
await repository.updateRunFrom(runId, "running", {
status: "success",
summary: capText(result.summary ?? "", 2000),
changed: result.changed ?? null,
output:
data === undefined ? null : capText(JSON.stringify(data), MAX_OUTPUT),
undoInput: result.undoInput ? JSON.stringify(result.undoInput) : null,
sealedInput: null,
finishedAt: now(),
});
}
} catch (error) {
const needsPerson =
(error as { code?: unknown } | null)?.code === "NEEDS_INTERACTION";
const stopping = draining.has(registration.pluginId);
const status = needsPerson
? "needs_interaction"
: stopping
? "interrupted"
: "failed";
const message = stopping
? "The plugin stopped during this run. Check the host."
: capText(errorText(error), 2000);
outcome = { runId, status, error: message };
if (needsPerson) outcome.errorCode = "NEEDS_INTERACTION";
if (runId) {
await repository.updateRunFrom(runId, "running", {
status,
error: message,
errorCode: outcome.errorCode ?? null,
sealedInput: null,
finishedAt: now(),
});
}
} finally {
clearTimeout(timer);
if (runId && !draining.has(registration.pluginId)) active.delete(runId);
}
if (action.risk !== "read" && !dryRun) {
if (request.hostId !== undefined && outcome.status === "success") {
dropResourceCache(request.hostId);
}
auditRun(
request,
username,
action,
host,
outcome.status === "success",
outcome.error,
);
void emitOperationEvent({
eventId:
outcome.status === "success"
? "operation.succeeded"
: "operation.failed",
pluginId: registration.pluginId,
hostId: request.hostId,
severity: outcome.status === "success" ? "info" : "warning",
payload: {
actionId: action.id,
summary: outcome.summary,
error: outcome.error,
origin: request.origin,
},
runId: runId ?? undefined,
depth: (request.depth ?? 0) + 1,
});
}
return outcome;
}
function outcomeOf(
run: OperationRunRecord,
approvalId?: string,
): OperationOutcome {
return {
runId: run.id,
status: run.status,
summary: run.summary ?? undefined,
changed: run.changed ?? undefined,
error: run.error ?? undefined,
errorCode: run.errorCode ?? undefined,
approvalId,
};
}
async function requestApproval(
prepared: Prepared,
request: OperationRequest,
decision: ApprovalDecision,
run: { id: string; username: string; createdAt: string },
): Promise<OperationOutcome> {
const { action, host, input } = prepared;
const repository = createCurrentOperationRepository();
const snapshot: ApprovalSnapshot = {
runId: run.id,
actionId: action.id,
actionVersion: action.version ?? 1,
pluginVersion: prepared.pluginVersion,
hostId: request.hostId ?? null,
hostUpdatedAt: host?.updatedAt ?? null,
resourceKey: request.resourceKey ?? null,
input,
};
await repository.updateRunFrom(run.id, "created", {
status: "pending_approval",
summary: decision.reason ?? null,
sealedInput: await encryptSystemSecret(JSON.stringify(snapshot)),
});
const minutes = decision.expiresInMinutes ?? DEFAULT_APPROVAL_MINUTES;
const approvalId = randomUUID();
await repository.insertApproval({
id: approvalId,
runId: run.id,
requestedBy: request.userId,
reason: decision.reason ?? null,
allowSelf: decision.allowSelf === true || canSelfApprove(request.origin),
status: "pending",
createdAt: run.createdAt,
expiresAt: new Date(Date.now() + minutes * 60_000).toISOString(),
});
void emitOperationEvent({
eventId: "operation.approval-requested",
pluginId: prepared.registration.pluginId,
hostId: request.hostId,
severity: "warning",
payload: { actionId: action.id, approvalId },
runId: run.id,
});
return { runId: run.id, status: "pending_approval", approvalId };
}
/** Runs an action, or parks it for approval. Throws OperationError if refused. */
export async function runOperation(
request: OperationRequest,
): Promise<OperationOutcome> {
const registration = getAction(request.actionId);
if (registration) checkKeyScope(request, registration.action);
const repository = createCurrentOperationRepository();
if (request.idempotencyKey) {
const earlier = await repository.findByIdempotencyKey(
request.userId,
request.idempotencyKey,
);
if (earlier) return outcomeOf(earlier);
}
const prepared = await prepare(request);
const { action } = prepared;
const dryRun = request.dryRun === true;
const isRead = action.risk === "read";
if (action.risk === "destructive" && !dryRun && !request.confirmed) {
throw new OperationError("NEEDS_CONFIRM", "This action needs a confirm");
}
let decision: ApprovalDecision = { decision: "allow" };
if (!dryRun && !request.internal) {
decision = await evaluateApproval({
actionId: action.id,
pluginId: prepared.registration.pluginId,
risk: action.risk,
effectiveRisk: prepared.effectiveRisk,
origin: request.origin,
userId: request.userId,
hostId: request.hostId,
hostTags: prepared.host?.tags,
batchId: request.batchId,
});
}
const username = await usernameOf(request.userId);
if (decision.decision === "deny") {
auditRun(
request,
username,
action,
prepared.host,
false,
decision.reason,
"operation_denied",
);
throw new OperationError(
"DENIED",
decision.reason ?? "A rule refused this action",
);
}
// Internal reads poll constantly and would flood history and the save loop.
if (request.internal && isRead) {
return execute(prepared, request, null, username);
}
const run = {
id: randomUUID(),
actionId: action.id,
actionVersion: action.version ?? 1,
pluginId: prepared.registration.pluginId,
pluginVersion: prepared.pluginVersion,
userId: request.userId,
username,
origin: originText(request.origin),
hostId: request.hostId ?? null,
hostName: prepared.host?.name ?? null,
batchId: request.batchId ?? null,
dryRun,
risk: action.risk,
status: decision.decision === "approve" ? "created" : "running",
input: JSON.stringify(redactFields(action.inputs, prepared.input)),
undoOf: request.undoOf ?? null,
idempotencyKey: request.idempotencyKey ?? null,
createdAt: now(),
startedAt: decision.decision === "approve" ? null : now(),
};
await repository.insertRun(run);
if (decision.decision === "approve") {
return requestApproval(prepared, request, decision, run);
}
return execute(prepared, request, run.id, username);
}
function originOf(run: OperationRunRecord): ActorOrigin {
try {
return JSON.parse(run.origin) as ActorOrigin;
} catch {
return { kind: "api" };
}
}
async function failStale(run: OperationRunRecord, why: string): Promise<void> {
await createCurrentOperationRepository().updateRunFrom(
run.id,
"pending_approval",
{
status: "rejected",
error: why,
sealedInput: null,
finishedAt: now(),
},
);
}
/**
* Records a person's decision. One shot: the row moves from pending once, so a
* second click or a replay does nothing. What runs is the sealed copy, never
* what the caller sends now.
*/
export async function decideOperationApproval(options: {
approvalId: string;
approverId: string;
approve: boolean;
comment?: string;
impersonated?: boolean;
}): Promise<OperationOutcome> {
const { approvalId, approverId, approve, comment } = options;
const repository = createCurrentOperationRepository();
const approval = await repository.findApproval(approvalId);
if (!approval) throw new OperationError("NOT_FOUND", "Approval not found");
if (approval.status !== "pending") {
throw new OperationError("CONFLICT", "This was already decided");
}
const run = await repository.findRun(approval.runId);
if (!run) throw new OperationError("NOT_FOUND", "Run not found");
if (options.impersonated) {
throw new OperationError("FORBIDDEN", "You cannot approve as another user");
}
if (approval.requestedBy === approverId && !approval.allowSelf) {
throw new OperationError(
"FORBIDDEN",
"Someone else has to approve your request",
);
}
if (new Date(approval.expiresAt).getTime() < Date.now()) {
await repository.decideApproval(approvalId, "expired", null, null, now());
await failStale(run, "The approval expired");
throw new OperationError("STALE", "This approval expired");
}
// Approving needs the same rights as running it.
const registration = getAction(run.actionId);
if (registration) {
const permissions = PermissionManager.getInstance();
const needed = qualifyPermission(
registration.pluginId,
registration.action.permission,
);
const allowed =
(await permissions.hasPermission(approverId, needed)) &&
(run.hostId === null ||
(
await permissions.canAccessHost(
approverId,
run.hostId,
registration.action.hostAccess ?? "edit",
)
).hasAccess);
if (!allowed) {
throw new OperationError(
"FORBIDDEN",
"You need the same access as this action to approve it",
);
}
}
const won = await repository.decideApproval(
approvalId,
approve ? "approved" : "rejected",
approverId,
comment?.slice(0, 500) ?? null,
now(),
);
if (!won) throw new OperationError("CONFLICT", "This was already decided");
const username = await usernameOf(approverId);
void logAudit({
userId: approverId,
username,
action: approve ? "operation_approved" : "operation_rejected",
resourceType: "operation",
resourceId: run.actionId,
details: run.id,
success: true,
}).catch(() => {});
if (!approve) {
await repository.updateRunFrom(run.id, "pending_approval", {
status: "rejected",
sealedInput: null,
finishedAt: now(),
});
return { runId: run.id, status: "rejected" };
}
return runApproved(run, approval);
}
async function readSnapshot(
run: OperationRunRecord,
): Promise<ApprovalSnapshot | null> {
if (!run.sealedInput || !isSystemEncrypted(run.sealedInput)) return null;
try {
const snapshot = JSON.parse(
await decryptSystemSecret(run.sealedInput),
) as ApprovalSnapshot;
return snapshot.runId === run.id ? snapshot : null;
} catch {
return null;
}
}
async function runApproved(
run: OperationRunRecord,
approval: OperationApprovalRecord,
): Promise<OperationOutcome> {
const snapshot = await readSnapshot(run);
if (!snapshot || !run.userId) {
await failStale(run, "The approved request could not be read");
throw new OperationError("STALE", "The approved request could not be read");
}
const request: OperationRequest = {
actionId: snapshot.actionId,
hostId: snapshot.hostId ?? undefined,
resourceKey: snapshot.resourceKey ?? undefined,
input: snapshot.input,
confirmed: true,
batchId: run.batchId ?? undefined,
userId: run.userId,
origin: originOf(run),
};
// The requester's own rights, as of now. They may have changed.
let prepared: Prepared;
try {
prepared = await prepare(request);
} catch (error) {
await failStale(run, errorText(error));
throw error;
}
const changedSince =
prepared.pluginVersion !== snapshot.pluginVersion ||
(prepared.action.version ?? 1) !== snapshot.actionVersion ||
(prepared.host?.updatedAt ?? null) !== snapshot.hostUpdatedAt;
if (changedSince) {
await failStale(run, "The host or plugin changed after this was requested");
throw new OperationError(
"STALE",
"The host or plugin changed after this was requested",
);
}
const repository = createCurrentOperationRepository();
const claimed = await repository.updateRunFrom(run.id, "pending_approval", {
status: "running",
startedAt: now(),
});
if (!claimed) throw new OperationError("CONFLICT", "This already ran");
const outcome = await execute(prepared, request, run.id, run.username);
return { ...outcome, approvalId: approval.id };
}
export async function cancelOperationRun(
run: OperationRunRecord,
): Promise<boolean> {
const repository = createCurrentOperationRepository();
if (run.status === "pending_approval") {
const approval = await repository.findApprovalForRun(run.id);
if (approval) {
await repository.decideApproval(
approval.id,
"rejected",
null,
"cancelled",
now(),
);
}
return repository.updateRunFrom(run.id, "pending_approval", {
status: "cancelled",
sealedInput: null,
finishedAt: now(),
});
}
const running = active.get(run.id);
if (!running) return false;
running.controller.abort();
return true;
}
/**
* Called when a plugin stops, before its SSH connections are torn down. New
* runs are refused and running ones told to stop. Anything still going after
* the wait has an unknown outcome and is never retried.
*/
export async function drainPlugin(pluginId: string): Promise<void> {
draining.add(pluginId);
try {
const runs = [...active.entries()].filter(
([, entry]) => entry.pluginId === pluginId,
);
if (runs.length === 0) return;
for (const [, entry] of runs) entry.controller.abort();
await Promise.race([
Promise.all(runs.map(([, entry]) => entry.done)),
new Promise((resolve) => setTimeout(resolve, DRAIN_WAIT_MS)),
]);
const repository = createCurrentOperationRepository();
for (const [runId] of runs) {
await repository.updateRunFrom(runId, "running", {
status: "interrupted",
error: "The plugin stopped during this run. Check the host.",
sealedInput: null,
finishedAt: now(),
});
active.delete(runId);
}
} catch (error) {
pluginLogger.warn("Could not finish stopping runs", {
operation: "operation_drain",
pluginId,
error: errorText(error),
});
} finally {
draining.delete(pluginId);
}
}
export function riskAtLeast(
risk: OperationRisk,
floor: OperationRisk,
): boolean {
return riskRank(risk) >= riskRank(floor);
}
/** Test seam. */
export function clearOperationGate(): void {
active.clear();
draining.clear();
}
+68
View File
@@ -0,0 +1,68 @@
/**
* The small view of a host the operations layer needs: enough to name it, test
* an action's `when`, and notice it changed. No secret field is ever read here.
*/
import type { OperationWhen } from "@termix-ssh/plugin-sdk/operations";
import type { PluginManifest } from "../plugins/manifest.js";
import { getSetting } from "../plugins/settings.js";
import { createCurrentHostResolutionRepository } from "../database/repositories/factory.js";
export interface OperationHost {
id: number;
name: string;
updatedAt: string | null;
tags: string[];
os: string | null;
/** Only the setting keys some `when` asks about. */
settings: Record<string, unknown>;
}
export function splitTags(tags: string | null | undefined): string[] {
return (tags ?? "")
.split(",")
.map((tag) => tag.trim())
.filter(Boolean);
}
async function settingsFor(
manifest: PluginManifest,
hostId: number,
): Promise<Record<string, unknown>> {
const keys = new Set<string>();
for (const action of manifest.contributes?.operations?.actions ?? []) {
for (const key of Object.keys(action.when?.settings ?? {})) keys.add(key);
}
const settings: Record<string, unknown> = {};
for (const key of keys) {
settings[key] = await getSetting(manifest, "host", hostId, key);
}
return settings;
}
/** A host with the settings its plugin's `when` rules read. */
export async function loadOperationHost(
hostId: number,
manifest: PluginManifest,
): Promise<OperationHost | null> {
const repository = createCurrentHostResolutionRepository();
const ownerId = await repository.findHostOwnerId(hostId);
if (!ownerId) return null;
const host = (await repository.findHostById(hostId, ownerId)) as {
name: string | null;
ip: string;
tags: string | null;
updatedAt?: string | null;
} | null;
if (!host) return null;
return {
id: hostId,
name: host.name || host.ip,
updatedAt: host.updatedAt ?? null,
tags: splitTags(host.tags),
os: null,
settings: await settingsFor(manifest, hostId),
};
}
export type { OperationWhen };
+44
View File
@@ -0,0 +1,44 @@
/**
* What a scoped API key may reach. Enforced where keys are authenticated, so a
* limited key cannot step around the gate through /plugin-api or a ws ticket. A
* key with no scopes has full access, which is every key made before this.
*/
export const API_KEY_SCOPES = ["operations:read", "operations:run"] as const;
const MCP_PATH = /^\/plugin-api\/[a-z0-9-]+\/mcp(\/|$)/;
const OPERATIONS_PATH = /^\/operations(\/|$)/;
export function parseKeyScopes(
raw: string | null | undefined,
): string[] | null {
if (!raw) return null;
try {
const value = JSON.parse(raw);
return Array.isArray(value)
? value.filter((scope): scope is string => typeof scope === "string")
: [];
} catch {
// Unreadable means no access, not full access.
return [];
}
}
export function keyScopeAllows(
scopes: string[] | null,
method: string,
path: string,
): boolean {
if (scopes === null) return true;
const clean = path.split("?")[0];
if (MCP_PATH.test(clean)) {
return (
scopes.includes("operations:read") || scopes.includes("operations:run")
);
}
if (!OPERATIONS_PATH.test(clean)) return false;
if (scopes.includes("operations:run")) return true;
if (!scopes.includes("operations:read")) return false;
// The gate still checks each action's risk on a run.
return method === "GET" || /^\/operations\/[^/]+\/run$/.test(clean);
}
+90
View File
@@ -0,0 +1,90 @@
/**
* Caps on how much runs at once. The SSH pool caps per key, not per host, so a
* large rollout or an all hosts table needs its own limit here.
*/
export const MAX_RUNNING = 32;
export const MAX_PER_HOST = 3;
class Slots {
private used = 0;
private readonly waiting: (() => void)[] = [];
constructor(private readonly size: number) {}
async take(): Promise<void> {
if (this.used < this.size) {
this.used++;
return;
}
await new Promise<void>((resolve) => this.waiting.push(resolve));
}
give(): void {
const next = this.waiting.shift();
if (next) next();
else this.used--;
}
get idle(): boolean {
return this.used === 0 && this.waiting.length === 0;
}
}
const everything = new Slots(MAX_RUNNING);
const perHost = new Map<number, Slots>();
const exclusive = new Map<number, Slots>();
function slotsFor(
map: Map<number, Slots>,
hostId: number,
size: number,
): Slots {
let slots = map.get(hostId);
if (!slots) {
slots = new Slots(size);
map.set(hostId, slots);
}
return slots;
}
/** Runs `work` once there is room. Exclusive work on a host goes one at a time. */
export async function withLimits<T>(
options: { hostId?: number; exclusive?: boolean },
work: () => Promise<T>,
): Promise<T> {
const { hostId } = options;
const held: { map?: Map<number, Slots>; slots: Slots }[] = [];
const hold = async (slots: Slots, map?: Map<number, Slots>) => {
await slots.take();
held.push({ slots, map });
};
try {
// Same order everywhere, so two runs cannot wait on each other.
if (hostId !== undefined && options.exclusive) {
await hold(slotsFor(exclusive, hostId, 1), exclusive);
}
if (hostId !== undefined) {
await hold(slotsFor(perHost, hostId, MAX_PER_HOST), perHost);
}
await hold(everything);
return await work();
} finally {
for (const { slots, map } of held.reverse()) {
slots.give();
if (map && hostId !== undefined && slots.idle) map.delete(hostId);
}
}
}
/** Shares one in-flight promise per key, so one read serves every waiter. */
const inflight = new Map<string, Promise<unknown>>();
export function coalesce<T>(key: string, work: () => Promise<T>): Promise<T> {
const running = inflight.get(key);
if (running) return running as Promise<T>;
const started = work().finally(() => inflight.delete(key));
inflight.set(key, started);
return started;
}
+47
View File
@@ -0,0 +1,47 @@
/** Startup sweep and periodic cleanup for runs, approvals and events. */
import { RETENTION_DAYS } from "./constants.js";
import { pluginLogger } from "../utils/logger.js";
import { createCurrentOperationRepository } from "../database/repositories/factory.js";
const SWEEP_MS = 10 * 60_000;
let timer: ReturnType<typeof setInterval> | null = null;
/** Parks expired approvals and trims history. Returns rows removed. */
export async function sweepOperations(at = new Date()): Promise<number> {
const repository = createCurrentOperationRepository();
const iso = at.toISOString();
for (const approval of await repository.listExpiredApprovals(iso)) {
await repository.decideApproval(approval.id, "expired", null, null, iso);
await repository.updateRunFrom(approval.runId, "pending_approval", {
status: "expired",
sealedInput: null,
finishedAt: iso,
});
}
const cutoff = new Date(at.getTime() - RETENTION_DAYS * 86_400_000);
return repository.prune(cutoff.toISOString());
}
/** Anything still running at boot lost its process, so its outcome is unknown. */
export async function startOperationsMaintenance(): Promise<void> {
await createCurrentOperationRepository().markRunningInterrupted(
new Date().toISOString(),
);
await sweepOperations();
timer ??= setInterval(() => {
sweepOperations().catch((error) => {
pluginLogger.warn("Operations cleanup failed", {
operation: "operations_sweep",
error: error instanceof Error ? error.message : String(error),
});
});
}, SWEEP_MS);
timer.unref?.();
}
export function stopOperationsMaintenance(): void {
if (timer) clearInterval(timer);
timer = null;
}
+84
View File
@@ -0,0 +1,84 @@
/**
* Handlers plugins registered for the actions their manifest declares.
*
* Same generation rule as service-registry.ts: a plugin that crashed and
* restarted must not have its old disposer remove the new registration, and a
* run that resolved a handler before a reload must not call the stale one.
*/
import type {
OperationAction,
OperationResult,
} from "@termix-ssh/plugin-sdk/operations";
export interface OperationRunContext {
hostId?: number;
resourceKey?: string;
runId: string | null;
dryRun: boolean;
signal: AbortSignal;
log: (line: string) => void;
}
export interface OperationHandler {
run: (
input: Record<string, unknown>,
context: OperationRunContext,
) => Promise<OperationResult> | OperationResult;
/** What a dry run reports when the action has no native dry run. */
describe?: (
input: Record<string, unknown>,
context: OperationRunContext,
) => string | Promise<string>;
}
export interface OperationRegistration {
pluginId: string;
action: OperationAction;
handler: OperationHandler;
generation: number;
}
const registrations = new Map<string, OperationRegistration>();
let generationCounter = 0;
export function registerAction(
pluginId: string,
action: OperationAction,
handler: OperationHandler,
): OperationRegistration {
const existing = registrations.get(action.id);
if (existing && existing.pluginId !== pluginId) {
throw new Error(
`Action "${action.id}" is already registered by ${existing.pluginId}`,
);
}
const entry = {
pluginId,
action,
handler,
generation: ++generationCounter,
};
registrations.set(action.id, entry);
return entry;
}
/** Removes only the exact registration given. */
export function revokeAction(registration: OperationRegistration): boolean {
const current = registrations.get(registration.action.id);
if (!current || current.generation !== registration.generation) return false;
return registrations.delete(registration.action.id);
}
export function getAction(id: string): OperationRegistration | undefined {
return registrations.get(id);
}
export function listActions(): OperationRegistration[] {
return [...registrations.values()];
}
/** Test seam. */
export function clearOperationRegistry(): void {
registrations.clear();
}
+235
View File
@@ -0,0 +1,235 @@
/**
* Reads for All hosts tables and status tiles.
*
* Asking every host over SSH on each page load cannot hold up, so a request
* answers right away from memory and refreshes stale hosts behind it. The page
* polls and the table fills in as hosts answer. Results stay in memory, never
* SQLite, since a save rewrites the whole database.
*
* Keys include the user: someone a host is shared with can have their own
* login there and see different rows.
*/
import type {
ResourceListResult,
SignalResult,
SignalStatus,
} from "@termix-ssh/plugin-sdk/operations";
import { RESOURCE_FRESH_MS } from "./constants.js";
import { coalesce } from "./limits.js";
import { getAction } from "./registry.js";
import { matchesWhen } from "@termix-ssh/plugin-sdk/operations";
import { loadOperationHost } from "./hosts.js";
import type { ActorOrigin } from "../plugins/actor.js";
import { hostStatus } from "../plugins/host-status-provider.js";
import { PermissionManager } from "../utils/permission-manager.js";
import { loadProbeTargets } from "../plugins/ctx-probe.js";
export type HostReadState =
"ok" | "loading" | "offline" | "error" | "no-access" | "not-applicable";
interface CacheEntry {
value: unknown;
error?: string;
fetchedAt: number;
}
export interface HostRead<T> {
hostId: number;
state: HostReadState;
value?: T;
error?: string;
fetchedAt?: string;
}
const cache = new Map<string, CacheEntry>();
const MAX_ENTRIES = 20_000;
function cacheKey(userId: string, actionId: string, hostId: number): string {
return `${userId}|${actionId}|${hostId}`;
}
/** After a change, the next read of this host goes back to the host. */
export function dropResourceCache(hostId: number): void {
const suffix = `|${hostId}`;
for (const key of cache.keys()) {
if (key.endsWith(suffix)) cache.delete(key);
}
}
function remember(key: string, entry: CacheEntry): void {
if (cache.size >= MAX_ENTRIES) {
const oldest = cache.keys().next().value;
if (oldest) cache.delete(oldest);
}
cache.delete(key);
cache.set(key, entry);
}
function isOffline(hostId: number): boolean {
const status = hostStatus.get(hostId) as
string | { status?: string } | null | undefined;
const value = typeof status === "string" ? status : status?.status;
return value === "offline";
}
interface ReadOptions {
userId: string;
actionId: string;
hostIds?: number[];
refresh?: boolean;
/** How long a cached answer stays fresh. Defaults to RESOURCE_FRESH_MS. */
freshMs?: number;
input?: Record<string, unknown>;
origin: ActorOrigin;
}
function refreshHost(
options: ReadOptions,
hostId: number,
key: string,
): Promise<unknown> {
return coalesce(key, async () => {
const { runOperation, OperationError } = await import("./gate.js");
try {
const outcome = await runOperation({
actionId: options.actionId,
hostId,
input: options.input,
internal: true,
userId: options.userId,
origin: options.origin,
});
if (outcome.status !== "success") {
remember(key, {
value: undefined,
error: outcome.error ?? outcome.status,
fetchedAt: Date.now(),
});
return;
}
remember(key, { value: outcome.data, fetchedAt: Date.now() });
} catch (error) {
remember(key, {
value: undefined,
error:
error instanceof OperationError || error instanceof Error
? error.message
: String(error),
fetchedAt: Date.now(),
});
}
});
}
/** One entry per host the user can reach. Never waits on a host. */
export async function readAcrossHosts<T>(
options: ReadOptions,
): Promise<HostRead<T>[]> {
const registration = getAction(options.actionId);
if (!registration) return [];
const { action } = registration;
const { getPluginRuntime } = await import("../plugins/index.js");
const manifest = getPluginRuntime().loader.get(
registration.pluginId,
)?.manifest;
if (!manifest) return [];
const permissions = PermissionManager.getInstance();
const targets = await loadProbeTargets(options.userId, options.hostIds);
const reads: HostRead<T>[] = [];
for (const target of targets) {
const hostId = target.id;
const access = await permissions.canAccessHost(
options.userId,
hostId,
action.hostAccess ?? "connect",
);
if (!access.hasAccess) {
reads.push({ hostId, state: "no-access" });
continue;
}
if (action.when) {
const host = await loadOperationHost(hostId, manifest);
if (!host || !matchesWhen(action.when, host)) continue;
}
const key = cacheKey(options.userId, options.actionId, hostId);
const cached = cache.get(key);
const fresh =
cached &&
Date.now() - cached.fetchedAt < (options.freshMs ?? RESOURCE_FRESH_MS);
if (isOffline(hostId)) {
reads.push({
hostId,
state: "offline",
value: cached?.value as T | undefined,
fetchedAt: cached
? new Date(cached.fetchedAt).toISOString()
: undefined,
});
continue;
}
if (!fresh || options.refresh) void refreshHost(options, hostId, key);
if (!cached) {
reads.push({ hostId, state: "loading" });
} else if (cached.error) {
reads.push({
hostId,
state: "error",
error: cached.error,
fetchedAt: new Date(cached.fetchedAt).toISOString(),
});
} else {
reads.push({
hostId,
state: "ok",
value: cached.value as T,
fetchedAt: new Date(cached.fetchedAt).toISOString(),
});
}
}
return reads;
}
export interface ResourceRow {
hostId: number;
key: string;
row: Record<string, unknown>;
}
/** Flattens per host reads into table rows, keeping each host's state. */
export function flattenResourceRows(
reads: HostRead<ResourceListResult>[],
keyField: string,
): { rows: ResourceRow[]; hosts: Omit<HostRead<unknown>, "value">[] } {
const rows: ResourceRow[] = [];
const hosts: Omit<HostRead<unknown>, "value">[] = [];
for (const read of reads) {
const { value, ...state } = read;
hosts.push(state);
for (const row of value?.rows ?? []) {
rows.push({ hostId: read.hostId, key: String(row[keyField]), row });
}
}
return { rows, hosts };
}
const WORST: SignalStatus[] = ["fail", "warn", "unknown", "ok"];
/** The worst status across hosts, for a server level tile. */
export function worstStatus(
results: (SignalResult | undefined)[],
): SignalStatus {
for (const status of WORST) {
if (results.some((result) => result?.status === status)) return status;
}
return "unknown";
}
/** Test seam. */
export function clearResourceCache(): void {
cache.clear();
}
+49 -2
View File
@@ -20,7 +20,28 @@
import { AsyncLocalStorage } from "node:async_hooks";
interface ActorStore {
/** How a call reached core, for run history and approval rules. */
export interface ActorOrigin {
kind: "ui" | "api" | "plugin";
/** Which plugin asked, set by core from the ctx that made the call. */
pluginId?: string;
/** What the plugin was doing: automation, assistant, mcp, rollout and so on. */
via?: string;
}
/** Follows a call through plugin work, so nothing loses who started it. */
export interface ActorTrail {
origin?: ActorOrigin;
apiKeyId?: string;
/** Null means a key with full access. */
apiKeyScopes?: string[] | null;
/** The operation run this work belongs to. */
runId?: string;
/** How many operations or events led here. */
depth?: number;
}
interface ActorStore extends ActorTrail {
userId: string;
/** Where the identity came from, for the audit line. */
source: "request" | "asUser" | "service";
@@ -35,8 +56,34 @@ export function runAsActor<T>(
source: ActorStore["source"],
fn: () => T,
sessionId?: string,
trail?: ActorTrail,
): T {
return storage.run({ userId, source, sessionId }, fn);
const parent = storage.getStore();
return storage.run(
{
origin: parent?.origin,
apiKeyId: parent?.apiKeyId,
apiKeyScopes: parent?.apiKeyScopes,
runId: parent?.runId,
depth: parent?.depth,
...trail,
userId,
source,
sessionId,
},
fn,
);
}
export function getActorTrail(): ActorTrail {
const store = storage.getStore();
return {
origin: store?.origin,
apiKeyId: store?.apiKeyId,
apiKeyScopes: store?.apiKeyScopes,
runId: store?.runId,
depth: store?.depth,
};
}
export function getActorSessionId(): string | undefined {
+17
View File
@@ -35,3 +35,20 @@ export function missingShippedPluginIds(
.map((record) => record.id)
.sort();
}
/**
* Plugins this image's profile ships that the install has never seen and
* that are not on disk. They get fetched from the registry once, the first
* boot that can reach it.
*/
export function newShippedPluginIds(
shipped: Iterable<string>,
records: { id: string }[],
presentIds: ReadonlySet<string>,
ignored: ReadonlySet<string>,
): string[] {
const known = new Set(records.map((record) => record.id));
return [...shipped]
.filter((id) => !known.has(id) && !presentIds.has(id) && !ignored.has(id))
.sort();
}
+49 -11
View File
@@ -18,7 +18,14 @@ export interface OnboardingDefaults {
type Recommended = boolean | "desktop";
export interface BundledCatalogEntry {
tier: string;
version?: string;
}
interface BundledIndex {
profile?: string;
catalog?: Record<string, BundledCatalogEntry>;
plugins: Record<
string,
{
@@ -45,7 +52,14 @@ export function readBundledIndex(): BundledIndex | null {
);
cached =
raw && typeof raw.plugins === "object" && raw.plugins
? { plugins: raw.plugins }
? {
plugins: raw.plugins,
profile: typeof raw.profile === "string" ? raw.profile : undefined,
catalog:
raw.catalog && typeof raw.catalog === "object"
? raw.catalog
: undefined,
}
: null;
} catch {
cached = null;
@@ -101,21 +115,45 @@ export function onboardingDefaults(pluginId: string): OnboardingDefaults {
};
}
const PROFILES = ["minimal", "standard", "full"];
/**
* The state a newly seen plugin's row starts in. Bundled plugins start
* enabled, except on a fresh install, where only recommended ones do and
* consent plugins wait for the admin. A plugin dropped into the data
* directory always starts disabled.
* Plugins the image's profile ships (its tier and every smaller one), with
* the version it pins. Read from the bundled index, so core never names them.
*/
export function shippedCatalog(): Map<string, BundledCatalogEntry> {
const index = readBundledIndex();
const out = new Map<string, BundledCatalogEntry>();
if (!index?.catalog) return out;
const rank = PROFILES.indexOf(index.profile ?? "standard");
for (const [id, entry] of Object.entries(index.catalog)) {
if (!entry || typeof entry !== "object") continue;
const tier = PROFILES.indexOf(entry.tier);
if (tier === -1 || rank === -1 || tier > rank) continue;
out.set(id, entry);
}
return out;
}
/** Whether a shipped plugin should be on when an install first sees it. */
export function startsEnabled(pluginId: string): boolean {
// The default look is what the app is drawn with.
if (isDefaultLookPlugin(pluginId)) return true;
const { recommended, consent } = onboardingDefaults(pluginId);
return recommended && !consent;
}
/**
* The state a newly seen plugin's row starts in. A bundled plugin starts on
* only when it is recommended, so a plugin a later release adds to the image
* does not switch itself on everywhere. Consent plugins wait for the admin.
* A plugin dropped into the data directory always starts disabled.
*/
export function initialPluginState(
pluginId: string,
source: "bundled" | "user",
freshInstall: boolean,
_freshInstall: boolean,
): "enabled" | "disabled" {
if (source !== "bundled") return "disabled";
if (!freshInstall) return "enabled";
// The default look is what the app is drawn with.
if (isDefaultLookPlugin(pluginId)) return "enabled";
const { recommended, consent } = onboardingDefaults(pluginId);
return recommended && !consent ? "enabled" : "disabled";
return startsEnabled(pluginId) ? "enabled" : "disabled";
}
+195
View File
@@ -0,0 +1,195 @@
/**
* ctx.operations: declaring what a plugin can do, and asking for another
* plugin's action to run.
*
* invoke acts for the current user, so on its own it would let any plugin spend
* that user's full authority. It needs the capability, the action named in the
* manifest's invokes list, and the origin always names the calling plugin, set
* here from the closure rather than from anything the plugin passes.
*/
import type {
PluginOperations,
PluginOperationInvokeOptions,
} from "@termix-ssh/plugin-sdk/backend";
import {
invokeAllowed,
type OperationAction,
} from "@termix-ssh/plugin-sdk/operations";
import type { PluginManifest } from "./manifest.js";
import { assertCapability, capabilityRefused } from "./permissions.js";
import { getActor, getActorTrail, type ActorOrigin } from "./actor.js";
import type { DisposableBag } from "./disposables.js";
import {
registerAction,
revokeAction,
type OperationHandler,
} from "../operations/registry.js";
import { provideApprovalPolicy } from "../operations/approvals.js";
import {
emitOperationEvent,
subscribeOperationEvents,
} from "../operations/events.js";
import {
drainPlugin,
OperationError,
runOperation,
} from "../operations/gate.js";
import { buildCatalog } from "../operations/catalog.js";
interface Deps {
manifest: PluginManifest;
bag: DisposableBag;
}
export function createPluginOperations({
manifest,
bag,
}: Deps): PluginOperations {
const pluginId = manifest.id;
const declared = manifest.capabilities;
const contribution = manifest.contributes?.operations;
let drainRegistered = false;
const declaredAction = (id: string): OperationAction => {
const action = contribution?.actions?.find((entry) => entry.id === id);
if (!action) {
throw new Error(
`Plugin ${pluginId} cannot register action "${id}": it is not declared in contributes.operations`,
);
}
return action;
};
const actingUser = (): string => {
const userId = getActor();
if (!userId) {
throw new Error(
"ctx.operations needs an acting user: call it inside a request or ctx.asUser",
);
}
return userId;
};
return {
register: (id, handler) => {
if (!declared.includes("operations:provide")) {
throw capabilityRefused(pluginId, "operations:provide");
}
const registration = registerAction(
pluginId,
declaredAction(id),
handler as OperationHandler,
);
bag.add(() => void revokeAction(registration), `action "${id}"`);
// Added once and after the actions, so disposal, which runs newest
// first, drains before anything the handlers use is torn down.
if (!drainRegistered) {
drainRegistered = true;
bag.add(() => drainPlugin(pluginId), "operations drain");
}
},
// A plugin's own route running its own action, so a button press lands in
// the same history. The caller stays the origin, not this plugin.
run: async (actionId, options = {}) => {
if (!declared.includes("operations:provide")) {
throw capabilityRefused(pluginId, "operations:provide");
}
declaredAction(actionId);
const trail = getActorTrail();
return runOperation({
actionId,
hostId: options.hostId,
resourceKey: options.resourceKey,
input: options.input,
dryRun: options.dryRun,
confirmed: options.confirmed,
idempotencyKey: options.idempotencyKey,
batchId: options.batchId,
userId: actingUser(),
origin: trail.origin ?? { kind: "plugin", pluginId },
apiKeyScopes: trail.apiKeyScopes,
depth: trail.depth,
});
},
invoke: async (actionId, options = {}) => {
await assertCapability(pluginId, "operations:invoke", declared);
if (!invokeAllowed(contribution?.invokes, actionId)) {
throw new OperationError(
"FORBIDDEN",
`${pluginId} did not declare that it runs ${actionId}`,
);
}
const trail = getActorTrail();
const origin: ActorOrigin = {
kind: "plugin",
pluginId,
via: (options as PluginOperationInvokeOptions).via,
};
return runOperation({
actionId,
hostId: options.hostId,
resourceKey: options.resourceKey,
input: options.input,
dryRun: options.dryRun,
confirmed: options.confirmed,
idempotencyKey: options.idempotencyKey,
batchId: options.batchId,
userId: actingUser(),
origin,
apiKeyScopes: trail.apiKeyScopes,
depth: trail.depth,
});
},
list: async (options) => {
await assertCapability(pluginId, "operations:invoke", declared);
return buildCatalog({
userId: actingUser(),
lang: options?.lang,
surface: options?.surface,
});
},
emit: async (eventId, payload, options) => {
if (!declared.includes("operations:provide")) {
throw capabilityRefused(pluginId, "operations:provide");
}
const event = contribution?.events?.find((entry) => entry.id === eventId);
if (!event) {
throw new Error(
`Plugin ${pluginId} cannot emit "${eventId}": it is not declared in contributes.operations`,
);
}
await emitOperationEvent({
eventId,
pluginId,
hostId: options?.hostId,
severity: event.severity,
payload,
});
},
onEvent: (subscription) => {
if (!declared.includes("operations:invoke")) {
throw capabilityRefused(pluginId, "operations:invoke");
}
const remove = subscribeOperationEvents(pluginId, subscription);
bag.add(remove, `events "${subscription.pattern}"`);
return remove;
},
approvals: {
provide: (provider) => {
if (!declared.includes("operations:approve")) {
throw capabilityRefused(pluginId, "operations:approve");
}
const remove = provideApprovalPolicy(pluginId, manifest, provider);
bag.add(remove, "approval rules");
return remove;
},
},
};
}
+3
View File
@@ -68,6 +68,7 @@ import { createPluginAuth, createPluginSsh } from "./ctx-ssh-auth.js";
import { createPluginHosts } from "./ctx-hosts.js";
import { createPluginSchedule } from "./schedule.js";
import { createPluginFetch, createPluginNotify } from "./ctx-notify.js";
import { createPluginOperations } from "./ctx-operations.js";
import { createPluginProcess } from "./ctx-process.js";
import { createPluginSystem } from "./ctx-system.js";
import { createPluginPlugins } from "./ctx-plugins.js";
@@ -976,6 +977,8 @@ export function createPluginContext(
void reportRuntimeError(pluginId, error);
}),
ssh: createPluginSsh({ manifest, bag: handle.bag, audit: auditCall }),
operations: createPluginOperations({ manifest, bag: handle.bag }),
notify: createPluginNotify({
manifest,
audit: auditCall,
@@ -560,7 +560,13 @@ export async function openDownload(
}
},
abort: async () => {
stream.destroy();
// The file opens in the background, so wait for the stream to close
// or a late open would leave the partial file behind.
await new Promise<void>((resolve) => {
if (stream.closed) return resolve();
stream.once("close", () => resolve());
stream.destroy();
});
await fsp.rm(partialPath, { force: true }).catch(() => {});
release();
},
+8 -2
View File
@@ -354,15 +354,21 @@ export function createPluginRouter({
// After auth, so the actor is the user core authenticated rather than
// whatever the request claimed.
outer.use((req: Request, _res: Response, next: NextFunction) => {
const { userId, sessionId } = req as Request & {
const { userId, sessionId, apiKeyId, apiKeyScopes } = req as Request & {
userId?: string;
sessionId?: string;
apiKeyId?: string;
apiKeyScopes?: string[] | null;
};
if (!userId) {
next();
return;
}
runAsActor(userId, "request", () => next(), sessionId);
runAsActor(userId, "request", () => next(), sessionId, {
origin: { kind: apiKeyId ? "api" : "ui" },
apiKeyId,
apiKeyScopes,
});
});
outer.use(inner);
+3 -1
View File
@@ -243,8 +243,10 @@ export async function initializePlugins(
const fresh = await seedPlugins(loaded, options.freshInstall === true);
if (!fresh) {
const { restoreMissingPlugins } = await import("./manage.js");
const { installNewShippedPlugins, restoreMissingPlugins } =
await import("./manage.js");
loaded.push(...(await restoreMissingPlugins()));
loaded.push(...(await installNewShippedPlugins()));
}
await syncCapabilityGrants(loaded);
if (loaded.some((plugin) => plugin.source === "bundled")) {
+20 -3
View File
@@ -409,6 +409,15 @@ export class PluginLoader {
}
loaded.push(plugin);
} catch (error) {
// A newer image ships a newer copy, so the old update can go.
if (isArtifact && error instanceof SupersededUpdateError) {
await fs.promises.rm(dir, { force: true }).catch(() => {});
await fs.promises.rm(`${dir}.sig`, { force: true }).catch(() => {});
pluginLogger.info(`Removed ${entry.name}: ${error.message}`, {
operation: "plugin_load",
});
continue;
}
this.skippedPlugins.push(
describeSkipped(dir, entry.name, isArtifact, error),
);
@@ -948,6 +957,16 @@ function withTimeout<T>(
const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
/** An installed update that the bundled copy has caught up with. */
export class SupersededUpdateError extends Error {
constructor(id: string, version: string, bundledVersion: string) {
super(
`update for "${id}" is ${version}, which is not newer than the bundled ${bundledVersion}`,
);
this.name = "SupersededUpdateError";
}
}
/**
* Throws unless an artifact may replace the bundled plugin with the same id:
* a trusted key signed it and its version is strictly higher, so an old
@@ -971,9 +990,7 @@ export function assertBundledUpdate(
// An admin pinning an exact older release is the one allowed downgrade.
if (pinnedVersion && pinnedVersion === version) return;
if (bundledVersion && !semver.gt(version, bundledVersion)) {
throw new Error(
`update for "${id}" is ${version}, which is not newer than the bundled ${bundledVersion}`,
);
throw new SupersededUpdateError(id, version, bundledVersion);
}
}
+67 -2
View File
@@ -17,7 +17,11 @@ import fs from "node:fs";
import path from "node:path";
import semver from "semver";
import { pluginLogger } from "../utils/logger.js";
import { isDefaultLookPlugin } from "./bundled-index.js";
import {
isDefaultLookPlugin,
shippedCatalog,
startsEnabled,
} from "./bundled-index.js";
import { mayRunInSafeMode } from "./safe-mode.js";
import {
activatePlugin,
@@ -26,7 +30,7 @@ import {
registerLoadedPlugins,
unloadPlugin,
} from "./index.js";
import { missingShippedPluginIds } from "./boot-state.js";
import { missingShippedPluginIds, newShippedPluginIds } from "./boot-state.js";
import type { LoadedPlugin, PluginLoader } from "./loader.js";
import {
getBundledPluginsDir,
@@ -1099,6 +1103,67 @@ export function restoreMissingPlugins(): Promise<LoadedPlugin[]> {
});
}
/**
* Called at boot on an existing install. A plugin this image's profile ships
* that the install never had and that is not on disk is fetched from the
* official registry and starts on only if it is recommended. Offline, nothing
* is recorded, so the next start tries again.
*/
export function installNewShippedPlugins(): Promise<LoadedPlugin[]> {
return serialized(async () => {
if (await isLinkedDesktop()) return [];
const catalog = shippedCatalog();
if (catalog.size === 0) return [];
const { createCurrentPluginRepository } = await repos();
const repository = createCurrentPluginRepository();
const { loader } = getPluginRuntime();
const ids = newShippedPluginIds(
catalog.keys(),
await repository.listAll(),
new Set(loader.list().map((plugin) => plugin.id)),
await readUninstalled(),
);
const added: LoadedPlugin[] = [];
for (const id of ids) {
try {
const { source, release } = await requireRelease(
id,
catalog.get(id)?.version,
"stable",
OFFICIAL_REGISTRY_ID,
);
const plugin = await swapIn(id, { release, source });
const enabled = startsEnabled(id);
await repository.update(id, {
registryId: source.id,
tier: "bundled",
state: enabled ? "enabled" : "disabled",
lastError: null,
});
await grantDeclared(plugin, null);
added.push(plugin);
pluginLogger.info(
`Added ${id}@${release.version} from the registry (${enabled ? "on" : "off"})`,
{ operation: "plugin_install_shipped" },
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
pluginLogger.warn(`Could not add ${id}: ${message}`, {
operation: "plugin_install_shipped",
});
// A half made row would hide the plugin from the next try.
if (!loader.get(id)) await repository.delete(id).catch(() => false);
if (
error instanceof PluginManageError &&
error.code === "REGISTRY_UNREACHABLE"
)
break;
}
}
return added;
});
}
export function updatePlugin(
pluginId: string,
options: InstallOptions,
+3
View File
@@ -243,6 +243,9 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
// Last, so a plugin's activate() sees a fully wired server. A plugin that
// fails to load must not stop the backend, so this never rejects.
try {
const { startOperationsMaintenance } =
await import("./operations/maintenance.js");
await startOperationsMaintenance();
const { initializePlugins } = await import("./plugins/index.js");
const loaded = await initializePlugins({ freshInstall });
if (loaded.length > 0) {
@@ -0,0 +1,301 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
userId: "user-1",
apiKeyId: undefined as string | undefined,
apiKeyScopes: undefined as string[] | null | undefined,
actingAdminUserId: undefined as string | undefined,
viewAll: false,
hostAccess: false,
runs: new Map<string, Record<string, unknown>>(),
runOperation: vi.fn(),
decide: vi.fn(),
cancel: vi.fn(async () => true),
}));
vi.mock("../../../utils/logger.js", () => ({
databaseLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() },
pluginLogger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() },
}));
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() =>
(
req: Record<string, unknown>,
_res: unknown,
next: () => void,
): void => {
req.userId = state.userId;
req.apiKeyId = state.apiKeyId;
req.apiKeyScopes = state.apiKeyScopes;
req.actingAdminUserId = state.actingAdminUserId;
next();
},
}),
},
}));
vi.mock("../../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
hasPermission: async () => state.viewAll,
canAccessHost: async () => ({ hasAccess: state.hostAccess }),
}),
},
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentOperationRepository: () => ({
findRun: async (id: string) => state.runs.get(id) ?? null,
listRuns: async (filters: { userId?: string }) =>
[...state.runs.values()].filter(
(run) => !filters.userId || run.userId === filters.userId,
),
listPendingApprovals: async () => [],
listEvents: async () => [],
}),
}));
vi.mock("../../../operations/catalog.js", () => ({
buildCatalog: vi.fn(async () => ({
actions: [{ id: "demo.restart" }],
events: [],
resources: [],
signals: [],
})),
}));
vi.mock("../../../operations/resources.js", () => ({
flattenResourceRows: vi.fn(),
readAcrossHosts: vi.fn(async () => []),
}));
vi.mock("../../../operations/gate.js", () => {
class OperationError extends Error {
status: number;
constructor(
readonly code: string,
message: string,
status = 403,
) {
super(message);
this.status = status;
}
}
return {
OperationError,
runOperation: state.runOperation,
decideOperationApproval: state.decide,
cancelOperationRun: state.cancel,
};
});
const { default: router } =
await import("../../../database/routes/operations-routes.js");
const { OperationError } = await import("../../../operations/gate.js");
const app = express();
app.use(express.json());
app.use("/operations", router);
const run = (overrides: Record<string, unknown> = {}) => ({
id: "run-1",
actionId: "demo.restart",
pluginId: "demo",
pluginVersion: "1.0.0",
userId: "user-1",
username: "name",
origin: '{"kind":"ui"}',
hostId: 1,
hostName: "web",
batchId: null,
dryRun: false,
risk: "change",
status: "success",
summary: "done",
changed: true,
input: '{"token":"[redacted]"}',
sealedInput: "sealed:secret",
output: null,
undoInput: null,
error: null,
errorCode: null,
undoOf: null,
createdAt: "2026-01-01",
startedAt: null,
finishedAt: null,
...overrides,
});
beforeEach(() => {
state.userId = "user-1";
state.apiKeyId = undefined;
state.apiKeyScopes = undefined;
state.actingAdminUserId = undefined;
state.viewAll = false;
state.hostAccess = false;
state.runs.clear();
state.runOperation.mockReset();
state.runOperation.mockResolvedValue({ runId: "r", status: "success" });
state.decide.mockReset();
state.decide.mockResolvedValue({ runId: "r", status: "success" });
});
describe("POST /operations/:id/run", () => {
it("passes a session as a ui origin", async () => {
await request(app)
.post("/operations/demo.restart/run")
.send({ hostId: 1, input: { name: "x" } })
.expect(200);
expect(state.runOperation).toHaveBeenCalledWith(
expect.objectContaining({
actionId: "demo.restart",
hostId: 1,
userId: "user-1",
origin: { kind: "ui" },
}),
);
});
it("passes a key as an api origin with its scopes", async () => {
state.apiKeyId = "key";
state.apiKeyScopes = ["operations:read"];
await request(app)
.post("/operations/demo.restart/run")
.send({})
.expect(200);
expect(state.runOperation).toHaveBeenCalledWith(
expect.objectContaining({
origin: { kind: "api" },
apiKeyScopes: ["operations:read"],
}),
);
});
it("does not take the user or origin from the body", async () => {
await request(app)
.post("/operations/demo.restart/run")
.send({ userId: "admin", origin: { kind: "plugin", via: "automation" } })
.expect(200);
expect(state.runOperation).toHaveBeenCalledWith(
expect.objectContaining({ userId: "user-1", origin: { kind: "ui" } }),
);
});
it("sends a refusal's status and code", async () => {
state.runOperation.mockRejectedValue(
new (
OperationError as never as new (code: string, message: string) => Error
)("FORBIDDEN", "No"),
);
const response = await request(app)
.post("/operations/demo.restart/run")
.send({})
.expect(403);
expect(response.body).toMatchObject({ code: "FORBIDDEN", error: "No" });
});
});
describe("runs", () => {
it("never returns the sealed input", async () => {
state.runs.set("run-1", run());
const response = await request(app)
.get("/operations/runs/run-1")
.expect(200);
expect(JSON.stringify(response.body)).not.toContain("secret");
});
it("hides another user's run", async () => {
state.runs.set("run-1", run({ userId: "someone" }));
await request(app).get("/operations/runs/run-1").expect(404);
});
it("shows a run to someone who manages its host", async () => {
state.runs.set("run-1", run({ userId: "someone" }));
state.hostAccess = true;
await request(app).get("/operations/runs/run-1").expect(200);
});
it("lists only a user's own runs", async () => {
state.runs.set("a", run({ id: "a" }));
state.runs.set("b", run({ id: "b", userId: "someone" }));
const response = await request(app).get("/operations/runs").expect(200);
expect(response.body.runs.map((row: { id: string }) => row.id)).toEqual([
"a",
]);
});
it("lists everything with the view permission", async () => {
state.viewAll = true;
state.runs.set("a", run({ id: "a" }));
state.runs.set("b", run({ id: "b", userId: "someone" }));
const response = await request(app).get("/operations/runs").expect(200);
expect(response.body.runs).toHaveLength(2);
});
it("does not cancel another user's run", async () => {
state.runs.set("run-1", run({ userId: "someone" }));
await request(app).post("/operations/runs/run-1/cancel").expect(404);
expect(state.cancel).not.toHaveBeenCalled();
});
});
describe("POST /operations/approvals/:id", () => {
it("needs a clear answer", async () => {
await request(app)
.post("/operations/approvals/a")
.send({ approve: "yes" })
.expect(400);
expect(state.decide).not.toHaveBeenCalled();
});
it("refuses a scoped key", async () => {
state.apiKeyScopes = ["operations:run"];
await request(app)
.post("/operations/approvals/a")
.send({ approve: true })
.expect(403);
expect(state.decide).not.toHaveBeenCalled();
});
it("marks an impersonated request", async () => {
state.actingAdminUserId = "admin";
await request(app)
.post("/operations/approvals/a")
.send({ approve: true })
.expect(200);
expect(state.decide).toHaveBeenCalledWith(
expect.objectContaining({ impersonated: true }),
);
});
});
describe("GET /operations/catalog", () => {
it("answers", async () => {
const response = await request(app).get("/operations/catalog").expect(200);
expect(response.body.actions).toEqual([{ id: "demo.restart" }]);
});
});
describe("GET /operations/signals", () => {
it("keeps a signal's answer for its refreshSec", async () => {
const { buildCatalog } = await import("../../../operations/catalog.js");
const { readAcrossHosts } =
await import("../../../operations/resources.js");
vi.mocked(buildCatalog).mockResolvedValueOnce({
actions: [],
events: [],
resources: [],
signals: [
{ id: "a", read: "demo.a", scope: "host", refreshSec: 600 },
{ id: "b", read: "demo.b", scope: "host" },
],
} as never);
await request(app).get("/operations/signals").expect(200);
expect(vi.mocked(readAcrossHosts)).toHaveBeenCalledWith(
expect.objectContaining({ actionId: "demo.a", freshMs: 600_000 }),
);
expect(vi.mocked(readAcrossHosts)).toHaveBeenCalledWith(
expect.objectContaining({ actionId: "demo.b", freshMs: undefined }),
);
});
});
@@ -148,8 +148,23 @@ async function build(
});
}
// Building a hop's config parses keys, which is slow while the whole suite
// runs, so give the multi hop steps more than vi.waitFor's 1 s.
const settle = (check: () => void) => vi.waitFor(check, { timeout: 10_000 });
vi.setConfig({ testTimeout: 30_000 });
beforeEach(() => {
vi.clearAllMocks();
// clearAllMocks keeps queued Once values, so a test that stopped early
// would hand its leftovers to the next one.
for (const mock of [
mocks.resolveHostById,
mocks.createJumpHostChain,
mocks.createSocks5Connection,
mocks.applyCertificateAuth,
mocks.applyAgentAuth,
])
mock.mockReset();
FakeSshClient.nextBehaviour = [];
FakeSshClient.instances = [];
mocks.createHostVerifier.mockResolvedValue(mocks.verifier);
@@ -532,7 +547,7 @@ describe("connectHost", () => {
"user-1",
prompt,
);
await vi.waitFor(() =>
await settle(() =>
expect(FakeSshClient.instances[0]?.connectConfig).toBeTruthy(),
);
const first = FakeSshClient.instances[0];
@@ -546,11 +561,9 @@ describe("connectHost", () => {
[{ prompt: "Verification code:", echo: false }],
firstAnswer,
);
await vi.waitFor(() =>
expect(firstAnswer).toHaveBeenCalledWith(["111111"]),
);
await settle(() => expect(firstAnswer).toHaveBeenCalledWith(["111111"]));
first.emit("ready");
await vi.waitFor(() =>
await settle(() =>
expect(FakeSshClient.instances[1]?.connectConfig).toBeTruthy(),
);
const second = FakeSshClient.instances[1];
@@ -566,7 +579,7 @@ describe("connectHost", () => {
],
secondAnswer,
);
await vi.waitFor(() =>
await settle(() =>
expect(secondAnswer).toHaveBeenCalledWith(["hop-password", "222222"]),
);
second.emit("ready");
@@ -674,7 +687,7 @@ describe("connectHost", () => {
it("auto-fills the stored password in keyboard-interactive with nobody to ask", async () => {
FakeSshClient.nextBehaviour = ["hang"];
const attempt = connectHost(host(), { userId: "user-1", purpose: "fleet" });
await vi.waitFor(() => {
await settle(() => {
const last = FakeSshClient.instances.at(-1)!;
expect(last.connectConfig).not.toBeNull();
});
@@ -704,7 +717,7 @@ describe("connectHost", () => {
purpose: "file-manager",
prompt: { ask },
});
await vi.waitFor(() =>
await settle(() =>
expect(FakeSshClient.instances.at(-1)!.connectConfig).not.toBeNull(),
);
const client = FakeSshClient.instances.at(-1)!;
@@ -720,7 +733,7 @@ describe("connectHost", () => {
],
finish,
);
await vi.waitFor(() => expect(finish).toHaveBeenCalled());
await settle(() => expect(finish).toHaveBeenCalled());
expect(ask).toHaveBeenCalledWith({
kind: "totp",
prompt: "Verification code: ",
@@ -751,7 +764,7 @@ describe("connectHost", () => {
purpose: "fleet",
}),
).rejects.toThrow(/All configured authentication methods failed/);
await vi.waitFor(() =>
await settle(() =>
expect(mocks.forgetCert).toHaveBeenCalledWith("user-1", 7),
);
dispose();
+703
View File
@@ -0,0 +1,703 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OperationAction } from "@termix-ssh/plugin-sdk/operations";
const state = vi.hoisted(() => ({
permissions: new Set<string>(),
hostLevels: new Map<number, string>(),
plugin: {
state: "active",
source: "bundled",
signedTier: undefined as string | undefined,
manifest: {
version: "1.0.0",
capabilities: ["operations:provide"],
},
},
capability: true,
safeMode: false,
host: { name: "web", updatedAt: "2026-01-01", tags: ["prod"] } as {
name: string;
updatedAt: string;
tags: string[];
},
runs: new Map<string, Record<string, unknown>>(),
approvals: new Map<string, Record<string, unknown>>(),
audits: [] as Record<string, unknown>[],
events: [] as Record<string, unknown>[],
}));
vi.mock("../../utils/logger.js", () => ({
pluginLogger: {
warn: vi.fn(),
info: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
state.audits.push(entry);
},
}));
vi.mock("../../utils/system-secret-crypto.js", () => ({
encryptSystemSecret: async (text: string) => `sealed:${text}`,
decryptSystemSecret: async (text: string) => text.slice("sealed:".length),
isSystemEncrypted: (text: string) => text.startsWith("sealed:"),
}));
vi.mock("../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
hasPermission: async (userId: string, permission: string) =>
state.permissions.has(`${userId}:${permission}`),
canAccessHost: async (userId: string, hostId: number, level: string) => {
const held = state.hostLevels.get(hostId);
const rank = ["connect", "view", "edit", "manage"];
return {
hasAccess:
held !== undefined && rank.indexOf(held) >= rank.indexOf(level),
};
},
}),
},
}));
vi.mock("../../plugins/permissions.js", () => ({
hasCapability: async () => state.capability,
}));
vi.mock("../../plugins/safe-mode.js", () => ({
safeModeOn: () => state.safeMode,
}));
vi.mock("../../plugins/manifest.js", () => ({
qualifyPermission: (pluginId: string, name: string) =>
name.startsWith(`${pluginId}.`) ? name : `${pluginId}.${name}`,
}));
vi.mock("../../plugins/index.js", () => ({
getPluginRuntime: () => ({ loader: { get: () => state.plugin } }),
}));
vi.mock("../../plugins/host-status-provider.js", () => ({
hostStatus: { get: () => null },
}));
vi.mock("../../plugins/ctx-probe.js", () => ({
loadProbeTargets: async () => [],
}));
vi.mock("../../operations/hosts.js", () => ({
loadOperationHost: async (id: number) => ({
id,
...state.host,
os: null,
settings: {},
}),
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentUserRepository: () => ({
findById: async (id: string) => ({ username: `name-${id}` }),
}),
createCurrentOperationRepository: () => ({
insertRun: async (run: Record<string, unknown>) => {
state.runs.set(run.id as string, { ...run });
},
findRun: async (id: string) => state.runs.get(id) ?? null,
findByIdempotencyKey: async (userId: string, key: string) =>
[...state.runs.values()].find(
(run) => run.userId === userId && run.idempotencyKey === key,
) ?? null,
updateRunFrom: async (
id: string,
from: string,
patch: Record<string, unknown>,
) => {
const run = state.runs.get(id);
if (!run || run.status !== from) return false;
Object.assign(run, patch);
return true;
},
insertApproval: async (row: Record<string, unknown>) => {
state.approvals.set(row.id as string, { ...row });
},
findApproval: async (id: string) => state.approvals.get(id) ?? null,
findApprovalForRun: async (runId: string) =>
[...state.approvals.values()].find((row) => row.runId === runId) ?? null,
decideApproval: async (id: string, status: string) => {
const row = state.approvals.get(id);
if (!row || row.status !== "pending") return false;
row.status = status;
return true;
},
insertEvent: async (event: Record<string, unknown>) => {
state.events.push(event);
},
}),
}));
const { registerAction, clearOperationRegistry } =
await import("../../operations/registry.js");
const { provideApprovalPolicy, clearApprovalPolicies } =
await import("../../operations/approvals.js");
const {
cancelOperationRun,
clearOperationGate,
decideOperationApproval,
drainPlugin,
OperationError,
OperationNeedsInteraction,
runOperation,
} = await import("../../operations/gate.js");
const USER = "user-1";
const APPROVER = "user-2";
const action = (overrides: Partial<OperationAction> = {}): OperationAction => ({
id: "demo.restart",
titleKey: "t",
scope: "host",
risk: "change",
permission: "use",
hostAccess: "edit",
inputs: [
{ key: "name", type: "string", required: true },
{ key: "token", type: "secret" },
],
dryRun: "native",
...overrides,
});
function register(
overrides: Partial<OperationAction> = {},
run = vi.fn(async () => ({ summary: "done", changed: true })),
) {
registerAction("demo", action(overrides), { run });
return run;
}
const manifestForPolicy = {
capabilities: ["operations:approve"],
} as never;
const base = {
actionId: "demo.restart",
hostId: 1,
input: { name: "nginx" },
userId: USER,
origin: { kind: "ui" } as const,
};
async function refused(request: Parameters<typeof runOperation>[0]) {
try {
await runOperation(request);
} catch (error) {
expect(error).toBeInstanceOf(OperationError);
return (error as InstanceType<typeof OperationError>).code;
}
throw new Error("Expected a refusal");
}
beforeEach(() => {
state.permissions = new Set([`${USER}:demo.use`, `${APPROVER}:demo.use`]);
state.hostLevels = new Map([[1, "edit"]]);
state.plugin.state = "active";
state.plugin.source = "bundled";
state.plugin.manifest.version = "1.0.0";
state.capability = true;
state.safeMode = false;
state.host = { name: "web", updatedAt: "2026-01-01", tags: ["prod"] };
state.runs.clear();
state.approvals.clear();
state.audits.length = 0;
state.events.length = 0;
});
afterEach(() => {
clearOperationRegistry();
clearApprovalPolicies();
clearOperationGate();
});
describe("refusals", () => {
it("does not run an unknown action", async () => {
expect(await refused(base)).toBe("NOT_FOUND");
});
it("does not run in safe mode", async () => {
register();
state.safeMode = true;
expect(await refused(base)).toBe("SAFE_MODE");
});
it("does not run for a plugin that stopped", async () => {
register();
state.plugin.state = "disabled";
expect(await refused(base)).toBe("UNAVAILABLE");
});
it("rechecks the grant at use", async () => {
const run = register();
state.capability = false;
expect(await refused(base)).toBe("UNAVAILABLE");
expect(run).not.toHaveBeenCalled();
});
it("needs the role permission", async () => {
const run = register();
state.permissions.clear();
expect(await refused(base)).toBe("FORBIDDEN");
expect(run).not.toHaveBeenCalled();
});
it("needs the declared host level", async () => {
const run = register();
state.hostLevels.set(1, "connect");
expect(await refused(base)).toBe("FORBIDDEN");
expect(run).not.toHaveBeenCalled();
});
it("refuses bad input before anything runs", async () => {
const run = register();
expect(await refused({ ...base, input: { nope: 1 } })).toBe(
"INVALID_INPUT",
);
expect(run).not.toHaveBeenCalled();
});
it("needs a host for a host action", async () => {
register();
expect(await refused({ ...base, hostId: undefined })).toBe("INVALID_INPUT");
});
it("needs a confirm for destructive actions", async () => {
register({ risk: "destructive" });
expect(await refused(base)).toBe("NEEDS_CONFIRM");
expect((await runOperation({ ...base, confirmed: true })).status).toBe(
"success",
);
});
it("stops long chains", async () => {
register();
expect(await refused({ ...base, depth: 4 })).toBe("TOO_DEEP");
});
});
describe("api key scopes", () => {
it("lets a read key run reads only", async () => {
register();
register({ id: "demo.read", risk: "read", hostAccess: "connect" });
expect(await refused({ ...base, apiKeyScopes: ["operations:read"] })).toBe(
"SCOPE",
);
const read = await runOperation({
...base,
actionId: "demo.read",
apiKeyScopes: ["operations:read"],
});
expect(read.status).toBe("success");
});
it("treats no scopes as full access", async () => {
register();
const outcome = await runOperation({ ...base, apiKeyScopes: null });
expect(outcome.status).toBe("success");
});
});
describe("runs", () => {
it("records a run and never stores a secret", async () => {
const run = register();
const outcome = await runOperation({
...base,
input: { name: "nginx", token: "hunter2" },
});
expect(outcome.status).toBe("success");
expect(run.mock.calls[0]?.[0]).toMatchObject({ token: "hunter2" });
const row = state.runs.get(outcome.runId!)!;
expect(row.status).toBe("success");
expect(JSON.stringify(row)).not.toContain("hunter2");
expect(JSON.stringify(state.audits)).not.toContain("hunter2");
expect(JSON.stringify(state.events)).not.toContain("hunter2");
});
it("keeps failures in the run, not thrown", async () => {
register(
{},
vi.fn(async () => {
throw new Error("no such unit");
}),
);
const outcome = await runOperation(base);
expect(outcome).toMatchObject({ status: "failed", error: "no such unit" });
expect(state.runs.get(outcome.runId!)!.status).toBe("failed");
});
it("reports hosts that need a person", async () => {
register(
{},
vi.fn(async () => {
throw new OperationNeedsInteraction("Sign in first");
}),
);
const outcome = await runOperation(base);
expect(outcome.status).toBe("needs_interaction");
});
it("passes dry run to a native handler", async () => {
const run = register();
await runOperation({ ...base, dryRun: true });
expect(run.mock.calls[0]).toBeDefined();
expect(
(run.mock.calls[0] as unknown as [unknown, { dryRun: boolean }])[1]
.dryRun,
).toBe(true);
});
it("never calls run for a describe dry run", async () => {
const run = vi.fn(async () => ({ summary: "x" }));
registerAction("demo", action({ dryRun: "describe" }), {
run,
describe: () => "Would restart nginx",
});
const outcome = await runOperation({ ...base, dryRun: true });
expect(outcome.summary).toBe("Would restart nginx");
expect(run).not.toHaveBeenCalled();
});
it("drops sensitive outputs", async () => {
register(
{ outputs: [{ key: "conf", type: "textarea", sensitive: true }] },
vi.fn(async () => ({
summary: "ok",
data: { conf: "private key" },
})),
);
const outcome = await runOperation(base);
expect(JSON.stringify(outcome)).not.toContain("private key");
expect(JSON.stringify(state.runs.get(outcome.runId!))).not.toContain(
"private key",
);
});
it("writes no row for a background read", async () => {
register({ risk: "read", hostAccess: "connect" });
const outcome = await runOperation({ ...base, internal: true });
expect(outcome.runId).toBeNull();
expect(state.runs.size).toBe(0);
});
it("does not run twice for one idempotency key", async () => {
const run = register();
const first = await runOperation({ ...base, idempotencyKey: "abc" });
const second = await runOperation({ ...base, idempotencyKey: "abc" });
expect(second.runId).toBe(first.runId);
expect(run).toHaveBeenCalledTimes(1);
});
it("emits a run event for a change", async () => {
register();
await runOperation(base);
await vi.waitFor(() =>
expect(state.events[0]).toMatchObject({
eventId: "operation.succeeded",
}),
);
});
it("stops a run on cancel", async () => {
let started = () => {};
const ready = new Promise<void>((resolve) => (started = resolve));
register(
{},
vi.fn(
(_input, context: { signal: AbortSignal }) =>
new Promise((_, reject) => {
started();
context.signal.addEventListener("abort", () =>
reject(new Error("stopped")),
);
}),
) as never,
);
const running = runOperation(base);
await ready;
const row = [...state.runs.values()][0]!;
expect(await cancelOperationRun(row as never)).toBe(true);
expect((await running).status).toBe("failed");
});
});
describe("policy", () => {
it("keeps AI and MCP away from changes with no rule", async () => {
register();
for (const via of ["assistant", "mcp"]) {
expect(
await refused({
...base,
origin: { kind: "plugin", pluginId: "x", via },
}),
).toBe("DENIED");
}
});
it("refuses a plugin with no stated purpose", async () => {
register();
expect(
await refused({ ...base, origin: { kind: "plugin", pluginId: "x" } }),
).toBe("DENIED");
});
it("lets a person's automation go by RBAC", async () => {
register();
const outcome = await runOperation({
...base,
origin: { kind: "plugin", pluginId: "x", via: "automation" },
});
expect(outcome.status).toBe("success");
});
it("lets a request a person approved in a plugin go by RBAC", async () => {
register();
const outcome = await runOperation({
...base,
origin: { kind: "plugin", pluginId: "x", via: "approved" },
});
expect(outcome.status).toBe("success");
});
it("treats a community read as a change", async () => {
state.plugin.source = "user";
state.plugin.signedTier = "community";
register({ risk: "read", hostAccess: "connect" });
expect(
await refused({
...base,
origin: { kind: "plugin", pluginId: "x", via: "assistant" },
}),
).toBe("DENIED");
state.plugin.signedTier = undefined;
});
it("denies when a provider throws", async () => {
register();
provideApprovalPolicy("rules", manifestForPolicy, () => {
throw new Error("boom");
});
expect(await refused(base)).toBe("DENIED");
});
it("takes the strictest answer", async () => {
register();
provideApprovalPolicy("a", manifestForPolicy, () => ({
decision: "allow",
}));
provideApprovalPolicy("b", manifestForPolicy, () => ({
decision: "deny",
reason: "No",
}));
expect(await refused(base)).toBe("DENIED");
});
it("audits a denial", async () => {
register();
provideApprovalPolicy("a", manifestForPolicy, () => ({ decision: "deny" }));
await refused(base);
expect(state.audits[0]).toMatchObject({
action: "operation_denied",
success: false,
});
});
});
describe("approvals", () => {
const needApproval = () =>
provideApprovalPolicy("rules", manifestForPolicy, () => ({
decision: "approve",
}));
async function pending(run = register()) {
needApproval();
const outcome = await runOperation({
...base,
input: { name: "nginx", token: "hunter2" },
});
expect(outcome.status).toBe("pending_approval");
expect(run).not.toHaveBeenCalled();
return outcome;
}
const decide = (approvalId: string, approverId = APPROVER) =>
decideOperationApproval({ approvalId, approverId, approve: true });
it("runs the sealed input once approved, then wipes it", async () => {
const run = register();
const { approvalId, runId } = await pending(run);
const row = state.runs.get(runId!)!;
expect(row.input).not.toContain("hunter2");
expect(row.sealedInput).toBeTruthy();
const outcome = await decide(approvalId!);
expect(outcome.status).toBe("success");
expect(run.mock.calls[0]?.[0]).toMatchObject({ token: "hunter2" });
expect(state.runs.get(runId!)!.sealedInput).toBeNull();
});
it("is one shot", async () => {
const run = register();
const { approvalId } = await pending(run);
await decide(approvalId!);
await expect(decide(approvalId!)).rejects.toMatchObject({
code: "CONFLICT",
});
expect(run).toHaveBeenCalledTimes(1);
});
it("does not let a person approve their own request", async () => {
const { approvalId } = await pending();
await expect(decide(approvalId!, USER)).rejects.toMatchObject({
code: "FORBIDDEN",
});
});
it("lets a person approve what an AI asked for", async () => {
register();
needApproval();
const outcome = await runOperation({
...base,
origin: { kind: "plugin", pluginId: "x", via: "assistant" },
});
const done = await decide(outcome.approvalId!, USER);
expect(done.status).toBe("success");
});
it("does not let someone impersonating approve", async () => {
const { approvalId } = await pending();
await expect(
decideOperationApproval({
approvalId: approvalId!,
approverId: APPROVER,
approve: true,
impersonated: true,
}),
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
it("needs the approver to hold the action's rights", async () => {
const { approvalId } = await pending();
state.permissions.delete(`${APPROVER}:demo.use`);
await expect(decide(approvalId!)).rejects.toMatchObject({
code: "FORBIDDEN",
});
});
it("rechecks the requester when it runs", async () => {
const run = register();
const { approvalId, runId } = await pending(run);
state.permissions.delete(`${USER}:demo.use`);
await expect(decide(approvalId!)).rejects.toMatchObject({
code: "FORBIDDEN",
});
expect(run).not.toHaveBeenCalled();
expect(state.runs.get(runId!)!.status).toBe("rejected");
});
it("goes stale when the host changed", async () => {
const run = register();
const { approvalId } = await pending(run);
state.host.updatedAt = "2026-02-02";
await expect(decide(approvalId!)).rejects.toMatchObject({ code: "STALE" });
expect(run).not.toHaveBeenCalled();
});
it("goes stale when the plugin changed", async () => {
const run = register();
const { approvalId } = await pending(run);
state.plugin.manifest.version = "2.0.0";
await expect(decide(approvalId!)).rejects.toMatchObject({ code: "STALE" });
expect(run).not.toHaveBeenCalled();
});
it("refuses a sealed copy from another run", async () => {
const run = register();
const first = await pending(run);
const second = await runOperation(base);
state.runs.get(second.runId!)!.sealedInput = state.runs.get(
first.runId!,
)!.sealedInput;
await expect(decide(second.approvalId!)).rejects.toMatchObject({
code: "STALE",
});
expect(run).not.toHaveBeenCalled();
});
it("refuses an expired approval", async () => {
const run = register();
const { approvalId } = await pending(run);
state.approvals.get(approvalId!)!.expiresAt = "2020-01-01T00:00:00.000Z";
await expect(decide(approvalId!)).rejects.toMatchObject({ code: "STALE" });
expect(run).not.toHaveBeenCalled();
});
it("wipes the sealed input on reject", async () => {
const { approvalId, runId } = await pending();
const outcome = await decideOperationApproval({
approvalId: approvalId!,
approverId: APPROVER,
approve: false,
});
expect(outcome.status).toBe("rejected");
expect(state.runs.get(runId!)!.sealedInput).toBeNull();
});
});
describe("drain", () => {
it("waits for a handler that is still stopping", async () => {
let finish = () => {};
register(
{},
vi.fn(
() =>
new Promise<never>((_, reject) => {
finish = () => reject(new Error("closed"));
}),
) as never,
);
vi.useFakeTimers();
void runOperation(base).catch(() => {});
await vi.advanceTimersByTimeAsync(0);
let drained = false;
const draining = drainPlugin("demo").then(() => (drained = true));
await vi.advanceTimersByTimeAsync(5_000);
expect(drained).toBe(false);
finish();
await vi.advanceTimersByTimeAsync(0);
await draining;
vi.useRealTimers();
expect(drained).toBe(true);
});
it("stops running work and marks it interrupted", async () => {
register({}, vi.fn(() => new Promise(() => {})) as never);
vi.useFakeTimers();
const running = runOperation(base);
await vi.advanceTimersByTimeAsync(0);
const draining = drainPlugin("demo");
await vi.advanceTimersByTimeAsync(11_000);
await draining;
await running;
vi.useRealTimers();
expect([...state.runs.values()][0]!.status).toBe("interrupted");
});
it("refuses new runs while draining", async () => {
register({}, vi.fn(() => new Promise(() => {})) as never);
vi.useFakeTimers();
void runOperation(base);
await vi.advanceTimersByTimeAsync(0);
const draining = drainPlugin("demo");
await expect(runOperation(base)).rejects.toMatchObject({
code: "UNAVAILABLE",
});
await vi.advanceTimersByTimeAsync(11_000);
await draining;
vi.useRealTimers();
});
});
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import { keyScopeAllows, parseKeyScopes } from "../../operations/key-scopes.js";
const READ = ["operations:read"];
const RUN = ["operations:run"];
describe("keyScopeAllows", () => {
it("leaves a key with no scopes alone", () => {
expect(keyScopeAllows(null, "POST", "/users/ws-ticket/api-key")).toBe(true);
expect(keyScopeAllows(null, "DELETE", "/host/1")).toBe(true);
});
it("keeps a scoped key off every other route", () => {
for (const path of [
"/users/ws-ticket/api-key",
"/host/1",
"/plugin-api/docker/1/containers",
"/credentials",
]) {
expect(keyScopeAllows(RUN, "GET", path)).toBe(false);
}
});
it("lets a read key read and start runs the gate then checks", () => {
expect(keyScopeAllows(READ, "GET", "/operations/catalog?lang=en")).toBe(
true,
);
expect(keyScopeAllows(READ, "POST", "/operations/docker.list/run")).toBe(
true,
);
expect(keyScopeAllows(READ, "POST", "/operations/approvals/abc")).toBe(
false,
);
expect(keyScopeAllows(READ, "POST", "/operations/runs/abc/cancel")).toBe(
false,
);
});
it("reaches a plugin's MCP route only", () => {
expect(keyScopeAllows(READ, "POST", "/plugin-api/some-plugin/mcp")).toBe(
true,
);
expect(
keyScopeAllows(READ, "POST", "/plugin-api/some-plugin/mcp-admin"),
).toBe(false);
});
it("does not match lookalike paths", () => {
expect(keyScopeAllows(RUN, "GET", "/operationsx/catalog")).toBe(false);
});
it("grants nothing with an empty list", () => {
expect(keyScopeAllows([], "GET", "/operations/catalog")).toBe(false);
});
});
describe("parseKeyScopes", () => {
it("reads a list", () => {
expect(parseKeyScopes('["operations:read"]')).toEqual(READ);
});
it("keeps old keys at full access", () => {
expect(parseKeyScopes(null)).toBeNull();
});
it("fails closed on junk", () => {
expect(parseKeyScopes("{nope")).toEqual([]);
expect(parseKeyScopes('"x"')).toEqual([]);
});
});
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
missingShippedPluginIds,
newShippedPluginIds,
startablePluginIds,
} from "../../plugins/boot-state.js";
@@ -38,3 +39,20 @@ describe("missingShippedPluginIds", () => {
expect(missingShippedPluginIds(records, present, new Set())).toEqual([]);
});
});
describe("newShippedPluginIds", () => {
it("lists shipped plugins the install never had and that are not on disk", () => {
expect(
newShippedPluginIds(
["new", "known", "here", "removed", "another"],
[{ id: "known" }],
new Set(["here"]),
new Set(["removed"]),
),
).toEqual(["another", "new"]);
});
it("finds nothing for an empty catalog", () => {
expect(newShippedPluginIds([], [], new Set(), new Set())).toEqual([]);
});
});
@@ -8,14 +8,18 @@ import {
isDefaultLookPlugin,
onboardingDefaults,
resetBundledIndexCache,
shippedCatalog,
} from "../../plugins/bundled-index.js";
let dir: string;
function writeIndex(plugins: Record<string, unknown>) {
function writeIndex(
plugins: Record<string, unknown>,
extra: Record<string, unknown> = {},
) {
fs.writeFileSync(
path.join(dir, "bundled-index.json"),
JSON.stringify({ version: 1, plugins }),
JSON.stringify({ version: 1, plugins, ...extra }),
);
resetBundledIndexCache();
}
@@ -89,9 +93,10 @@ describe("initialPluginState", () => {
});
});
it("enables every bundled plugin on an existing install", () => {
expect(initialPluginState("other", "bundled", false)).toBe("enabled");
expect(initialPluginState("ask", "bundled", false)).toBe("enabled");
it("enables only recommended plugins an existing install sees for the first time", () => {
expect(initialPluginState("kept", "bundled", false)).toBe("enabled");
expect(initialPluginState("other", "bundled", false)).toBe("disabled");
expect(initialPluginState("ask", "bundled", false)).toBe("disabled");
});
it("enables only recommended, non-consent plugins on a fresh install", () => {
@@ -106,6 +111,40 @@ describe("initialPluginState", () => {
});
});
describe("shippedCatalog", () => {
it("is empty for an index without a catalog", () => {
writeIndex({});
expect(shippedCatalog().size).toBe(0);
});
it("keeps the pins of the image's tier and smaller ones", () => {
writeIndex(
{},
{
profile: "standard",
catalog: {
look: { tier: "minimal", version: "1.0.0" },
ops: { tier: "standard", version: "1.0.0" },
big: { tier: "full", version: "2.0.0" },
odd: { tier: "huge" },
},
},
);
expect([...shippedCatalog().keys()].sort()).toEqual(["look", "ops"]);
});
it("follows a minimal image", () => {
writeIndex(
{},
{
profile: "minimal",
catalog: { look: { tier: "minimal" }, ops: { tier: "standard" } },
},
);
expect([...shippedCatalog().keys()]).toEqual(["look"]);
});
});
describe("default look", () => {
it("lists the plugins flagged as the default look", () => {
writeIndex({
@@ -186,6 +186,21 @@ describe("ctx.desktop.local", () => {
expect(fs.readdirSync(dir)).toEqual([]);
});
it("leaves no partial file when aborted before the file has opened", async () => {
grants.set("demo", ["desktop:local"]);
const ctx = contextFor("demo", ["desktop:local"]);
for (let i = 0; i < 20; i++) {
const download = await ctx.desktop.local.fs.openDownload({
rootPath: dir,
destPath: path.join(dir, `early-${i}.txt`),
transferId: `early-${i}`,
});
await download.abort();
}
await new Promise((resolve) => setTimeout(resolve, 20));
expect(fs.readdirSync(dir)).toEqual([]);
});
it("sends trash and open through Electron main and audits each call", async () => {
grants.set("demo", ["desktop:local"]);
const ctx = contextFor("demo", ["desktop:local"]);
@@ -0,0 +1,210 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginManifest } from "../../plugins/manifest.js";
const gate = vi.hoisted(() => ({
runOperation: vi.fn(async () => ({ runId: "r", status: "success" })),
drainPlugin: vi.fn(async () => {}),
}));
vi.mock("../../utils/logger.js", () => ({
pluginLogger: {
warn: vi.fn(),
info: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
vi.mock("../../operations/gate.js", () => ({
...gate,
OperationError: class extends Error {
constructor(_code: string, message: string) {
super(message);
}
},
}));
vi.mock("../../operations/catalog.js", () => ({
buildCatalog: vi.fn(async () => ({ actions: [] })),
}));
vi.mock("../../operations/events.js", () => ({
emitOperationEvent: vi.fn(async () => {}),
subscribeOperationEvents: vi.fn(() => () => {}),
}));
vi.mock("../../plugins/permissions.js", () => ({
assertCapability: vi.fn(
async (_id: string, capability: string, declared: string[]) => {
if (!declared.includes(capability)) throw new Error("refused");
},
),
capabilityRefused: (pluginId: string, capability: string) =>
new Error(`${pluginId} lacks ${capability}`),
}));
const { createPluginOperations } =
await import("../../plugins/ctx-operations.js");
const { DisposableBag } = await import("../../plugins/disposables.js");
const { clearOperationRegistry, getAction } =
await import("../../operations/registry.js");
const { runAsActor } = await import("../../plugins/actor.js");
const action = {
id: "demo.restart",
titleKey: "t",
scope: "host",
risk: "change",
permission: "use",
hostAccess: "edit",
};
function build(capabilities: string[], invokes?: string[]) {
const bag = new DisposableBag("demo");
const manifest = {
id: "demo",
capabilities,
contributes: { operations: { actions: [action], invokes } },
} as unknown as PluginManifest;
return { bag, operations: createPluginOperations({ manifest, bag }) };
}
const handler = { run: async () => ({ summary: "ok" }) };
beforeEach(() => {
clearOperationRegistry();
gate.runOperation.mockClear();
gate.drainPlugin.mockClear();
});
describe("register", () => {
it("needs the capability", () => {
const { operations } = build([]);
expect(() => operations.register("demo.restart", handler)).toThrow(
"operations:provide",
);
});
it("refuses an undeclared action", () => {
const { operations } = build(["operations:provide"]);
expect(() => operations.register("demo.other", handler)).toThrow(
"not declared",
);
});
it("drains, then revokes, when the plugin stops", async () => {
const { bag, operations } = build(["operations:provide"]);
operations.register("demo.restart", handler);
expect(getAction("demo.restart")).toBeDefined();
await bag.disposeAll();
expect(gate.drainPlugin).toHaveBeenCalledWith("demo");
expect(getAction("demo.restart")).toBeUndefined();
});
it("keeps the new handler when an old disposer runs after a restart", async () => {
const first = build(["operations:provide"]);
first.operations.register("demo.restart", handler);
const second = build(["operations:provide"]);
const fresh = { run: async () => ({ summary: "new" }) };
second.operations.register("demo.restart", fresh);
await first.bag.disposeAll();
expect(getAction("demo.restart")?.handler).toBe(fresh);
});
});
describe("invoke", () => {
const asUser = <T>(fn: () => Promise<T>) =>
runAsActor("user", "request", fn, undefined, {
origin: { kind: "ui" },
apiKeyScopes: ["operations:read"],
});
it("needs the capability", async () => {
const { operations } = build([], ["*"]);
await expect(
asUser(() => operations.invoke("docker.restart")),
).rejects.toThrow("refused");
});
it("only reaches what the manifest lists", async () => {
const { operations } = build(["operations:invoke"], ["docker.*"]);
await expect(
asUser(() => operations.invoke("firewall.allow")),
).rejects.toThrow("did not declare");
expect(gate.runOperation).not.toHaveBeenCalled();
});
it("names the plugin as origin and keeps the key's limits", async () => {
const { operations } = build(["operations:invoke"], ["docker.*"]);
await asUser(() =>
operations.invoke("docker.restart", { via: "automation" }),
);
expect(gate.runOperation).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user",
origin: { kind: "plugin", pluginId: "demo", via: "automation" },
apiKeyScopes: ["operations:read"],
}),
);
});
it("does not let a plugin pick its own origin", async () => {
const { operations } = build(["operations:invoke"], ["*"]);
await asUser(() =>
operations.invoke("docker.restart", {
origin: { kind: "ui" },
} as never),
);
expect(gate.runOperation).toHaveBeenCalledWith(
expect.objectContaining({
origin: expect.objectContaining({ kind: "plugin", pluginId: "demo" }),
}),
);
});
it("needs an acting user", async () => {
const { operations } = build(["operations:invoke"], ["*"]);
await expect(operations.invoke("docker.restart")).rejects.toThrow(
"acting user",
);
});
});
describe("run", () => {
it("keeps the caller as origin for a plugin's own action", async () => {
const { operations } = build(["operations:provide"]);
await runAsActor(
"user",
"request",
() => operations.run("demo.restart", { hostId: 1 }),
undefined,
{ origin: { kind: "ui" } },
);
expect(gate.runOperation).toHaveBeenCalledWith(
expect.objectContaining({ origin: { kind: "ui" }, hostId: 1 }),
);
});
it("refuses another plugin's action", async () => {
const { operations } = build(["operations:provide"]);
await expect(
runAsActor("user", "request", () => operations.run("docker.restart")),
).rejects.toThrow("not declared");
});
});
describe("emit", () => {
it("refuses an undeclared event", async () => {
const { operations } = build(["operations:provide"]);
await expect(operations.emit("demo.nothing")).rejects.toThrow(
"not declared",
);
});
});
describe("approvals", () => {
it("needs the capability", () => {
const { operations } = build([]);
expect(() => operations.approvals.provide(() => null)).toThrow(
"operations:approve",
);
});
});
@@ -0,0 +1,297 @@
import { describe, expect, it } from "vitest";
import { parseManifest } from "@termix-ssh/plugin-sdk/manifest";
import {
invokeAllowed,
matchesWhen,
onSurface,
parseOperationInput,
redactFields,
toJsonSchema,
type OperationAction,
type OperationField,
} from "@termix-ssh/plugin-sdk/operations";
const action = (overrides: Partial<OperationAction> = {}): OperationAction => ({
id: "demo.allow-port",
titleKey: "ops.allow",
scope: "host",
risk: "change",
permission: "use",
hostAccess: "edit",
...overrides,
});
function manifest(operations: unknown, capabilities = ["operations:provide"]) {
return {
id: "demo",
name: "Demo",
version: "1.0.0",
description: "A demo.",
author: { name: "test" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=26.11.1-0", api: "^1.13" },
capabilities,
contributes: {
permissions: [
{ name: "use", titleKey: "perm.use", descriptionKey: "perm.useDesc" },
],
operations,
},
};
}
function errorsFor(operations: unknown, capabilities?: string[]): string[] {
return parseManifest(manifest(operations, capabilities)).errors ?? [];
}
describe("contributes.operations", () => {
it("accepts a full declaration", () => {
const errors = errorsFor({
actions: [
action({
check: "demo.port-allowed",
inputs: [
{ key: "port", type: "number", required: true, min: 1, max: 65535 },
{ key: "name", type: "resource", resource: "demo.rule" },
],
}),
action({
id: "demo.port-allowed",
risk: "read",
hostAccess: "connect",
}),
action({
id: "demo.list-rules",
risk: "read",
hostAccess: "connect",
}),
],
events: [{ id: "demo.turned-off", titleKey: "e", scope: "host" }],
resources: [
{
id: "demo.rule",
titleKey: "r",
list: "demo.list-rules",
key: "id",
name: "name",
},
],
signals: [
{
id: "demo.rules",
titleKey: "s",
read: "demo.port-allowed",
scope: "host",
kind: "count",
fix: "demo.allow-port",
},
],
});
expect(errors).toEqual([]);
});
it("needs the capability", () => {
expect(errorsFor({ actions: [action()] }, [])).toContainEqual(
expect.stringContaining("operations:provide"),
);
});
it("refuses an id outside the plugin", () => {
expect(
errorsFor({ actions: [action({ id: "docker.stop" })] }),
).toContainEqual(expect.stringContaining('must start with "demo."'));
});
it("refuses a destructive action a connect level user could run", () => {
expect(
errorsFor({
actions: [action({ risk: "destructive", hostAccess: "connect" })],
}),
).toContainEqual(expect.stringContaining("edit or manage"));
});
it("lets a change declare its own level", () => {
expect(errorsFor({ actions: [action({ hostAccess: "connect" })] })).toEqual(
[],
);
});
it("refuses an undeclared permission", () => {
expect(
errorsFor({ actions: [action({ permission: "nope" })] }),
).toContainEqual(expect.stringContaining("is not declared"));
});
it("refuses a check that is not a read", () => {
expect(
errorsFor({
actions: [
action({ check: "demo.other" }),
action({ id: "demo.other" }),
],
}),
).toContainEqual(expect.stringContaining("must be a read action"));
});
it("refuses references that go nowhere", () => {
const errors = errorsFor({
actions: [action({ undo: "demo.gone" })],
resources: [
{
id: "demo.rule",
titleKey: "r",
list: "demo.gone",
key: "id",
name: "n",
},
],
});
expect(errors.length).toBeGreaterThanOrEqual(2);
});
it("refuses unknown fields", () => {
expect(
errorsFor({ actions: [{ ...action(), extra: true }] }),
).toContainEqual(expect.stringContaining('Unknown field "extra"'));
});
it("refuses a default on a secret", () => {
expect(
errorsFor({
actions: [
action({
inputs: [{ key: "token", type: "secret", default: "abc" }],
}),
],
}),
).toContainEqual(expect.stringContaining("not allowed on a secret"));
});
it("needs operations:invoke for invokes", () => {
expect(errorsFor({ invokes: ["docker.*"] }, [])).toContainEqual(
expect.stringContaining("operations:invoke"),
);
});
});
describe("parseOperationInput", () => {
const fields: OperationField[] = [
{ key: "port", type: "number", required: true, min: 1, max: 65535 },
{
key: "proto",
type: "select",
default: "tcp",
options: [
{ value: "tcp", labelKey: "t" },
{ value: "udp", labelKey: "u" },
],
},
{ key: "name", type: "string", pattern: "[a-z]+" },
{ key: "data", type: "json" },
];
it("coerces strings and fills defaults", () => {
expect(parseOperationInput(fields, { port: "443" })).toEqual({
values: { port: 443, proto: "tcp" },
errors: [],
});
});
it("refuses keys no field declares", () => {
expect(
parseOperationInput(fields, { port: 1, sudo: true }).errors,
).toContainEqual(expect.stringContaining('"sudo"'));
});
it("needs required fields", () => {
expect(parseOperationInput(fields, {}).errors).toEqual([
'"port" is required',
]);
});
it("checks bounds, options and the whole pattern", () => {
expect(parseOperationInput(fields, { port: 0 }).errors).toHaveLength(1);
expect(
parseOperationInput(fields, { port: 1, proto: "icmp" }).errors,
).toHaveLength(1);
expect(
parseOperationInput(fields, { port: 1, name: "abc; rm -rf" }).errors,
).toEqual(['"name" has the wrong format']);
});
it("parses json strings", () => {
expect(
parseOperationInput(fields, { port: 1, data: '{"a":1}' }).values.data,
).toEqual({ a: 1 });
});
it("refuses a redacted echo for a secret", () => {
const secret: OperationField[] = [{ key: "token", type: "secret" }];
expect(
parseOperationInput(secret, { token: { set: true } }).errors,
).toHaveLength(1);
});
it("refuses input that is not an object", () => {
expect(parseOperationInput(fields, "nope").errors).toHaveLength(1);
});
});
describe("helpers", () => {
it("redacts secrets and sensitive outputs", () => {
expect(
redactFields(
[
{ key: "token", type: "secret" },
{ key: "conf", type: "textarea", sensitive: true },
{ key: "port", type: "number" },
],
{ token: "abc", conf: "key", port: 1 },
),
).toEqual({ token: "[redacted]", conf: "[redacted]", port: 1 });
});
it("builds JSON Schema", () => {
const schema = toJsonSchema([
{ key: "port", type: "number", required: true, min: 1 },
{ key: "token", type: "secret" },
]) as { properties: Record<string, unknown>; required: string[] };
expect(schema.required).toEqual(["port"]);
expect(schema.properties.port).toEqual({ type: "number", minimum: 1 });
expect(schema.properties.token).toEqual({
type: "string",
writeOnly: true,
});
});
it("keeps AI and MCP opt in, the rest opt out", () => {
expect(onSurface({}, "assistant")).toBe(false);
expect(onSurface({}, "mcp")).toBe(false);
expect(onSurface({}, "cli")).toBe(true);
expect(onSurface({ surfaces: { assistant: true } }, "assistant")).toBe(
true,
);
expect(onSurface({ surfaces: { cli: false } }, "cli")).toBe(false);
});
it("matches invokes patterns", () => {
expect(invokeAllowed(["docker.*"], "docker.stop")).toBe(true);
expect(invokeAllowed(["docker.*"], "dockerx.stop")).toBe(false);
expect(invokeAllowed(["docker.stop"], "docker.start")).toBe(false);
expect(invokeAllowed(["*"], "anything.at-all")).toBe(true);
expect(invokeAllowed(undefined, "docker.stop")).toBe(false);
});
it("tests when against a host", () => {
const when = { settings: { enableDocker: true }, os: ["linux"] };
expect(
matchesWhen(when, { settings: { enableDocker: true }, os: "Linux" }),
).toBe(true);
expect(matchesWhen(when, { settings: {} })).toBe(false);
expect(
matchesWhen(when, { settings: { enableDocker: true }, os: "windows" }),
).toBe(false);
expect(matchesWhen(when, { settings: { enableDocker: true } })).toBe(true);
});
});
@@ -231,6 +231,16 @@ describe("updating a bundled plugin", () => {
expect(loader.get("bundled-one")?.manifest.version).toBe("1.0.0");
});
it("removes an update the bundled copy caught up with", async () => {
const { userDir } = setup();
const file = await writeArtifact(userDir, "bundled-one", "valid", "0.9.0");
const loader = new PluginLoader();
await loader.loadAll();
expect(fs.existsSync(file)).toBe(false);
expect(fs.existsSync(`${file}.sig`)).toBe(false);
expect(loader.skipped()).toEqual([]);
});
it("refuses a downgrade after the bundled copy was unloaded", async () => {
const { userDir } = setup();
const loader = new PluginLoader();
+12
View File
@@ -17,6 +17,7 @@ import { inArray, isNotNull } from "drizzle-orm";
import type { ExternalSessionRef } from "../auth/types.js";
import { listSecondFactors } from "../auth/registry.js";
import { SECOND_FACTOR_SETUP } from "../auth/policy.js";
import { keyScopeAllows, parseKeyScopes } from "../operations/key-scopes.js";
import type { DeviceType } from "./user-agent-parser.js";
import { getDb } from "../database/db/index.js";
import { sessions } from "../database/db/schema.js";
@@ -69,6 +70,7 @@ interface AuthenticatedRequest extends Request {
userId?: string;
sessionId?: string;
apiKeyId?: string;
apiKeyScopes?: string[] | null;
pendingTOTP?: boolean;
dataKey?: Buffer;
actingAdminUserId?: string;
@@ -879,8 +881,18 @@ class AuthManager {
});
});
const scopes = parseKeyScopes(matchedKey.scopes);
if (!keyScopeAllows(scopes, req.method, req.originalUrl ?? "")) {
res.status(403).json({
error: "This API key may not use this route",
code: "API_KEY_SCOPE",
});
return;
}
req.userId = matchedKey.userId;
req.apiKeyId = matchedKey.id;
req.apiKeyScopes = scopes;
next();
} catch (error) {
databaseLogger.error("API key authentication failed", error, {
+1
View File
@@ -54,6 +54,7 @@ export const PERMISSION_CATALOG: PermissionCatalogEntry[] = [
"admin.roles.manage",
"admin.settings.manage",
"admin.sessions.manage",
"admin.operations.view",
"admin.plugins.manage",
],
},
+2 -1
View File
@@ -34,7 +34,7 @@ export type HostTrayTrigger = "always" | "hover" | "click" | "actionsOnly";
/** What clicking a host does when it already has an open tab. */
export type HostClickBehavior =
"newTab" | "focusExisting" | "focusExistingDoubleClickNew";
"newTab" | "focusExisting" | "focusExistingDoubleClickNew" | "hostPage";
export interface HostSidebarFilterState {
status: ("online" | "offline" | "pinned")[];
@@ -136,6 +136,7 @@ const HOST_CLICK_BEHAVIORS: HostClickBehavior[] = [
"newTab",
"focusExisting",
"focusExistingDoubleClickNew",
"hostPage",
];
const STATUS_COLOR_SCHEMES: StatusColorScheme[] = ["accent", "status"];
const FILTER_STATUS: HostSidebarFilterState["status"] = [
+4
View File
@@ -87,6 +87,8 @@ export interface ApiKey {
expiresAt: string | null;
lastUsedAt: string | null;
isActive: boolean;
/** Null for full access. */
scopes?: string[] | null;
}
export interface CreatedApiKey extends ApiKey {
@@ -97,12 +99,14 @@ export async function createApiKey(
name: string,
userId: string,
expiresAt?: string,
scopes?: string[] | null,
): Promise<CreatedApiKey> {
try {
const response = await authApi.post("/users/api-keys", {
name,
userId,
expiresAt: expiresAt ?? null,
scopes: scopes?.length ? scopes : null,
});
return response.data;
} catch (error) {
+1 -1
View File
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative w-full border border-border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
"relative w-full border border-border px-4 py-3 text-sm not-has-[>svg]:block grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
+6 -1
View File
@@ -1,18 +1,23 @@
import * as React from "react";
import { cn } from "@/lib/utils";
import { useInSeamlessTile } from "@/components/tile-grid/context";
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
// Inside an edge to edge tile layout the layout draws the lines.
const tiled = useInSeamlessTile();
return (
<div
data-slot="card"
data-tile-card=""
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-none bg-card py-4 text-xs/relaxed text-card-foreground border border-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-2 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-none *:[img:last-child]:rounded-none",
"group/card flex flex-col gap-4 overflow-hidden rounded-none py-4 text-xs/relaxed text-card-foreground has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-2 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-none *:[img:last-child]:rounded-none",
!tiled && "border border-border",
className,
)}
{...props}
+26 -47
View File
@@ -1,6 +1,7 @@
import { useRef, useState, useEffect, useCallback } from "react";
import { cn } from "@/lib/utils";
import { parseServerTime } from "@/lib/server-time";
import { formatTimestamp, pickXTicks } from "./x-ticks";
export interface LineChartSeries {
key: string;
@@ -19,23 +20,11 @@ interface LineChartProps {
className?: string;
}
const Y_LABEL_W = 44;
const Y_LABEL_MIN = 44;
const X_LABEL_H = 20;
const TOP_PAD = 6;
const TICK_COUNT = 5;
function formatTimestamp(ts: string, rangeMs: number): string {
const d = parseServerTime(ts);
if (rangeMs <= 25 * 60 * 60 * 1000) {
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
return (
d.toLocaleDateString([], { month: "short", day: "numeric" }) +
" " +
d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
);
}
function valToY(v: number, drawH: number, domain: [number, number]): number {
const [lo, hi] = domain;
const range = hi === lo ? 1 : hi - lo;
@@ -95,7 +84,18 @@ export function LineChart({
return () => ro.disconnect();
}, []);
const chartW = Math.max(0, width - Y_LABEL_W);
const yTicks = (() => {
const [lo, hi] = domain;
const step = (hi - lo) / (TICK_COUNT - 1);
return Array.from({ length: TICK_COUNT }, (_, i) => lo + i * step);
})();
// Wide enough for the longest tick label, like "1.5 KiB/s".
const yLabelW = Math.max(
Y_LABEL_MIN,
Math.max(...yTicks.map((tick) => yFormatter(tick).length)) * 6 + 12,
);
const chartW = Math.max(0, width - yLabelW);
const drawH = height - TOP_PAD;
const svgH = height + X_LABEL_H;
@@ -105,36 +105,19 @@ export function LineChart({
parseServerTime(timestamps[0]).getTime()
: 0;
const yTicks = (() => {
const [lo, hi] = domain;
const step = (hi - lo) / (TICK_COUNT - 1);
return Array.from({ length: TICK_COUNT }, (_, i) => lo + i * step);
})();
const xTickIndices: number[] = [];
if (timestamps.length > 1 && chartW > 0) {
const n = timestamps.length;
// Long labels ("Jun 28 14:30") are ~80px; short ("14:30") are ~40px.
// Use at most 3 ticks for long labels, 5 for short, so they never crowd.
const longLabel = rangeMs > 25 * 60 * 60 * 1000;
const maxTicks = longLabel ? 3 : 5;
const count = Math.min(maxTicks, n);
for (let k = 0; k < count; k++) {
xTickIndices.push(Math.round((k / (count - 1)) * (n - 1)));
}
}
const xTickIndices = pickXTicks(timestamps, rangeMs, chartW);
const handleMouseMove = useCallback(
(e: React.MouseEvent<SVGElement>) => {
if (timestamps.length < 2 || chartW <= 0) return;
const rect = (e.currentTarget as SVGElement).getBoundingClientRect();
const relX = e.clientX - rect.left - Y_LABEL_W;
const relX = e.clientX - rect.left - yLabelW;
const index = Math.round(
Math.max(0, Math.min(1, relX / chartW)) * (timestamps.length - 1),
);
setTooltip({ x: e.clientX - rect.left, index });
},
[timestamps.length, chartW],
[timestamps.length, chartW, yLabelW],
);
return (
@@ -156,7 +139,7 @@ export function LineChart({
return (
<g key={ti}>
<line
x1={Y_LABEL_W}
x1={yLabelW}
y1={y}
x2={width}
y2={y}
@@ -165,7 +148,7 @@ export function LineChart({
strokeOpacity={0.6}
/>
<text
x={Y_LABEL_W - 6}
x={yLabelW - 6}
y={y + 4}
fontSize={10}
textAnchor="end"
@@ -179,15 +162,15 @@ export function LineChart({
{/* Left + bottom border */}
<line
x1={Y_LABEL_W}
x1={yLabelW}
y1={TOP_PAD}
x2={Y_LABEL_W}
x2={yLabelW}
y2={TOP_PAD + drawH}
stroke="var(--border)"
strokeWidth={1}
/>
<line
x1={Y_LABEL_W}
x1={yLabelW}
y1={TOP_PAD + drawH}
x2={width}
y2={TOP_PAD + drawH}
@@ -209,7 +192,7 @@ export function LineChart({
strokeWidth={1.5}
strokeLinejoin="round"
strokeLinecap="round"
transform={`translate(${Y_LABEL_W},0)`}
transform={`translate(${yLabelW},0)`}
/>
);
})}
@@ -217,13 +200,9 @@ export function LineChart({
{/* Crosshair */}
{tooltip !== null && timestamps.length > 1 && chartW > 0 && (
<line
x1={
Y_LABEL_W + (tooltip.index / (timestamps.length - 1)) * chartW
}
x1={yLabelW + (tooltip.index / (timestamps.length - 1)) * chartW}
y1={TOP_PAD}
x2={
Y_LABEL_W + (tooltip.index / (timestamps.length - 1)) * chartW
}
x2={yLabelW + (tooltip.index / (timestamps.length - 1)) * chartW}
y2={TOP_PAD + drawH}
stroke="var(--muted-foreground)"
strokeOpacity={0.4}
@@ -237,7 +216,7 @@ export function LineChart({
const isFirst = li === 0;
const isLast = li === xTickIndices.length - 1;
const x =
Y_LABEL_W + (i / Math.max(1, timestamps.length - 1)) * chartW;
yLabelW + (i / Math.max(1, timestamps.length - 1)) * chartW;
return (
<text
key={i}
+74
View File
@@ -0,0 +1,74 @@
import { parseServerTime } from "@/lib/server-time";
export function formatTimestamp(ts: string, rangeMs: number): string {
const d = parseServerTime(ts);
// A live chart covers a minute or two, so minutes alone would repeat.
if (rangeMs <= 10 * 60 * 1000) {
return d.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
if (rangeMs <= 25 * 60 * 60 * 1000) {
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
return (
d.toLocaleDateString([], { month: "short", day: "numeric" }) +
" " +
d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
);
}
// Rough width of a 10px label, used to keep x labels apart.
const CHAR_W = 6.2;
const LABEL_GAP = 12;
/**
* Picks which timestamps get an x label. The count follows the chart width and
* label length, and a label that would touch its neighbour is dropped.
*/
export function pickXTicks(
timestamps: string[],
rangeMs: number,
chartW: number,
): number[] {
const n = timestamps.length;
if (n < 2 || chartW <= 0) return [];
const labelW = (i: number) =>
formatTimestamp(timestamps[i], rangeMs).length * CHAR_W;
const widest = Math.max(labelW(0), labelW(n - 1));
const fit = Math.floor((chartW + LABEL_GAP) / (widest + LABEL_GAP));
const count = Math.max(1, Math.min(5, n, fit));
const candidates =
count === 1
? [n - 1]
: Array.from({ length: count }, (_, k) =>
Math.round((k / (count - 1)) * (n - 1)),
);
// Box of a label as drawn: first is start anchored, last end, others middle.
const box = (i: number): [number, number] => {
const x = (i / (n - 1)) * chartW;
const w = labelW(i);
if (i === 0) return [x, x + w];
if (i === n - 1) return [x - w, x];
return [x - w / 2, x + w / 2];
};
const clashes = (a: number | undefined, b: number) =>
a !== undefined && box(a)[1] + LABEL_GAP > box(b)[0];
const placed: number[] = [];
for (const i of [...new Set(candidates)]) {
// The newest time stays visible on a live chart, older labels make room.
if (i === n - 1) {
while (placed.length && clashes(placed[placed.length - 1], i)) {
placed.pop();
}
} else if (clashes(placed[placed.length - 1], i)) {
continue;
}
placed.push(i);
}
return placed;
}
+57
View File
@@ -0,0 +1,57 @@
import type React from "react";
import { cn } from "@/lib/utils";
/**
* A plain table in the tile look: sticky header on the page background, a
* line under each row, no outer box. For wide data with real columns; use
* DataView when the same list also has a card view.
*/
export function DataTable({
className,
containerClassName,
children,
...props
}: React.ComponentProps<"table"> & {
/** Classes for the scroll box around the table, like "flex-1". */
containerClassName?: string;
}) {
return (
<div
className={cn(
"min-h-0 min-w-0 overflow-auto thin-scrollbar",
containerClassName,
)}
>
<table
className={cn(
"w-full border-collapse text-xs [&_td]:px-2 [&_td]:py-1.5 [&_th]:px-2 [&_th]:py-2 [&_th]:text-left [&_th]:text-[10px] [&_th]:font-semibold [&_th]:uppercase [&_th]:tracking-widest [&_th]:text-muted-foreground [&_thead]:sticky [&_thead]:top-0 [&_thead]:z-10 [&_thead]:bg-background [&_thead_tr]:border-b [&_tr]:border-b [&_tr]:border-border",
className,
)}
{...props}
>
{children}
</table>
</div>
);
}
/** A body row that highlights on hover, and when active. */
export function DataTableRow({
active = false,
className,
onClick,
...props
}: React.ComponentProps<"tr"> & { active?: boolean }) {
return (
<tr
onClick={onClick}
className={cn(
"transition-colors",
onClick && "cursor-pointer",
active ? "bg-accent-brand/10" : "hover:bg-muted/40",
className,
)}
{...props}
/>
);
}
+71 -69
View File
@@ -1,8 +1,8 @@
import type React from "react";
import { cn } from "@/lib/utils";
import { TileWall } from "@/components/tile-grid/TileWall";
import { BottomEdge } from "@/components/tile-grid/edge";
import {
PANEL,
gridSizeStyle,
type GridSize,
type PanelDensity,
type PanelViewMode,
@@ -91,7 +91,10 @@ function columnVisibility(hideBelow?: "md" | "lg"): string {
return "";
}
/** One collection as a card grid or a dense row table. */
/**
* One collection as a card grid or a dense row table, edge to edge with
* shared lines like the rest of the tile look.
*/
export function DataView<T>({
items,
view,
@@ -132,21 +135,18 @@ export function DataView<T>({
if (view === "grid") {
return (
<div
className={cn(
"grid",
PANEL.gap,
!size && gridClass(columns),
className,
)}
style={size ? gridSizeStyle(size, minCardWidth) : undefined}
<TileWall
size={size}
minWidth={minCardWidth ?? 320}
gridClassName={size ? undefined : gridClass(columns)}
className={className}
>
{items.map((item) => (
<div key={getKey(item)} className="flex flex-col *:flex-1">
{renderCard(item)}
</div>
))}
</div>
</TileWall>
);
}
@@ -154,68 +154,70 @@ export function DataView<T>({
const rowHeight = density === "compact" ? "min-h-7" : "min-h-9";
return (
<div
className={cn("@container border border-border bg-card", className)}
style={
{
"--data-view-cols": templates.base,
"--data-view-cols-md": templates.md,
"--data-view-cols-lg": templates.lg,
} as React.CSSProperties
}
role="table"
>
<BottomEdge>
<div
className={cn(
LIST_GRID_CLASS,
"sticky top-0 z-10 items-center border-b border-border bg-card text-[10px] font-semibold uppercase tracking-widest text-muted-foreground",
)}
className={cn("@container", className)}
style={
{
"--data-view-cols": templates.base,
"--data-view-cols-md": templates.md,
"--data-view-cols-lg": templates.lg,
} as React.CSSProperties
}
role="table"
>
{listColumns.map((column) => (
<span
key={column.key}
className={cn(
"truncate py-2",
column.align === "end" && "text-right",
columnVisibility(column.hideBelow),
)}
>
{column.header}
</span>
))}
</div>
<div>
{items.map((item) => {
const active = isRowActive?.(item) ?? false;
return (
<div
key={getKey(item)}
onClick={onRowClick ? () => onRowClick(item) : undefined}
<div
className={cn(
LIST_GRID_CLASS,
"sticky top-0 z-10 items-center border-b border-border bg-background text-[10px] font-semibold uppercase tracking-widest text-muted-foreground",
)}
>
{listColumns.map((column) => (
<span
key={column.key}
className={cn(
LIST_GRID_CLASS,
"items-center border-b border-border/50 text-xs transition-colors last:border-0",
rowHeight,
onRowClick && "cursor-pointer",
active ? "bg-accent-brand/10" : "hover:bg-muted/40",
"truncate py-2",
column.align === "end" && "text-right",
columnVisibility(column.hideBelow),
)}
>
{listColumns.map((column) => (
<div
key={column.key}
className={cn(
"min-w-0 truncate",
column.align === "end" && "text-right",
columnVisibility(column.hideBelow),
)}
>
{column.cell(item)}
</div>
))}
</div>
);
})}
{column.header}
</span>
))}
</div>
<div>
{items.map((item) => {
const active = isRowActive?.(item) ?? false;
return (
<div
key={getKey(item)}
onClick={onRowClick ? () => onRowClick(item) : undefined}
className={cn(
LIST_GRID_CLASS,
"items-center border-b border-border/50 text-xs transition-colors last:border-border",
rowHeight,
onRowClick && "cursor-pointer",
active ? "bg-accent-brand/10" : "hover:bg-muted/40",
)}
>
{listColumns.map((column) => (
<div
key={column.key}
className={cn(
"min-w-0 truncate",
column.align === "end" && "text-right",
columnVisibility(column.hideBelow),
)}
>
{column.cell(item)}
</div>
))}
</div>
);
})}
</div>
</div>
</div>
</BottomEdge>
);
}
+41
View File
@@ -0,0 +1,41 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
export interface InfoItem {
label: ReactNode;
value: ReactNode;
/** Monospace value, for ids, paths and addresses. */
mono?: boolean;
}
/** Label and value pairs in two aligned columns. Empty values are skipped. */
export function InfoGrid({
items,
className,
}: {
items: InfoItem[];
className?: string;
}) {
const shown = items.filter(
(item) =>
item.value !== null && item.value !== undefined && item.value !== "",
);
if (shown.length === 0) return null;
return (
<dl
className={cn(
"grid grid-cols-[max-content_minmax(0,1fr)] gap-x-4 gap-y-1 text-xs",
className,
)}
>
{shown.map((item, index) => (
<div key={index} className="contents">
<dt className="text-muted-foreground">{item.label}</dt>
<dd className={cn("min-w-0 break-words", item.mono && "font-mono")}>
{item.value}
</dd>
</div>
))}
</dl>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
/** A spinner with a short line, for a view waiting on its first load. */
export function LoadingState({
label,
compact = false,
className,
}: {
label?: string;
/** One inline line instead of the centered block. */
compact?: boolean;
className?: string;
}) {
const { t } = useTranslation();
const text = label ?? t("common.loading");
if (compact) {
return (
<div
role="status"
className={cn(
"flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground",
className,
)}
>
<Loader2 className="size-3 animate-spin" />
{text}
</div>
);
}
return (
<div
role="status"
className={cn(
"flex flex-1 flex-col items-center justify-center gap-2 px-4 py-12 text-xs text-muted-foreground",
className,
)}
>
<Loader2 className="size-5 animate-spin opacity-60" />
{text}
</div>
);
}
+31 -4
View File
@@ -1,4 +1,5 @@
import type { ReactNode } from "react";
import { useInSeamlessTile } from "@/components/tile-grid/context";
import { cn } from "@/lib/utils";
/** Default cap (px) for a scrollable card body before it scrolls. */
@@ -29,11 +30,37 @@ export function MetricCard({
/** Max body height in px for scrollable cards. */
scrollMax?: number;
}) {
// In an edge to edge tile grid the grid draws the lines, so the card takes
// the dashboard tile look: no border or fill, the small title row.
const tiled = useInSeamlessTile();
return (
<div className="flex min-h-0 flex-col overflow-hidden border border-border bg-card">
<div className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-2.5">
<span className="text-muted-foreground">{icon}</span>
<span className="flex-1 truncate text-xs font-semibold uppercase tracking-widest text-muted-foreground">
<div
data-tile-card=""
className={cn(
"flex min-h-0 flex-col overflow-hidden",
!tiled && "border border-border",
)}
>
<div
className={cn(
"flex shrink-0 items-center gap-2 border-b border-border",
tiled ? "px-3 py-1.5" : "px-4 py-2.5",
)}
>
<span
className={cn(
"text-muted-foreground",
tiled && "flex [&_svg]:size-3",
)}
>
{icon}
</span>
<span
className={cn(
"flex-1 truncate font-semibold uppercase tracking-widest text-muted-foreground",
tiled ? "text-[10px]" : "text-xs",
)}
>
{title}
</span>
{action && <div className="ml-auto shrink-0">{action}</div>}
+52
View File
@@ -0,0 +1,52 @@
import type { ReactNode } from "react";
import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* A block that explains why a view has nothing to show yet and what to do,
* like "Samba is not installed" with an Install button. Unlike EmptyState the
* action always shows.
*/
export function NoticePanel({
icon: Icon,
title,
children,
action,
tone = "default",
className,
}: {
icon?: LucideIcon;
title: ReactNode;
children?: ReactNode;
action?: ReactNode;
tone?: "default" | "warning" | "destructive";
className?: string;
}) {
return (
<div
className={cn(
"flex flex-col items-start gap-2 px-4 py-4",
tone === "warning" && "bg-warning/5",
tone === "destructive" && "bg-destructive/5",
className,
)}
>
{Icon && (
<Icon
className={cn(
"size-5 text-muted-foreground",
tone === "warning" && "text-warning",
tone === "destructive" && "text-destructive",
)}
/>
)}
<p className="text-sm font-medium">{title}</p>
{children && (
<div className="text-xs leading-relaxed text-muted-foreground">
{children}
</div>
)}
{action && <div className="mt-1 flex flex-wrap gap-2">{action}</div>}
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
/* eslint-disable react-refresh/only-export-components */
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/button";
import { cn } from "@/lib/utils";
/** One page of a list, with the page clamped to what exists. */
export function pageOf<T>(
items: T[],
page: number,
size: number,
): { rows: T[]; page: number; pages: number } {
const pages = Math.max(1, Math.ceil(items.length / size));
const at = Math.min(Math.max(0, page), pages - 1);
return { rows: items.slice(at * size, at * size + size), page: at, pages };
}
/** Pages a list in memory. Goes back to the first page when `reset` changes. */
export function usePaged<T>(items: T[], size: number, reset?: unknown) {
const [page, setPage] = useState(0);
useEffect(() => setPage(0), [reset]);
return { ...pageOf(items, page, size), setPage };
}
/** Previous and Next under a list. Hidden when there is only one page. */
export function Pager({
page,
pages,
hasNext,
onPage,
className,
}: {
page: number;
/** Total pages when known; leave out for a list read a page at a time. */
pages?: number;
hasNext?: boolean;
onPage: (next: number) => void;
className?: string;
}) {
const { t } = useTranslation();
const next = hasNext ?? (pages !== undefined && page < pages - 1);
if (page === 0 && !next) return null;
return (
<div
className={cn(
"flex shrink-0 items-center justify-between gap-2 border-t border-border px-2 py-1 text-xs text-muted-foreground",
className,
)}
>
<Button
size="sm"
variant="ghost"
disabled={page === 0}
onClick={() => onPage(page - 1)}
>
{t("panel.pager.previous")}
</Button>
<span>
{pages !== undefined
? t("panel.pager.pageOf", { page: page + 1, pages })
: t("panel.pager.page", { page: page + 1 })}
</span>
<Button
size="sm"
variant="ghost"
disabled={!next}
onClick={() => onPage(page + 1)}
>
{t("panel.pager.next")}
</Button>
</div>
);
}
+70 -6
View File
@@ -6,6 +6,7 @@ import {
Columns3,
Grid3X3,
List,
RefreshCw,
Rows3,
Search,
X,
@@ -14,6 +15,7 @@ import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/separator";
import { DocsLink } from "@/components/docs-link";
import { useInSeamlessTile } from "@/components/tile-grid/context";
import {
Select,
SelectContent,
@@ -116,6 +118,9 @@ export function PanelShell({
const bands = (
<>
{tabs && (
<div className="shrink-0 border-b border-border px-1">{tabs}</div>
)}
{toolbar && (
<div
className={`flex shrink-0 items-center gap-2 border-b border-border ${PANEL.band}`}
@@ -123,9 +128,6 @@ export function PanelShell({
{toolbar}
</div>
)}
{tabs && (
<div className="shrink-0 border-b border-border px-1">{tabs}</div>
)}
</>
);
@@ -215,6 +217,59 @@ export function BackButton({
}
/** Search box sized to match toolbar buttons. Esc clears it. */
/** The refresh button panel headers show, spinning while it loads. */
export function RefreshButton({
onClick,
loading = false,
disabled,
label,
className,
}: {
onClick: () => void;
loading?: boolean;
disabled?: boolean;
label?: string;
className?: string;
}) {
const { t } = useTranslation();
const text = label ?? t("common.refresh");
return (
<button
type="button"
onClick={onClick}
disabled={disabled ?? loading}
title={text}
aria-label={text}
className={cn(
"flex size-8 shrink-0 items-center justify-center text-accent-brand transition-colors hover:bg-muted/40 disabled:opacity-60",
className,
)}
>
<RefreshCw className={cn("size-4", loading && "animate-spin")} />
</button>
);
}
/**
* The outer box of a plugin tab: fills the tab and clips, so the PanelShell
* inside can scroll its own body.
*/
export function PanelFrame({
className,
children,
}: {
className?: string;
children: React.ReactNode;
}) {
return (
<div className={cn("h-full w-full overflow-hidden", className)}>
<div className="flex h-full min-h-0 w-full flex-1 flex-col overflow-hidden">
{children}
</div>
</div>
);
}
export function PanelSearch({
value,
onChange,
@@ -333,7 +388,7 @@ export function Segmented<T extends string>({
title={option.title ?? option.label}
onClick={() => onChange(option.value)}
className={cn(
"flex h-8 items-center justify-center gap-1.5 px-2.5 text-[11px] font-medium transition-colors focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring",
"flex h-[30px] items-center justify-center gap-1.5 px-2.5 text-[11px] font-medium transition-colors focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring",
i > 0 && "border-l border-border",
active
? "bg-accent-brand/10 text-accent-brand"
@@ -458,7 +513,7 @@ function ToggleButton({
aria-label={title}
aria-pressed={active}
className={cn(
"flex size-8 items-center justify-center transition-colors focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring",
"flex size-[30px] items-center justify-center transition-colors focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring",
bordered && "border-l border-border",
active
? "bg-accent-brand/10 text-accent-brand"
@@ -513,13 +568,18 @@ export function GroupHeading({
title,
count,
action,
plain,
className,
}: {
title: string;
count?: number;
action?: React.ReactNode;
/** No rule after the title. The default inside a tile layout. */
plain?: boolean;
className?: string;
}) {
const tiled = useInSeamlessTile();
const noRule = plain ?? tiled;
return (
<div className={cn("flex items-center gap-2", className)}>
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
@@ -530,7 +590,11 @@ export function GroupHeading({
{count}
</span>
)}
<div className="h-px flex-1 bg-border" />
{noRule ? (
<div className="flex-1" />
) : (
<div className="h-px flex-1 bg-border" />
)}
{action}
</div>
);
+4 -4
View File
@@ -18,19 +18,19 @@ export const PasswordInput = React.forwardRef<
<Input
ref={ref}
type={showPassword ? "text" : "password"}
className={cn("h-11 text-base pr-12", className)} // extra padding-right
className={cn("h-8 pr-8", className)}
{...props}
/>
<button
type="button"
onClick={() => setShowPassword((prev) => !prev)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition"
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition"
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? (
<EyeOff className="h-5 w-5" />
<EyeOff className="size-4" />
) : (
<Eye className="h-5 w-5" />
<Eye className="size-4" />
)}
</button>
</div>
+15 -4
View File
@@ -1,5 +1,7 @@
import { DefaultsOnly, HostDefaultBadge } from "@/lib/host-defaults-context";
import { useState } from "react";
import { useInSeamlessTile } from "@/components/tile-grid/context";
import { cn } from "@/lib/utils";
import type React from "react";
export function SectionCard({
@@ -18,19 +20,28 @@ export function SectionCard({
/** Lets settings search jump to this card. */
rowId?: string;
}) {
// Inside an edge to edge tile layout the layout draws the lines.
const tiled = useInSeamlessTile();
return (
<div
data-tile-card=""
data-setting-row={rowId}
className={`flex flex-col border border-border bg-card overflow-hidden ${className ?? ""}`}
className={cn(
"flex flex-col overflow-hidden",
!tiled && "border border-border",
className,
)}
>
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border shrink-0">
<span className="text-muted-foreground">{icon}</span>
<div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-1.5">
<span className="flex text-muted-foreground [&_svg]:size-3">
{icon}
</span>
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground flex-1">
{title}
</span>
{action && <div className="ml-auto">{action}</div>}
</div>
<div className="px-3 md:px-4 py-1">{children}</div>
<div className="px-3 py-1">{children}</div>
</div>
);
}
+2 -2
View File
@@ -35,7 +35,7 @@ function SelectTrigger({
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-none border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-none border bg-transparent px-2.5 py-1 text-xs whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-8 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
@@ -105,7 +105,7 @@ function SelectItem({
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-none py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-none py-1.5 pr-8 pl-2 text-xs outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
+4 -4
View File
@@ -154,7 +154,7 @@ function Select2({
aria-expanded={open}
aria-required={required}
className={cn(
"border-input bg-transparent dark:bg-input/30 dark:hover:bg-input/50 text-foreground flex h-9 w-full items-center justify-between gap-2 rounded-none border px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
"border-input bg-transparent dark:bg-input/30 dark:hover:bg-input/50 text-foreground flex h-8 w-full items-center justify-between gap-2 rounded-none border px-2.5 py-1 text-xs shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
onKeyDown={(event) => {
@@ -189,7 +189,7 @@ function Select2({
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t("common.search")}
className="h-9 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
className="h-8 w-full bg-transparent text-xs outline-none placeholder:text-muted-foreground"
autoFocus
/>
</div>
@@ -198,7 +198,7 @@ function Select2({
className="max-h-64 overflow-y-auto p-1 thin-scrollbar"
>
{filteredOptions.length === 0 ? (
<div className="px-2 py-6 text-center text-sm text-muted-foreground">
<div className="px-2 py-6 text-center text-xs text-muted-foreground">
{t("common.noResults")}
</div>
) : (
@@ -222,7 +222,7 @@ function Select2({
aria-selected={isSelected}
disabled={option.disabled}
className={cn(
"flex w-full items-center gap-2 rounded-none px-2 py-1.5 text-left text-sm outline-none transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-50",
"flex w-full items-center gap-2 rounded-none px-2 py-1.5 text-left text-xs outline-none transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-50",
isSelected && "bg-muted text-accent-foreground",
)}
onClick={() => selectValue(option.value)}
+127
View File
@@ -0,0 +1,127 @@
import type { ComponentType, ReactNode } from "react";
import { ExternalLink } from "lucide-react";
import { cn } from "@/lib/utils";
import { Select2 } from "@/components/select2";
export interface SideNavEntry {
id: string;
label: string;
icon?: ComponentType<{ className?: string }>;
/** A count or short badge after the label. */
badge?: ReactNode;
/** Opens it in a normal tab instead, for split view. */
onPopOut?: () => void;
popOutLabel?: string;
/** Draws a line above this entry. */
divider?: boolean;
}
/**
* The left list of sections a page switches between, like the host page and
* the Operations tab. On a phone it turns into a select over the content.
*/
export function SideNav({
entries,
active,
onSelect,
footer,
label,
className,
}: {
entries: SideNavEntry[];
/** Names the list for screen readers and the phone select. */
label?: string;
active: string;
onSelect: (id: string) => void;
footer?: ReactNode;
className?: string;
}) {
return (
<>
<nav
aria-label={label}
className={cn(
"hidden w-44 shrink-0 flex-col gap-0.5 overflow-auto border-r border-border p-2 thin-scrollbar md:flex",
className,
)}
>
{entries.map((entry) => (
<div key={entry.id} className="contents">
{entry.divider && <div className="my-1 h-px bg-border" />}
<SideNavItem
entry={entry}
active={entry.id === active}
onClick={() => onSelect(entry.id)}
/>
</div>
))}
{footer && <div className="mt-auto pt-2">{footer}</div>}
</nav>
<div className="shrink-0 border-b border-border p-2 md:hidden">
<Select2
value={active}
aria-label={label}
onChange={(event) => onSelect(event.target.value)}
className="h-8 w-full px-2.5 text-xs"
>
{entries.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.label}
</option>
))}
</Select2>
</div>
</>
);
}
export function SideNavItem({
entry,
active,
onClick,
}: {
entry: SideNavEntry;
active: boolean;
onClick: () => void;
}) {
const Icon = entry.icon;
return (
<div
className={cn(
"group flex items-center",
active
? "bg-muted font-medium text-foreground"
: "text-muted-foreground hover:bg-muted/60",
)}
>
<button
type="button"
onClick={onClick}
aria-current={active ? "page" : undefined}
className="flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5 text-left text-xs"
>
{Icon && <Icon className="size-3.5 shrink-0" />}
<span className="truncate">{entry.label}</span>
{entry.badge !== undefined && entry.badge !== null && (
<span className="ml-auto shrink-0 px-1 text-[10px] tabular-nums">
{entry.badge}
</span>
)}
</button>
{entry.onPopOut && (
<button
type="button"
title={entry.popOutLabel}
aria-label={entry.popOutLabel}
onClick={entry.onPopOut}
className={cn(
"mr-1 shrink-0 p-1 text-muted-foreground hover:text-foreground",
!active && "opacity-0 focus:opacity-100 group-hover:opacity-100",
)}
>
<ExternalLink className="size-3" />
</button>
)}
</div>
);
}
+87
View File
@@ -0,0 +1,87 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import { Meter } from "@/components/meter";
import { TileFrame } from "@/components/tile-grid/TileFrame";
const VALUE_TONE = {
default: "",
ok: "text-green-500",
warn: "text-warning",
bad: "text-destructive",
} as const;
/**
* One number in the tile look: the small title row, a big value, then an
* optional usage bar and a hint line. Put several in a TileWall for a strip.
*/
export function StatTile({
label,
icon,
value,
unit,
percent,
hint,
tone = "default",
actions,
onClick,
children,
className,
}: {
label: ReactNode;
icon?: ReactNode;
value: ReactNode;
unit?: ReactNode;
/** Draws a usage bar under the value. */
percent?: number | null;
hint?: ReactNode;
tone?: keyof typeof VALUE_TONE;
actions?: ReactNode;
onClick?: () => void;
/** Extra lines under the hint. */
children?: ReactNode;
className?: string;
}) {
const body = (
<div className="flex flex-col gap-1.5 px-3 py-2.5">
<div className="flex items-baseline gap-1">
<span
className={cn(
"truncate text-2xl font-bold tabular-nums leading-none",
VALUE_TONE[tone],
)}
>
{value}
</span>
{unit && <span className="text-xs text-muted-foreground">{unit}</span>}
</div>
{percent !== undefined && <Meter percent={percent} />}
{hint && (
<span className="truncate text-[11px] text-muted-foreground">
{hint}
</span>
)}
{children}
</div>
);
return (
<TileFrame
title={label}
icon={icon}
actions={actions}
className={className}
bodyClassName="overflow-hidden"
>
{onClick ? (
<button
type="button"
onClick={onClick}
className="text-left transition-colors hover:bg-muted/40"
>
{body}
</button>
) : (
body
)}
</TileFrame>
);
}
+125
View File
@@ -0,0 +1,125 @@
/* eslint-disable react-refresh/only-export-components */
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import { ListBadge } from "@/components/list-kit";
export type StatusTone = "ok" | "warn" | "bad" | "info" | "idle";
const DOT: Record<StatusTone, string> = {
ok: "bg-green-500",
warn: "bg-warning",
bad: "bg-destructive",
info: "bg-accent-brand",
idle: "bg-muted-foreground/50",
};
const BADGE = {
ok: "success",
warn: "warning",
bad: "destructive",
info: "brand",
idle: "muted",
} as const;
// Common state words, so most callers only pass the state.
const WORDS: Record<string, StatusTone> = {
running: "ok",
up: "ok",
online: "ok",
active: "ok",
healthy: "ok",
ready: "ok",
ok: "ok",
success: "ok",
succeeded: "ok",
done: "ok",
completed: "ok",
enabled: "ok",
connected: "ok",
bound: "ok",
compliant: "ok",
pending: "warn",
paused: "warn",
restarting: "warn",
starting: "warn",
stopping: "warn",
waiting: "warn",
degraded: "warn",
warn: "warn",
warning: "warn",
unhealthy: "warn",
drifted: "warn",
pending_approval: "warn",
needs_interaction: "warn",
interrupted: "warn",
update: "info",
created: "idle",
exited: "idle",
stopped: "idle",
inactive: "idle",
disabled: "idle",
offline: "bad",
down: "bad",
dead: "bad",
failed: "bad",
fail: "bad",
error: "bad",
crashloopbackoff: "bad",
};
/** The tone for a state word like "running" or "failed". Unknown words are idle. */
export function statusTone(state: string | null | undefined): StatusTone {
if (!state) return "idle";
const key = state.toLowerCase().replace(/[\s-]+/g, "_");
return WORDS[key] ?? WORDS[key.replace(/_/g, "")] ?? "idle";
}
/** A small round status light. */
export function StatusDot({
tone,
state,
pulse = false,
className,
title,
}: {
tone?: StatusTone;
/** A state word, used when no tone is given. */
state?: string | null;
pulse?: boolean;
className?: string;
title?: string;
}) {
const resolved = tone ?? statusTone(state);
return (
<span
title={title}
aria-hidden={title ? undefined : true}
className={cn(
"inline-block size-2 shrink-0 rounded-full",
DOT[resolved],
pulse && "animate-pulse",
className,
)}
/>
);
}
/** A state as a small colored badge. The text is the state unless children are given. */
export function StatusBadge({
tone,
state,
className,
children,
}: {
tone?: StatusTone;
state?: string | null;
className?: string;
children?: ReactNode;
}) {
const resolved = tone ?? statusTone(state);
return (
<ListBadge tone={BADGE[resolved]} className={className}>
{children ?? state}
</ListBadge>
);
}
@@ -15,6 +15,7 @@ import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { BackButton, PanelShell } from "@/components/panel-layout";
import { TileColumn } from "@/components/tile-grid/TileColumn";
import {
PROMPT_BUTTON,
PROMPT_DESTRUCTIVE_BUTTON,
@@ -397,6 +398,7 @@ export function InlineView({
onBeforeClose,
scroll = true,
bare = false,
stacked = false,
className,
children,
}: {
@@ -416,6 +418,11 @@ export function InlineView({
scroll?: boolean;
/** Draw children edge to edge, for views with their own bands. */
bare?: boolean;
/**
* Cards stacked in one column with a line between them and no padding,
* like a settings page. For forms built from SectionCards.
*/
stacked?: boolean;
className?: string;
children: React.ReactNode;
}) {
@@ -462,6 +469,10 @@ export function InlineView({
>
{bare ? (
children
) : stacked ? (
<TileColumn className={cn("w-full", className)}>
{children}
</TileColumn>
) : (
<div
className={cn(
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff. Show more