Compare commits

...
17 Commits
Author SHA1 Message Date
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
LukeGus f5895f15f2 chore: update translations and plugin pins 2026-11-03 15:09:54 +00:00
LukeGus 4fb91850a0 fix: keep the default look out of the first run plugin picker 2026-11-02 16:48:15 -06:00
82 changed files with 22742 additions and 48 deletions

No files matched your search

+21
View File
@@ -1,5 +1,26 @@
# Changelog # Changelog
## 26.11.1
### Added
- Termix actions: plugins can declare what they do once, and it works from the API with permission checks, dry runs, run history and approvals
- API keys can be limited to reading or running actions, when an admin makes them
- A click on a host can open its host page, when a plugin offers one
### Changed
- Host status, import and export, and credential tools now start on for new installs
- Sync now starts on in a new desktop app, so it can link to a server
- The first run plugin picker leaves plugins you did not choose off instead of removing them
### Fixed
- The first run plugin picker no longer sets the default layout and themes to Remove. They always stay on
- Alerts with no icon no longer squeeze their text to one word per line
- Password fields with the show button are the same height as the other fields next to them
- The Secure your account setup step no longer shows up empty when no passkey or authenticator plugin is on
## 26.11.0 ## 26.11.0
### Fixed ### Fixed
+5 -2
View File
@@ -71,8 +71,8 @@ Plugins add everything else. The official ones ship with Termix, and admins can
| [Databases](https://github.com/Termix-SSH/Plugin-Databases) | Browse and edit MySQL, MariaDB, PostgreSQL and SQLite databases on your hosts from one tab. | | [Databases](https://github.com/Termix-SSH/Plugin-Databases) | Browse and edit MySQL, MariaDB, PostgreSQL and SQLite databases on your hosts from one tab. |
| [Docker](https://github.com/Termix-SSH/Plugin-Docker) | Manage Docker and Podman containers on your hosts over SSH, with logs, stats and a console. | | [Docker](https://github.com/Termix-SSH/Plugin-Docker) | Manage Docker and Podman containers on your hosts over SSH, with logs, stats and a console. |
| [File Manager](https://github.com/Termix-SSH/Plugin-File-Manager) | Browse, edit and move files over SFTP, including straight from one server to another. | | [File Manager](https://github.com/Termix-SSH/Plugin-File-Manager) | Browse, edit and move files over SFTP, including straight from one server to another. |
| [Firewall](https://github.com/Termix-SSH/Plugin-Firewall) | Manage UFW, firewalld, nftables and iptables rules on your hosts over SSH, with lockout protection. |
| [Fleets](https://github.com/Termix-SSH/Plugin-Fleets) | Group hosts into fleets and run commands, package actions and file transfers on all of them at once. | | [Fleets](https://github.com/Termix-SSH/Plugin-Fleets) | Group hosts into fleets and run commands, package actions and file transfers on all of them at once. |
| [HashiCorp Vault](https://github.com/Termix-SSH/Plugin-Vault) | Connect to hosts with short-lived SSH certificates signed by HashiCorp Vault. |
| [Homepage](https://github.com/Termix-SSH/Plugin-Homepage) | A drag and drop widget page for your servers and services, plus service links for the dashboard. | | [Homepage](https://github.com/Termix-SSH/Plugin-Homepage) | A drag and drop widget page for your servers and services, plus service links for the dashboard. |
| [Host Metrics](https://github.com/Termix-SSH/Plugin-Host-Metrics) | Live CPU, memory, disk, network and GPU stats, plus tools to manage your hosts. | | [Host Metrics](https://github.com/Termix-SSH/Plugin-Host-Metrics) | Live CPU, memory, disk, network and GPU stats, plus tools to manage your hosts. |
| [Host Status](https://github.com/Termix-SSH/Plugin-Host-Status) | Shows if each host is online with a dot in the host list, checked by connecting to its port. | | [Host Status](https://github.com/Termix-SSH/Plugin-Host-Status) | Shows if each host is online with a dot in the host list, checked by connecting to its port. |
@@ -82,6 +82,7 @@ Plugins add everything else. The official ones ship with Termix, and admins can
| [LDAP](https://github.com/Termix-SSH/Plugin-LDAP) | Sign in with your LDAP or Active Directory username and password. | | [LDAP](https://github.com/Termix-SSH/Plugin-LDAP) | Sign in with your LDAP or Active Directory username and password. |
| [Login Security](https://github.com/Termix-SSH/Plugin-Login-Security) | Sign people out when idle, limit session length, turn off remember me and require a second factor at login. | | [Login Security](https://github.com/Termix-SSH/Plugin-Login-Security) | Sign people out when idle, limit session length, turn off remember me and require a second factor at login. |
| [Network Topology](https://github.com/Termix-SSH/Plugin-Network-Topology) | Draws your hosts and the links between them as a live, interactive graph. | | [Network Topology](https://github.com/Termix-SSH/Plugin-Network-Topology) | Draws your hosts and the links between them as a live, interactive graph. |
| [Operations Center](https://github.com/Termix-SSH/Plugin-Operations-Center) | Run, roll out, approve and track every plugin action across your hosts, with a page per host and tables across all of them. |
| [OPKSSH](https://github.com/Termix-SSH/Plugin-OPKSSH) | Connect to hosts with short-lived OpenPubkey SSH certificates after signing in with your identity provider. | | [OPKSSH](https://github.com/Termix-SSH/Plugin-OPKSSH) | Connect to hosts with short-lived OpenPubkey SSH certificates after signing in with your identity provider. |
| [Passkeys](https://github.com/Termix-SSH/Plugin-WebAuthn) | Sign in with a passkey or security key instead of a password. | | [Passkeys](https://github.com/Termix-SSH/Plugin-WebAuthn) | Sign in with a passkey or security key instead of a password. |
| [Proxmox](https://github.com/Termix-SSH/Plugin-Proxmox) | Import Proxmox VE guests as hosts, keep them in sync and watch node stats. | | [Proxmox](https://github.com/Termix-SSH/Plugin-Proxmox) | Import Proxmox VE guests as hosts, keep them in sync and watch node stats. |
@@ -96,6 +97,7 @@ Plugins add everything else. The official ones ship with Termix, and admins can
| [Simple Layout](https://github.com/Termix-SSH/Plugin-Simple-Layout) | A simple Termix layout for small setups with a top bar, a Home screen and only the basics. | | [Simple Layout](https://github.com/Termix-SSH/Plugin-Simple-Layout) | A simple Termix layout for small setups with a top bar, a Home screen and only the basics. |
| [Single sign-on](https://github.com/Termix-SSH/Plugin-SSO) | Sign in with any OpenID Connect provider, GitHub or Google. | | [Single sign-on](https://github.com/Termix-SSH/Plugin-SSO) | Sign in with any OpenID Connect provider, GitHub or Google. |
| [Snippets](https://github.com/Termix-SSH/Plugin-Snippets) | Save the commands you run often and run them in a terminal or on many hosts in one click. | | [Snippets](https://github.com/Termix-SSH/Plugin-Snippets) | Save the commands you run often and run them in a terminal or on many hosts in one click. |
| [SSH Certificate Signer](https://github.com/Termix-SSH/Plugin-Vault) | Connect to hosts with short-lived SSH certificates signed by HashiCorp Vault or OpenBao. |
| [SSH Terminal](https://github.com/Termix-SSH/Plugin-SSH-Terminal) | SSH terminal sessions with tabs, split screen, history, macros and a local terminal in the desktop app. | | [SSH Terminal](https://github.com/Termix-SSH/Plugin-SSH-Terminal) | SSH terminal sessions with tabs, split screen, history, macros and a local terminal in the desktop app. |
| [Step CA](https://github.com/Termix-SSH/Plugin-Step-CA) | Connect to hosts with short-lived SSH certificates from a smallstep step-ca server. | | [Step CA](https://github.com/Termix-SSH/Plugin-Step-CA) | Connect to hosts with short-lived SSH certificates from a smallstep step-ca server. |
| [Sync](https://github.com/Termix-SSH/Plugin-Sync) | Sync the desktop app with one or more servers, and manage other Termix servers as remote sites from a master. | | [Sync](https://github.com/Termix-SSH/Plugin-Sync) | Sync the desktop app with one or more servers, and manage other Termix servers as remote sites from a master. |
@@ -105,9 +107,10 @@ Plugins add everything else. The official ones ship with Termix, and admins can
| [TOTP](https://github.com/Termix-SSH/Plugin-TOTP) | Two-factor sign in with a code from an authenticator app, with backup codes. | | [TOTP](https://github.com/Termix-SSH/Plugin-TOTP) | Two-factor sign in with a code from an authenticator app, with backup codes. |
| [Tunnels](https://github.com/Termix-SSH/Plugin-Tunnels) | SSH port forwarding that reconnects on its own, plus client tunnels from the desktop app. | | [Tunnels](https://github.com/Termix-SSH/Plugin-Tunnels) | SSH port forwarding that reconnects on its own, plus client tunnels from the desktop app. |
| [Usage Statistics](https://github.com/Termix-SSH/Plugin-Telemetry) | Sends a small anonymous daily report so we can see how Termix is used. | | [Usage Statistics](https://github.com/Termix-SSH/Plugin-Telemetry) | Sends a small anonymous daily report so we can see how Termix is used. |
| [Wake-on-LAN](https://github.com/Termix-SSH/Plugin-Wake-On-LAN) | Wake sleeping machines by sending a Wake-on-LAN packet to their MAC address. | | [Wake-on-LAN](https://github.com/Termix-SSH/Plugin-Wake-On-LAN) | Wake, shut down and track your machines from one power dashboard. |
| [Warpgate](https://github.com/Termix-SSH/Plugin-Warpgate) | Connect to hosts through a Warpgate SSH bastion. | | [Warpgate](https://github.com/Termix-SSH/Plugin-Warpgate) | Connect to hosts through a Warpgate SSH bastion. |
| [Web Endpoint](https://github.com/Termix-SSH/Plugin-Web-Endpoint) | Open a host's web UI inside Termix, directly or over an SSH tunnel. | | [Web Endpoint](https://github.com/Termix-SSH/Plugin-Web-Endpoint) | Open a host's web UI inside Termix, directly or over an SSH tunnel. |
| [WireGuard](https://github.com/Termix-SSH/Plugin-WireGuard) | Install WireGuard on your hosts and manage interfaces, clients and configs from one tab. |
| [Workspaces](https://github.com/Termix-SSH/Plugin-Workspaces) | Save your open tabs and split layouts and reopen them in one click. | | [Workspaces](https://github.com/Termix-SSH/Plugin-Workspaces) | Save your open tabs and split layouts and reopen them in one click. |
Want to build your own? Start from the [plugin template](https://github.com/Termix-SSH/Termix-Plugin-Template). Plugins you can install are listed in the [registry](https://github.com/Termix-SSH/Termix-Registry). Turn on the community registry in Plugins, Registries to install plugins made by other people, or add your own registry. See [submitting a plugin](https://docs.termix.site/develop/community-registry) and [hosting your own registry](https://docs.termix.site/develop/registries#host-your-own-registry). Want to build your own? Start from the [plugin template](https://github.com/Termix-SSH/Termix-Plugin-Template). Plugins you can install are listed in the [registry](https://github.com/Termix-SSH/Termix-Registry). Turn on the community registry in Plugins, Registries to install plugins made by other people, or add your own registry. See [submitting a plugin](https://docs.termix.site/develop/community-registry) and [hosting your own registry](https://docs.termix.site/develop/registries#host-your-own-registry).
+24 -8
View File
@@ -67,7 +67,11 @@
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Credential-Tools/releases/download/v1.0.0/credential-tools-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Credential-Tools/releases/download/v1.0.0/credential-tools-1.0.0.tmxplug",
"sha256": "413eff710b5c832a2575a890bbab1c90d28ea55f10cd7a7b94b851d51dd77dce", "sha256": "413eff710b5c832a2575a890bbab1c90d28ea55f10cd7a7b94b851d51dd77dce",
"tier": "standard" "tier": "standard",
"onboarding": {
"recommended": true,
"consent": false
}
}, },
{ {
"id": "databases", "id": "databases",
@@ -128,7 +132,11 @@
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Host-Status/releases/download/v1.0.0/host-status-1.0.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Host-Status/releases/download/v1.0.0/host-status-1.0.0.tmxplug",
"sha256": "1ba62577dc7373a2a2787bab7efdb89b544a30a8003b1de91ce51f372fc4f170", "sha256": "1ba62577dc7373a2a2787bab7efdb89b544a30a8003b1de91ce51f372fc4f170",
"tier": "standard" "tier": "standard",
"onboarding": {
"recommended": true,
"consent": false
}
}, },
{ {
"id": "hosts-config", "id": "hosts-config",
@@ -142,7 +150,11 @@
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Import-Export/releases/download/v1.0.1/import-export-1.0.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Import-Export/releases/download/v1.0.1/import-export-1.0.1.tmxplug",
"sha256": "d8f2f865e1e6b226f4fe38501bf69b752f47a643bed1d9d127181083e27da2f0", "sha256": "d8f2f865e1e6b226f4fe38501bf69b752f47a643bed1d9d127181083e27da2f0",
"tier": "standard" "tier": "standard",
"onboarding": {
"recommended": true,
"consent": false
}
}, },
{ {
"id": "kubernetes", "id": "kubernetes",
@@ -200,8 +212,8 @@
{ {
"id": "reverse-proxy", "id": "reverse-proxy",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Reverse-Proxy/releases/download/v1.1.0/reverse-proxy-1.1.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Reverse-Proxy/releases/download/v1.1.1/reverse-proxy-1.1.1.tmxplug",
"sha256": "c8a9851da7ca6291c1f47cf6ab235da5057455a86b4ed0dc1c1a3026afb30aa7", "sha256": "f5e0089ca5eae1254f21ce18b222e70eb2beeae3bd874c2abc4faa1a68d3e47a",
"tier": "full" "tier": "full"
}, },
{ {
@@ -303,7 +315,11 @@
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Sync/releases/download/v1.1.0/sync-1.1.0.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Sync/releases/download/v1.1.0/sync-1.1.0.tmxplug",
"sha256": "9023d547f0e0bcef582c6ba7b7a8ab2ef68d8d0b973dde17d9e8c84d92b6429e", "sha256": "9023d547f0e0bcef582c6ba7b7a8ab2ef68d8d0b973dde17d9e8c84d92b6429e",
"tier": "standard" "tier": "standard",
"onboarding": {
"recommended": "desktop",
"consent": false
}
}, },
{ {
"id": "tailscale", "id": "tailscale",
@@ -351,8 +367,8 @@
{ {
"id": "tunnels", "id": "tunnels",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.2.2/tunnels-1.2.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.2.3/tunnels-1.2.3.tmxplug",
"sha256": "92d89c5bf7fa43fff790cc5240ae75eaf05c3c9089c6a0dfb68bbc37c517bf04", "sha256": "5a23953ee19b2951bb895e033d2099b8f6d5abc0121253ef3da25328134984ad",
"tier": "standard", "tier": "standard",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
+9
View File
@@ -276,6 +276,15 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
} }
location ~ ^/operations(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location ~ ^/plugins(/.*)?$ { location ~ ^/plugins(/.*)?$ {
proxy_pass http://127.0.0.1:30001; proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1; proxy_http_version 1.1;
+9
View File
@@ -245,6 +245,15 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
} }
location ~ ^/operations(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location ~ ^/plugins(/.*)?$ { location ~ ^/plugins(/.*)?$ {
proxy_pass http://127.0.0.1:30001; proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1; proxy_http_version 1.1;
+70
View File
@@ -0,0 +1,70 @@
CREATE TABLE `operation_approvals` (
`id` varchar(255) NOT NULL,
`run_id` varchar(255) NOT NULL,
`requested_by` text,
`reason` text,
`allow_self` boolean NOT NULL DEFAULT false,
`status` varchar(255) NOT NULL,
`decided_by` text,
`comment` text,
`created_at` text NOT NULL,
`expires_at` varchar(255) NOT NULL,
`decided_at` text,
CONSTRAINT `operation_approvals_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `operation_events` (
`id` varchar(255) NOT NULL,
`event_id` text NOT NULL,
`plugin_id` text NOT NULL,
`host_id` int,
`severity` text,
`payload` text,
`run_id` text,
`created_at` varchar(255) NOT NULL,
CONSTRAINT `operation_events_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `operation_runs` (
`id` varchar(255) NOT NULL,
`action_id` text NOT NULL,
`action_version` int NOT NULL DEFAULT 1,
`plugin_id` text NOT NULL,
`plugin_version` text NOT NULL,
`user_id` varchar(255),
`username` text NOT NULL,
`origin` text NOT NULL,
`host_id` int,
`host_name` text,
`batch_id` varchar(255),
`dry_run` boolean NOT NULL DEFAULT false,
`risk` text NOT NULL,
`status` varchar(255) NOT NULL,
`summary` text,
`changed` boolean,
`input` text,
`sealed_input` text,
`output` text,
`undo_input` text,
`error` text,
`error_code` text,
`undo_of` text,
`idempotency_key` text,
`created_at` varchar(255) NOT NULL,
`started_at` text,
`finished_at` text,
CONSTRAINT `operation_runs_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
ALTER TABLE `api_keys` ADD `scopes` text;--> statement-breakpoint
ALTER TABLE `operation_approvals` ADD CONSTRAINT `operation_approvals_run_id_operation_runs_id_fk` FOREIGN KEY (`run_id`) REFERENCES `operation_runs`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `operation_runs` ADD CONSTRAINT `operation_runs_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `idx_operation_approvals_status` ON `operation_approvals` (`status`,`expires_at`);--> statement-breakpoint
CREATE INDEX `idx_operation_approvals_run` ON `operation_approvals` (`run_id`);--> statement-breakpoint
CREATE INDEX `idx_operation_events_created` ON `operation_events` (`created_at`);--> statement-breakpoint
CREATE INDEX `idx_operation_events_host_created` ON `operation_events` (`host_id`,`created_at`);--> statement-breakpoint
CREATE INDEX `idx_operation_runs_created` ON `operation_runs` (`created_at`);--> statement-breakpoint
CREATE INDEX `idx_operation_runs_user_created` ON `operation_runs` (`user_id`,`created_at`);--> statement-breakpoint
CREATE INDEX `idx_operation_runs_host_created` ON `operation_runs` (`host_id`,`created_at`);--> statement-breakpoint
CREATE INDEX `idx_operation_runs_status` ON `operation_runs` (`status`);--> statement-breakpoint
CREATE INDEX `idx_operation_runs_batch` ON `operation_runs` (`batch_id`);
File diff suppressed because it is too large. Load diff
+7
View File
@@ -547,6 +547,13 @@
"when": 1793304929772, "when": 1793304929772,
"tag": "0077_shared_folder_auth_overrides", "tag": "0077_shared_folder_auth_overrides",
"breakpoints": true "breakpoints": true
},
{
"idx": 78,
"version": "5",
"when": 1793765383557,
"tag": "0078_sweet_king_cobra",
"breakpoints": true
} }
] ]
} }
@@ -0,0 +1,67 @@
CREATE TABLE "operation_approvals" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"run_id" varchar(255) NOT NULL,
"requested_by" text,
"reason" text,
"allow_self" boolean DEFAULT false NOT NULL,
"status" varchar(255) NOT NULL,
"decided_by" text,
"comment" text,
"created_at" text NOT NULL,
"expires_at" varchar(255) NOT NULL,
"decided_at" text
);
--> statement-breakpoint
CREATE TABLE "operation_events" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"event_id" text NOT NULL,
"plugin_id" text NOT NULL,
"host_id" integer,
"severity" text,
"payload" text,
"run_id" text,
"created_at" varchar(255) NOT NULL
);
--> statement-breakpoint
CREATE TABLE "operation_runs" (
"id" varchar(255) PRIMARY KEY NOT NULL,
"action_id" text NOT NULL,
"action_version" integer DEFAULT 1 NOT NULL,
"plugin_id" text NOT NULL,
"plugin_version" text NOT NULL,
"user_id" varchar(255),
"username" text NOT NULL,
"origin" text NOT NULL,
"host_id" integer,
"host_name" text,
"batch_id" varchar(255),
"dry_run" boolean DEFAULT false NOT NULL,
"risk" text NOT NULL,
"status" varchar(255) NOT NULL,
"summary" text,
"changed" boolean,
"input" text,
"sealed_input" text,
"output" text,
"undo_input" text,
"error" text,
"error_code" text,
"undo_of" text,
"idempotency_key" text,
"created_at" varchar(255) NOT NULL,
"started_at" text,
"finished_at" text
);
--> statement-breakpoint
ALTER TABLE "api_keys" ADD COLUMN "scopes" text;--> statement-breakpoint
ALTER TABLE "operation_approvals" ADD CONSTRAINT "operation_approvals_run_id_operation_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."operation_runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "operation_runs" ADD CONSTRAINT "operation_runs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_operation_approvals_status" ON "operation_approvals" USING btree ("status","expires_at");--> statement-breakpoint
CREATE INDEX "idx_operation_approvals_run" ON "operation_approvals" USING btree ("run_id");--> statement-breakpoint
CREATE INDEX "idx_operation_events_created" ON "operation_events" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX "idx_operation_events_host_created" ON "operation_events" USING btree ("host_id","created_at");--> statement-breakpoint
CREATE INDEX "idx_operation_runs_created" ON "operation_runs" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX "idx_operation_runs_user_created" ON "operation_runs" USING btree ("user_id","created_at");--> statement-breakpoint
CREATE INDEX "idx_operation_runs_host_created" ON "operation_runs" USING btree ("host_id","created_at");--> statement-breakpoint
CREATE INDEX "idx_operation_runs_status" ON "operation_runs" USING btree ("status");--> statement-breakpoint
CREATE INDEX "idx_operation_runs_batch" ON "operation_runs" USING btree ("batch_id");
File diff suppressed because it is too large. Load diff
+7
View File
@@ -547,6 +547,13 @@
"when": 1793304928399, "when": 1793304928399,
"tag": "0077_shared_folder_auth_overrides", "tag": "0077_shared_folder_auth_overrides",
"breakpoints": true "breakpoints": true
},
{
"idx": 78,
"version": "7",
"when": 1793765382955,
"tag": "0078_clean_living_mummy",
"breakpoints": true
} }
] ]
} }
@@ -0,0 +1,67 @@
CREATE TABLE `operation_approvals` (
`id` text PRIMARY KEY NOT NULL,
`run_id` text NOT NULL,
`requested_by` text,
`reason` text,
`allow_self` integer DEFAULT false NOT NULL,
`status` text NOT NULL,
`decided_by` text,
`comment` text,
`created_at` text NOT NULL,
`expires_at` text NOT NULL,
`decided_at` text,
FOREIGN KEY (`run_id`) REFERENCES `operation_runs`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `idx_operation_approvals_status` ON `operation_approvals` (`status`,`expires_at`);--> statement-breakpoint
CREATE INDEX `idx_operation_approvals_run` ON `operation_approvals` (`run_id`);--> statement-breakpoint
CREATE TABLE `operation_events` (
`id` text PRIMARY KEY NOT NULL,
`event_id` text NOT NULL,
`plugin_id` text NOT NULL,
`host_id` integer,
`severity` text,
`payload` text,
`run_id` text,
`created_at` text NOT NULL
);
--> statement-breakpoint
CREATE INDEX `idx_operation_events_created` ON `operation_events` (`created_at`);--> statement-breakpoint
CREATE INDEX `idx_operation_events_host_created` ON `operation_events` (`host_id`,`created_at`);--> statement-breakpoint
CREATE TABLE `operation_runs` (
`id` text PRIMARY KEY NOT NULL,
`action_id` text NOT NULL,
`action_version` integer DEFAULT 1 NOT NULL,
`plugin_id` text NOT NULL,
`plugin_version` text NOT NULL,
`user_id` text,
`username` text NOT NULL,
`origin` text NOT NULL,
`host_id` integer,
`host_name` text,
`batch_id` text,
`dry_run` integer DEFAULT false NOT NULL,
`risk` text NOT NULL,
`status` text NOT NULL,
`summary` text,
`changed` integer,
`input` text,
`sealed_input` text,
`output` text,
`undo_input` text,
`error` text,
`error_code` text,
`undo_of` text,
`idempotency_key` text,
`created_at` text NOT NULL,
`started_at` text,
`finished_at` text,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE INDEX `idx_operation_runs_created` ON `operation_runs` (`created_at`);--> statement-breakpoint
CREATE INDEX `idx_operation_runs_user_created` ON `operation_runs` (`user_id`,`created_at`);--> statement-breakpoint
CREATE INDEX `idx_operation_runs_host_created` ON `operation_runs` (`host_id`,`created_at`);--> statement-breakpoint
CREATE INDEX `idx_operation_runs_status` ON `operation_runs` (`status`);--> statement-breakpoint
CREATE INDEX `idx_operation_runs_batch` ON `operation_runs` (`batch_id`);--> statement-breakpoint
ALTER TABLE `api_keys` ADD `scopes` text;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -512,6 +512,13 @@
"when": 1793304926942, "when": 1793304926942,
"tag": "0072_shared_folder_auth_overrides", "tag": "0072_shared_folder_auth_overrides",
"breakpoints": true "breakpoints": true
},
{
"idx": 73,
"version": "6",
"when": 1793765382283,
"tag": "0073_dazzling_amazoness",
"breakpoints": true
} }
] ]
} }
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "termix", "name": "termix",
"version": "26.11.0", "version": "26.11.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "termix", "name": "termix",
"version": "26.11.0", "version": "26.11.1",
"hasInstallScript": true, "hasInstallScript": true,
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
+1 -1
View File
@@ -4,7 +4,7 @@
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
], ],
"version": "26.11.0", "version": "26.11.1",
"description": "Self-hosted, plugin-based server management.", "description": "Self-hosted, plugin-based server management.",
"author": "Karmaa", "author": "Karmaa",
"main": "electron/main.cjs", "main": "electron/main.cjs",
+29
View File
@@ -1,5 +1,34 @@
# Changelog # Changelog
## 1.0.25
Plugin API 1.14.0.
### Added
- Host click behavior `hostPage` opens a host page when a plugin offers one
- `createApiKey` takes scopes, to limit a key to actions
## 1.0.24
Plugin API 1.14.0.
### Added
- `app.registerHostSection` adds a card to the host page
- `/ui` exports `HostTabEmbed`, which draws any tab type inside another view, the host section registry, and `operationsApi` for core's `/operations` routes
## 1.0.23
Plugin API 1.14.0.
### Added
- `contributes.operations` declares a plugin's actions, events, resources and signals, so Automations, Fleets, the assistant, the CLI, the phone app and the command palette can find them with no wiring
- `ctx.operations` registers handlers, runs other plugins' declared actions, emits events and provides approval rules, with the new `operations:provide`, `operations:invoke` and `operations:approve` capabilities
- `@termix-ssh/plugin-sdk/operations` has the types, input checks, redaction and JSON Schema for declared actions
- `contributes.hostSections` declares cards for the host page
## 1.0.22 ## 1.0.22
Plugin API 1.13.0. Plugin API 1.13.0.
+69 -1
View File
@@ -1,6 +1,6 @@
# Termix plugin guide # Termix plugin guide
SDK 1.0.22 | Plugin API 1.13.0 SDK 1.0.25 | Plugin API 1.14.0
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed. `@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
@@ -414,6 +414,70 @@ ctx.schedule.every(60_000, () => refresh(), { jitterMs: 5_000 });
- Use a service: declare it in `requires`, then `ctx.services.get<T>("snippets.access")`. It returns - Use a service: declare it in `requires`, then `ctx.services.get<T>("snippets.access")`. It returns
nothing while no provider runs, so always handle that. nothing while no provider runs, so always handle that.
### Operations
Declare what your plugin can do in `contributes.operations`, and it shows up in Automations, Fleets, the
assistant, the CLI, the phone app and the command palette with no other wiring. It also gets per host results,
dry runs, run history, approvals and drift checks.
```json
"capabilities": ["operations:provide"],
"engine": { "termix": ">=26.11.1-0", "api": "^1.13" },
"contributes": {
"operations": {
"actions": [{
"id": "demo.restart",
"titleKey": "ops.restart.title",
"scope": "host",
"inputs": [{ "key": "name", "type": "string", "labelKey": "ops.restart.name", "required": true }],
"risk": "change",
"permission": "use",
"hostAccess": "edit",
"dryRun": "describe"
}]
}
}
```
```ts
ctx.operations.register("demo.restart", {
describe: (input) => `Would restart ${input.name}`,
run: async (input, { hostId, signal, log }) => {
return { summary: `Restarted ${input.name}`, changed: true };
},
});
```
- Actions: `scope` is `host`, `resource` or `server`. `risk` is `read`, `change` or `destructive`. Host actions declare
`hostAccess`. Use `edit` for changes unless people with `connect` already do this today. A destructive action
must use `edit` or `manage` and needs a confirm.
- Inputs use the settings field types, plus `required`, `pattern`, `optionsFrom` (a read action fills the choices)
and type `resource`. Keys you did not declare are refused before your handler runs. `secret` values reach the handler
but are never stored or shown. Mark private outputs `sensitive`.
- `dryRun`: `native` calls `run` with `dryRun: true` and nothing may change. `describe` calls `describe` instead.
- `check` names a read action that proves the result, used for drift. `undo` names the reverse action, and `run` returns
`undoInput`. `idempotent` lets a rollout retry it. `exclusive` allows one at a time per host. `when` limits it to
hosts by setting or OS.
- `surfaces`: the assistant and MCP are off until you set them true. Only do that for actions safe for a model to
ask for. The rest are on unless you set them false.
- Resources describe a kind of thing, such as a container: a read action on one host returns `{ rows }` as `data`, and
core draws the All hosts table and host page section. Attach actions with `scope: "resource"`.
- Signals are status tiles: a read action returns `{ value, status, label }` as `data`, with status `ok`, `warn`, `fail` or
`unknown`. `fix` names the action offered when it is not ok. Keep these reads cheap, they run for every host.
- Events: declare them, then call `ctx.operations.emit(id, payload, { hostId })`.
- Handlers must watch `signal` and stop when it fires. Throw on failure; a failed run never counts against your
plugin's error budget. Throw an error with `code = "NEEDS_INTERACTION"` when a host needs a person to sign in first.
- Handlers have no request and no session. Take what you need from the input and `hostId`.
- Keep your REST routes. Inside one, call `ctx.operations.run(id, { hostId, input })` so a button press goes through
the same checks and lands in the same history.
- To run another plugin's action, add `operations:invoke`, list it in `operations.invokes` and call
`ctx.operations.invoke(id, { hostId, input, via: "automation" })`. It acts as the current user and rules still apply.
- `risk` is your plugin's claim. Plugins run in-process, so this is not a sandbox. Actions from plugins not signed as
official count as `change` when rules decide what needs a person, and stay off the assistant and MCP until an admin
allows them.
- Set `engine.termix` to at least `>=26.11.1-0`. Keep `engine.api` as it was unless you import something new from
`/ui`, or the phone app will ask people to update to see your plugin.
### Shared packages ### Shared packages
These come from Termix and are never bundled: `@termix-ssh/plugin-sdk`, `express`, `ssh2`, `ws`, `multer`, These come from Termix and are never bundled: `@termix-ssh/plugin-sdk`, `express`, `ssh2`, `ws`, `multer`,
@@ -962,6 +1026,7 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
| `@termix-ssh/plugin-sdk/db` | `defineTable` and column builders | | `@termix-ssh/plugin-sdk/db` | `defineTable` and column builders |
| `@termix-ssh/plugin-sdk/manifest` | `PluginManifest` type and manifest checks | | `@termix-ssh/plugin-sdk/manifest` | `PluginManifest` type and manifest checks |
| `@termix-ssh/plugin-sdk/capabilities` | The capability catalog | | `@termix-ssh/plugin-sdk/capabilities` | The capability catalog |
| `@termix-ssh/plugin-sdk/operations` | Types, input checks and JSON Schema for declared actions |
| `@termix-ssh/plugin-sdk/settings` | Settings field validation | | `@termix-ssh/plugin-sdk/settings` | Settings field validation |
| `@termix-ssh/plugin-sdk/testing` | `createMockCtx`, `createTestDb`, `renderWithApp`, `createFakeContext` | | `@termix-ssh/plugin-sdk/testing` | `createMockCtx`, `createTestDb`, `renderWithApp`, `createFakeContext` |
| `@termix-ssh/plugin-sdk/vitest-preset` | `pluginVitestConfig` | | `@termix-ssh/plugin-sdk/vitest-preset` | `pluginVitestConfig` |
@@ -993,6 +1058,8 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
| `desktop:local` | high | Use the user's own computer, desktop app only | | `desktop:local` | high | Use the user's own computer, desktop app only |
| `hosts:export` | high | Export hosts with their saved logins | | `hosts:export` | high | Export hosts with their saved logins |
| `notify:hub` | high | Receive every alert (the alerts hub only) | | `notify:hub` | high | Receive every alert (the alerts hub only) |
| `operations:invoke` | high | Run other plugins' declared actions, listed in `invokes` |
| `operations:approve` | high | Decide which actions need a person |
| `ui:layout` | high | Replace the whole app layout | | `ui:layout` | high | Replace the whole app layout |
| `hosts:write` | medium | Add and edit hosts | | `hosts:write` | medium | Add and edit hosts |
| `hosts:status-provide` | medium | Be the source of host status dots | | `hosts:status-provide` | medium | Be the source of host status dots |
@@ -1008,6 +1075,7 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
| `users:read` | medium | Usernames, roles and UI language | | `users:read` | medium | Usernames, roles and UI language |
| `events:core` | medium | Listen to core events | | `events:core` | medium | Listen to core events |
| `notify:send` | medium | Send alerts | | `notify:send` | medium | Send alerts |
| `operations:provide` | medium | Offer declared actions and events |
| `audit:read` | medium | Read the audit log | | `audit:read` | medium | Read the audit log |
| `desktop:window` | medium | Open windows in the desktop app | | `desktop:window` | medium | Open windows in the desktop app |
| `hosts:read` | low | Read hosts the user can see | | `hosts:read` | low | Read hosts the user can see |
+5 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@termix-ssh/plugin-sdk", "name": "@termix-ssh/plugin-sdk",
"version": "1.0.22", "version": "1.0.25",
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.", "description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
"license": "Apache-2.0", "license": "Apache-2.0",
"repository": { "repository": {
@@ -61,6 +61,10 @@
"types": "./dist/openapi.d.ts", "types": "./dist/openapi.d.ts",
"default": "./dist/openapi.js" "default": "./dist/openapi.js"
}, },
"./operations": {
"types": "./dist/operations.d.ts",
"default": "./dist/operations.js"
},
"./settings": { "./settings": {
"types": "./dist/settings.d.ts", "types": "./dist/settings.d.ts",
"default": "./dist/settings.js" "default": "./dist/settings.js"
@@ -536,6 +536,31 @@
} }
} }
}, },
"hostSections": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "titleKey"],
"properties": {
"id": { "type": "string", "minLength": 1 },
"titleKey": { "type": "string", "minLength": 1 },
"icon": { "type": "string", "minLength": 1 }
}
}
},
"operations": {
"type": "object",
"description": "What this plugin can do. Every surface finds these on its own. See the operations section of PLUGIN-GUIDE.md.",
"additionalProperties": false,
"properties": {
"actions": { "type": "array", "items": { "type": "object" } },
"events": { "type": "array", "items": { "type": "object" } },
"resources": { "type": "array", "items": { "type": "object" } },
"signals": { "type": "array", "items": { "type": "object" } },
"invokes": { "type": "array", "items": { "type": "string" } }
}
},
"dashboardCards": { "dashboardCards": {
"type": "array", "type": "array",
"items": { "items": {
+137
View File
@@ -14,6 +14,11 @@ import type { HostSshOptions } from "./ssh-options.js";
export type { HostSshOptions } from "./ssh-options.js"; export type { HostSshOptions } from "./ssh-options.js";
import type { PluginManifest, PluginSettingsField } from "./manifest.js"; import type { PluginManifest, PluginSettingsField } from "./manifest.js";
import type { PluginTableDefinition } from "./db.js"; import type { PluginTableDefinition } from "./db.js";
import type {
OperationResult,
OperationRisk,
OperationSurfaces,
} from "./operations.js";
/** /**
* Thrown when a plugin calls a guarded member without the capability. * Thrown when a plugin calls a guarded member without the capability.
@@ -3104,6 +3109,136 @@ export interface PluginPlugins {
list: () => Promise<PluginSummary[]>; list: () => Promise<PluginSummary[]>;
} }
/** What a handler gets besides its input. */
export interface PluginOperationContext {
hostId?: number;
/** The row a resource action targets. */
resourceKey?: string;
/** Null for a background read. */
runId: string | null;
/** Nothing may change when this is set. */
dryRun: boolean;
/** Fires when the run is cancelled, times out or the plugin stops. */
signal: AbortSignal;
log: (line: string) => void;
}
export interface PluginOperationHandler {
run: (
input: Record<string, unknown>,
context: PluginOperationContext,
) => Promise<OperationResult> | OperationResult;
/** The dry run text for an action declared with dryRun "describe". */
describe?: (
input: Record<string, unknown>,
context: PluginOperationContext,
) => string | Promise<string>;
}
export interface PluginOperationInvokeOptions {
hostId?: number;
resourceKey?: string;
input?: Record<string, unknown>;
dryRun?: boolean;
/** A person agreed to a destructive action. */
confirmed?: boolean;
idempotencyKey?: string;
batchId?: string;
/** What this plugin is doing: automation, assistant, mcp, rollout and so on. */
via?: string;
}
export interface PluginOperationOutcome {
runId: string | null;
status: string;
summary?: string;
changed?: boolean;
data?: unknown;
error?: string;
errorCode?: string;
approvalId?: string;
}
export interface PluginOperationApprovalInput {
actionId: string;
pluginId: string;
risk: OperationRisk;
/** What rules should use. Plugins not signed as official count as change. */
effectiveRisk: OperationRisk;
origin: { kind: string; pluginId?: string; via?: string };
userId: string;
hostId?: number;
hostTags?: string[];
batchId?: string;
}
export interface PluginOperationApprovalDecision {
decision: "allow" | "approve" | "deny";
reason?: string;
allowSelf?: boolean;
expiresInMinutes?: number;
}
export interface PluginOperationEvent {
id: string;
eventId: string;
pluginId: string;
hostId?: number;
severity?: string;
payload?: unknown;
runId?: string;
depth: number;
at: string;
}
/**
* Declared actions and events. See contributes.operations in the manifest.
* register and emit need operations:provide. invoke, list and onEvent need
* operations:invoke, and invoke only reaches ids in the manifest's invokes list.
*/
export interface PluginOperations {
register: (id: string, handler: PluginOperationHandler) => void;
/**
* Runs one of this plugin's own actions from its route, so a button press
* goes through the same checks and lands in the same history.
*/
run: (
actionId: string,
options?: PluginOperationInvokeOptions,
) => Promise<PluginOperationOutcome>;
invoke: (
actionId: string,
options?: PluginOperationInvokeOptions,
) => Promise<PluginOperationOutcome>;
list: (options?: {
lang?: string;
surface?: keyof OperationSurfaces;
}) => Promise<unknown>;
emit: (
eventId: string,
payload?: unknown,
options?: { hostId?: number },
) => Promise<void>;
onEvent: (subscription: {
pattern: string;
handler: (event: PluginOperationEvent) => void | Promise<void>;
userId?: string;
cooldownMs?: number;
}) => () => void;
/** Rules for what needs a person. Needs operations:approve. */
approvals: {
provide: (
provider: (
input: PluginOperationApprovalInput,
) =>
| PluginOperationApprovalDecision
| null
| undefined
| Promise<PluginOperationApprovalDecision | null | undefined>,
) => () => void;
};
}
export interface PluginContext { export interface PluginContext {
readonly pluginId: string; readonly pluginId: string;
readonly manifest: PluginManifest; readonly manifest: PluginManifest;
@@ -3144,6 +3279,8 @@ export interface PluginContext {
readonly schedule: PluginSchedule; readonly schedule: PluginSchedule;
/** Core's notification channels. Needs notify:send. */ /** Core's notification channels. Needs notify:send. */
readonly notify: PluginNotify; readonly notify: PluginNotify;
/** Declared actions and events, reachable from every surface. */
readonly operations: PluginOperations;
/** Outbound HTTP through core's SSRF guard. Needs network:outbound. */ /** Outbound HTTP through core's SSRF guard. Needs network:outbound. */
readonly fetch: PluginFetch; readonly fetch: PluginFetch;
/** Plain reachability checks. Needs network:probe. */ /** Plain reachability checks. Needs network:probe. */
+3
View File
@@ -52,6 +52,8 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
entry("ssh:transport", "high"), entry("ssh:transport", "high"),
entry("device:serial", "high"), entry("device:serial", "high"),
entry("notify:hub", "high"), entry("notify:hub", "high"),
entry("operations:invoke", "high"),
entry("operations:approve", "high"),
entry("ui:layout", "high"), entry("ui:layout", "high"),
entry("hosts:write", "medium"), entry("hosts:write", "medium"),
@@ -68,6 +70,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
entry("users:read", "medium"), entry("users:read", "medium"),
entry("events:core", "medium"), entry("events:core", "medium"),
entry("notify:send", "medium"), entry("notify:send", "medium"),
entry("operations:provide", "medium"),
entry("audit:read", "medium"), entry("audit:read", "medium"),
entry("desktop:window", "medium"), entry("desktop:window", "medium"),
entry("desktop:local", "high"), entry("desktop:local", "high"),
+21
View File
@@ -670,6 +670,22 @@ export type HostStatusIndicatorContribution = HostBadgeContribution;
/** A control in the host editor's toolbar. */ /** A control in the host editor's toolbar. */
export type HostEditorToolbarItemContribution = HostBadgeContribution; export type HostEditorToolbarItemContribution = HostBadgeContribution;
export interface HostSectionProps {
host: PluginHostRecord;
isVisible: boolean;
shell: ShellApi;
}
export interface HostSectionContribution {
/** Declare it in manifest contributes.hostSections too. */
id: string;
titleKey: string;
/** Lower sorts first. */
order?: number;
when?: (host: PluginHostRecord) => boolean;
component: ComponentType<HostSectionProps>;
}
export interface HostContextMenuItemContribution { export interface HostContextMenuItemContribution {
id: string; id: string;
titleKey: string; titleKey: string;
@@ -1172,6 +1188,11 @@ export interface TermixApp extends TermixAppInfo {
registerHostContextMenuItem: ( registerHostContextMenuItem: (
item: HostContextMenuItemContribution, item: HostContextMenuItemContribution,
) => Disposer; ) => Disposer;
/**
* A card on the host page. Core 26.11.1 and newer; call it as
* app.registerHostSection?.(...) if your plugin allows older cores.
*/
registerHostSection: (section: HostSectionContribution) => Disposer;
registerPaletteEntry: (entry: PaletteEntryContribution) => Disposer; registerPaletteEntry: (entry: PaletteEntryContribution) => Disposer;
registerPaletteGroup: (group: PaletteGroupContribution) => Disposer; registerPaletteGroup: (group: PaletteGroupContribution) => Disposer;
registerKeybindingAction: (action: KeybindingActionContribution) => Disposer; registerKeybindingAction: (action: KeybindingActionContribution) => Disposer;
+20 -1
View File
@@ -12,6 +12,11 @@
import semver from "semver"; import semver from "semver";
import { isKnownCapability } from "./capabilities.js"; import { isKnownCapability } from "./capabilities.js";
import {
checkOperationReferences,
validateOperations,
type PluginOperationsContribution,
} from "./operations.js";
import { import {
ENV_NAME_PATTERN, ENV_NAME_PATTERN,
MAX_ENV_DESCRIPTION_LENGTH, MAX_ENV_DESCRIPTION_LENGTH,
@@ -37,7 +42,7 @@ export {
* "^1.2" needs 1.2 or later). engine.termix is checked against the core * "^1.2" needs 1.2 or later). engine.termix is checked against the core
* version by the server. * version by the server.
*/ */
export const PLUGIN_API_VERSION = "1.13.0"; export const PLUGIN_API_VERSION = "1.14.0";
/** The API major, the default engine.api for a new plugin. */ /** The API major, the default engine.api for a new plugin. */
export const SUPPORTED_PLUGIN_API_VERSION = String( export const SUPPORTED_PLUGIN_API_VERSION = String(
@@ -465,6 +470,10 @@ export interface PluginContributions {
tabs?: PluginTabContribution[]; tabs?: PluginTabContribution[];
panels?: PluginViewContribution[]; panels?: PluginViewContribution[];
dashboardCards?: PluginViewContribution[]; dashboardCards?: PluginViewContribution[];
/** Cards on the host page, filled with app.registerHostSection. */
hostSections?: PluginViewContribution[];
/** What this plugin can do, for every surface to find on its own. */
operations?: PluginOperationsContribution;
/** /**
* The frontend also runs on anonymous guest pages (shared-session and * The frontend also runs on anonymous guest pages (shared-session and
* collab links), where it is activated with app.guest set. * collab links), where it is activated with app.guest set.
@@ -779,6 +788,8 @@ const ALLOWED_CONTRIBUTES = new Set([
"tabs", "tabs",
"panels", "panels",
"dashboardCards", "dashboardCards",
"hostSections",
"operations",
"guest", "guest",
"guestViews", "guestViews",
"actions", "actions",
@@ -1500,6 +1511,8 @@ function validateContributes(
validateTabs(contributes.tabs, errors); validateTabs(contributes.tabs, errors);
validateViews(contributes.panels, "panels", errors); validateViews(contributes.panels, "panels", errors);
validateViews(contributes.dashboardCards, "dashboardCards", errors); validateViews(contributes.dashboardCards, "dashboardCards", errors);
validateViews(contributes.hostSections, "hostSections", errors);
validateOperations(contributes.operations, pluginId, errors);
if ( if (
contributes.guest !== undefined && contributes.guest !== undefined &&
typeof contributes.guest !== "boolean" typeof contributes.guest !== "boolean"
@@ -2622,6 +2635,12 @@ export function parseManifest(raw: unknown): ParsedManifest {
); );
} }
} }
checkOperationReferences(
manifest.contributes?.operations,
manifest.capabilities,
(name) => declared.has(qualifyPermission(manifest.id, name)),
errors,
);
// A settings field may gate on one of this plugin's own permissions, or on // A settings field may gate on one of this plugin's own permissions, or on
// a core/other-plugin id used as given. Only the first form is checkable // a core/other-plugin id used as given. Only the first form is checkable
// here, and an undeclared one would hide the field from every admin. // here, and an undeclared one would hide the field from every admin.
+951
View File
@@ -0,0 +1,951 @@
/**
* The operations contract: what a plugin declares it can do, in one shape every
* surface reads (Automations, Fleets, AI, CLI, Mobile, palette, API, MCP).
*
* Declarations are data in the manifest, so a surface can list and describe an
* action without loading the plugin's code. Handlers are registered at
* activation with ctx.operations.register and must match a declaration.
*
* No Node imports: the frontend, the CLI and Mobile read this file too.
*/
import { coerceSettingValue, validateSettingValue } from "./settings.js";
import type { PluginSettingsField, PluginSettingsOption } from "./manifest.js";
export const OPERATION_RISKS = ["read", "change", "destructive"] as const;
export type OperationRisk = (typeof OPERATION_RISKS)[number];
export const OPERATION_SCOPES = ["host", "resource", "server"] as const;
export type OperationScope = (typeof OPERATION_SCOPES)[number];
export const OPERATION_HOST_ACCESS = [
"connect",
"view",
"edit",
"manage",
] as const;
export type OperationHostAccess = (typeof OPERATION_HOST_ACCESS)[number];
export const OPERATION_DRY_RUN = ["native", "describe"] as const;
/** native: the handler checks ctx.dryRun. describe: core reports its text. */
export type OperationDryRun = (typeof OPERATION_DRY_RUN)[number];
export const OPERATION_FIELD_TYPES = [
"boolean",
"string",
"number",
"select",
"multiselect",
"secret",
"textarea",
"json",
"resource",
] as const;
export type OperationFieldType = (typeof OPERATION_FIELD_TYPES)[number];
export const OPERATION_SEVERITIES = ["info", "warning", "critical"] as const;
export type OperationSeverity = (typeof OPERATION_SEVERITIES)[number];
export const SIGNAL_KINDS = ["count", "gauge", "status", "list"] as const;
export type SignalKind = (typeof SIGNAL_KINDS)[number];
export const SIGNAL_STATUSES = ["ok", "warn", "fail", "unknown"] as const;
export type SignalStatus = (typeof SIGNAL_STATUSES)[number];
export const RESOURCE_COLUMN_TYPES = [
"text",
"status",
"bytes",
"percent",
"time",
"tags",
] as const;
export type ResourceColumnType = (typeof RESOURCE_COLUMN_TYPES)[number];
/** One input or output of an action. A subset of a settings field, plus rules. */
export interface OperationField {
key: string;
type: OperationFieldType;
labelKey?: string;
descriptionKey?: string;
placeholderKey?: string;
default?: unknown;
options?: PluginSettingsOption[];
min?: number;
max?: number;
/** Key of a boolean field in the same list that must be on. */
requires?: string;
group?: string;
required?: boolean;
/** string and textarea only. A regular expression the whole value matches. */
pattern?: string;
/** Read action whose rows fill the choices, for a list known only at run time. */
optionsFrom?: string;
/** type "resource" only: the resource id whose list fills the picker. */
resource?: string;
/** Outputs only: dropped before a run is stored or shown. */
sensitive?: boolean;
}
export interface OperationWhen {
/** Host setting values that must match, such as { enableDocker: true }. */
settings?: Record<string, boolean | string | number>;
/** Lowercase OS names. A host with no known OS passes. */
os?: string[];
}
export interface OperationSurfaces {
/** Offered to the AI assistant. Off unless set. */
assistant?: boolean;
/** Offered over MCP. Off unless set. */
mcp?: boolean;
/** Set false to keep out of the command palette. */
palette?: boolean;
/** Set false to keep out of Automations. */
automations?: boolean;
/** Set false to keep out of the phone app. */
mobile?: boolean;
/** Set false to keep out of the CLI. */
cli?: boolean;
}
export interface OperationAction {
/** `<pluginId>.<name>`. */
id: string;
/** Bump when inputs change in a way saved references cannot follow. */
version?: number;
titleKey: string;
descriptionKey?: string;
icon?: string;
scope: OperationScope;
/** Required for scope "resource": the resource this acts on. */
resource?: string;
inputs?: OperationField[];
outputs?: OperationField[];
risk: OperationRisk;
/** One of this plugin's own permissions, short or full. */
permission: string;
/** Lowest share level. Required for host and resource scopes. */
hostAccess?: OperationHostAccess;
dryRun?: OperationDryRun;
/** A read action of this plugin that proves the result. */
check?: string;
/** An action of this plugin that reverses this one. */
undo?: string;
/** Safe to run twice, so a rollout may retry it. */
idempotent?: boolean;
/** Only one such action per host at a time. */
exclusive?: boolean;
timeoutMs?: number;
when?: OperationWhen;
surfaces?: OperationSurfaces;
}
export interface OperationEvent {
id: string;
titleKey: string;
descriptionKey?: string;
severity?: OperationSeverity;
scope: "host" | "server";
payload?: OperationField[];
}
export interface ResourceColumn {
key: string;
titleKey: string;
type?: ResourceColumnType;
}
export interface OperationResource {
id: string;
titleKey: string;
icon?: string;
/** Read action that returns { rows: [...] } for one host. */
list: string;
/** Row field that is unique on a host. */
key: string;
/** Row field shown as the name. */
name: string;
/** Row field holding a status word. */
status?: string;
/** Row field holding a link, for auto links on the homepage. */
url?: string;
/** Tab type that opens this on a host. */
tab?: string;
columns?: ResourceColumn[];
filters?: string[];
}
export interface OperationSignal {
id: string;
titleKey: string;
/** Read action that returns a SignalResult. */
read: string;
scope: "host" | "server";
kind: SignalKind;
refreshSec?: number;
/** Action offered as the fix when this signal is not ok. */
fix?: string;
}
export interface PluginOperationsContribution {
actions?: OperationAction[];
events?: OperationEvent[];
resources?: OperationResource[];
signals?: OperationSignal[];
/**
* Actions this plugin may run through ctx.operations.invoke: exact ids,
* `<pluginId>.*`, or `*`.
*/
invokes?: string[];
}
/** What a signal's read action returns. */
export interface SignalResult {
value?: number | string | null;
status: SignalStatus;
label?: string;
items?: { label: string; status?: SignalStatus }[];
link?: { tab?: string; data?: Record<string, unknown> };
}
/** What a resource's list action returns. */
export interface ResourceListResult {
rows: Record<string, unknown>[];
}
/** What a handler returns. */
export interface OperationResult {
/** One plain line for history and per host results. */
summary: string;
/** Whether anything actually changed. */
changed?: boolean;
data?: unknown;
/** Input for the `undo` action that reverses this exact run. */
undoInput?: Record<string, unknown>;
}
export const OPERATION_ID_PATTERN = /^[a-z0-9][a-z0-9-]*\.[a-z0-9][a-z0-9-]*$/;
const FIELD_KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
const INVOKE_PATTERN = /^(\*|[a-z0-9][a-z0-9-]*\.(\*|[a-z0-9][a-z0-9-]*))$/;
export const MAX_OPERATION_TIMEOUT_MS = 3_600_000;
export const REDACTED_VALUE = "[redacted]";
const ALLOWED_OPERATIONS = [
"actions",
"events",
"resources",
"signals",
"invokes",
];
const ALLOWED_ACTION = [
"id",
"version",
"titleKey",
"descriptionKey",
"icon",
"scope",
"resource",
"inputs",
"outputs",
"risk",
"permission",
"hostAccess",
"dryRun",
"check",
"undo",
"idempotent",
"exclusive",
"timeoutMs",
"when",
"surfaces",
];
const ALLOWED_FIELD = [
"key",
"type",
"labelKey",
"descriptionKey",
"placeholderKey",
"default",
"options",
"min",
"max",
"requires",
"group",
"required",
"pattern",
"optionsFrom",
"resource",
"sensitive",
];
const ALLOWED_EVENT = [
"id",
"titleKey",
"descriptionKey",
"severity",
"scope",
"payload",
];
const ALLOWED_RESOURCE = [
"id",
"titleKey",
"icon",
"list",
"key",
"name",
"status",
"url",
"tab",
"columns",
"filters",
];
const ALLOWED_SIGNAL = [
"id",
"titleKey",
"read",
"scope",
"kind",
"refreshSec",
"fix",
];
const ALLOWED_SURFACES = [
"assistant",
"mcp",
"palette",
"automations",
"mobile",
"cli",
];
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function rejectUnknown(
raw: Record<string, unknown>,
allowed: string[],
where: string,
errors: string[],
): void {
for (const key of Object.keys(raw)) {
if (!allowed.includes(key))
errors.push(`Unknown field "${key}" in ${where}`);
}
}
function requireEnum(
value: unknown,
allowed: readonly string[],
where: string,
errors: string[],
): void {
if (typeof value !== "string" || !allowed.includes(value)) {
errors.push(`${where} must be one of: ${allowed.join(", ")}`);
}
}
function requireText(value: unknown, where: string, errors: string[]): void {
if (typeof value !== "string" || value.trim().length === 0) {
errors.push(`${where} must be a non-empty string`);
}
}
function optionalText(value: unknown, where: string, errors: string[]): void {
if (value !== undefined) requireText(value, where, errors);
}
function optionalBoolean(
value: unknown,
where: string,
errors: string[],
): void {
if (value !== undefined && typeof value !== "boolean") {
errors.push(`${where} must be a boolean`);
}
}
function requireId(
value: unknown,
pluginId: string | undefined,
where: string,
seen: Set<string>,
errors: string[],
): void {
if (typeof value !== "string" || !OPERATION_ID_PATTERN.test(value)) {
errors.push(`${where}.id must look like "<pluginId>.<name>" in lowercase`);
return;
}
if (pluginId && !value.startsWith(`${pluginId}.`)) {
errors.push(`${where}.id must start with "${pluginId}."`);
}
if (seen.has(value)) errors.push(`${where}.id "${value}" is declared twice`);
seen.add(value);
}
function eachObject(
value: unknown,
where: string,
errors: string[],
each: (raw: Record<string, unknown>, at: string) => void,
): void {
if (value === undefined) return;
if (!Array.isArray(value)) {
errors.push(`${where} must be an array`);
return;
}
value.forEach((raw, index) => {
const at = `${where}[${index}]`;
if (!isObject(raw)) {
errors.push(`${at} must be an object`);
return;
}
each(raw, at);
});
}
function validateFields(value: unknown, where: string, errors: string[]): void {
const keys = new Set<string>();
eachObject(value, where, errors, (raw, at) => {
rejectUnknown(raw, ALLOWED_FIELD, at, errors);
if (typeof raw.key !== "string" || !FIELD_KEY_PATTERN.test(raw.key)) {
errors.push(`${at}.key must be a short name starting with a letter`);
} else if (keys.has(raw.key)) {
errors.push(`${at}.key "${raw.key}" is declared twice`);
} else {
keys.add(raw.key);
}
requireEnum(raw.type, OPERATION_FIELD_TYPES, `${at}.type`, errors);
optionalText(raw.labelKey, `${at}.labelKey`, errors);
optionalText(raw.descriptionKey, `${at}.descriptionKey`, errors);
optionalText(raw.placeholderKey, `${at}.placeholderKey`, errors);
optionalText(raw.group, `${at}.group`, errors);
optionalText(raw.requires, `${at}.requires`, errors);
optionalText(raw.optionsFrom, `${at}.optionsFrom`, errors);
optionalBoolean(raw.required, `${at}.required`, errors);
optionalBoolean(raw.sensitive, `${at}.sensitive`, errors);
if (raw.type === "resource" && typeof raw.resource !== "string") {
errors.push(`${at}.resource is required for type "resource"`);
}
if (raw.resource !== undefined && raw.type !== "resource") {
errors.push(`${at}.resource needs type "resource"`);
}
if (raw.type === "secret" && raw.default !== undefined) {
errors.push(`${at}.default is not allowed on a secret`);
}
if (
(raw.type === "select" || raw.type === "multiselect") &&
!raw.optionsFrom &&
(!Array.isArray(raw.options) || raw.options.length === 0)
) {
errors.push(`${at} needs options or optionsFrom`);
}
for (const bound of ["min", "max"] as const) {
if (raw[bound] !== undefined && typeof raw[bound] !== "number") {
errors.push(`${at}.${bound} must be a number`);
}
}
if (raw.pattern !== undefined) {
if (typeof raw.pattern !== "string") {
errors.push(`${at}.pattern must be a string`);
} else {
try {
new RegExp(raw.pattern);
} catch {
errors.push(`${at}.pattern is not a valid regular expression`);
}
}
}
});
}
function validateWhen(value: unknown, at: string, errors: string[]): void {
if (value === undefined) return;
if (!isObject(value)) {
errors.push(`${at} must be an object`);
return;
}
rejectUnknown(value, ["settings", "os"], at, errors);
if (value.settings !== undefined && !isObject(value.settings)) {
errors.push(`${at}.settings must be an object`);
}
if (
value.os !== undefined &&
(!Array.isArray(value.os) ||
value.os.some((entry) => typeof entry !== "string"))
) {
errors.push(`${at}.os must be an array of strings`);
}
}
function validateAction(
raw: Record<string, unknown>,
at: string,
errors: string[],
): void {
rejectUnknown(raw, ALLOWED_ACTION, at, errors);
requireText(raw.titleKey, `${at}.titleKey`, errors);
optionalText(raw.descriptionKey, `${at}.descriptionKey`, errors);
optionalText(raw.icon, `${at}.icon`, errors);
requireEnum(raw.scope, OPERATION_SCOPES, `${at}.scope`, errors);
requireEnum(raw.risk, OPERATION_RISKS, `${at}.risk`, errors);
requireText(raw.permission, `${at}.permission`, errors);
validateFields(raw.inputs, `${at}.inputs`, errors);
validateFields(raw.outputs, `${at}.outputs`, errors);
validateWhen(raw.when, `${at}.when`, errors);
optionalBoolean(raw.idempotent, `${at}.idempotent`, errors);
optionalBoolean(raw.exclusive, `${at}.exclusive`, errors);
if (
raw.version !== undefined &&
(!Number.isInteger(raw.version) || (raw.version as number) < 1)
) {
errors.push(`${at}.version must be a whole number from 1`);
}
if (raw.dryRun !== undefined) {
requireEnum(raw.dryRun, OPERATION_DRY_RUN, `${at}.dryRun`, errors);
}
if (
raw.timeoutMs !== undefined &&
(typeof raw.timeoutMs !== "number" ||
raw.timeoutMs <= 0 ||
raw.timeoutMs > MAX_OPERATION_TIMEOUT_MS)
) {
errors.push(`${at}.timeoutMs must be above 0 and at most 1 hour`);
}
if (raw.scope === "resource" && typeof raw.resource !== "string") {
errors.push(`${at}.resource is required for scope "resource"`);
}
if (raw.scope !== "resource" && raw.resource !== undefined) {
errors.push(`${at}.resource needs scope "resource"`);
}
if (raw.scope === "server" && raw.hostAccess !== undefined) {
errors.push(`${at}.hostAccess does not apply to scope "server"`);
}
if (raw.scope === "host" || raw.scope === "resource") {
requireEnum(
raw.hostAccess,
OPERATION_HOST_ACCESS,
`${at}.hostAccess`,
errors,
);
if (
raw.risk === "destructive" &&
(raw.hostAccess === "connect" || raw.hostAccess === "view")
) {
errors.push(`${at}.hostAccess must be edit or manage when destructive`);
}
}
if (raw.surfaces !== undefined) {
if (!isObject(raw.surfaces)) {
errors.push(`${at}.surfaces must be an object`);
} else {
rejectUnknown(raw.surfaces, ALLOWED_SURFACES, `${at}.surfaces`, errors);
for (const [key, value] of Object.entries(raw.surfaces)) {
optionalBoolean(value, `${at}.surfaces.${key}`, errors);
}
}
}
}
function validateResource(
raw: Record<string, unknown>,
at: string,
errors: string[],
): void {
rejectUnknown(raw, ALLOWED_RESOURCE, at, errors);
requireText(raw.titleKey, `${at}.titleKey`, errors);
requireText(raw.list, `${at}.list`, errors);
requireText(raw.key, `${at}.key`, errors);
requireText(raw.name, `${at}.name`, errors);
optionalText(raw.icon, `${at}.icon`, errors);
optionalText(raw.status, `${at}.status`, errors);
optionalText(raw.url, `${at}.url`, errors);
optionalText(raw.tab, `${at}.tab`, errors);
eachObject(raw.columns, `${at}.columns`, errors, (column, where) => {
rejectUnknown(column, ["key", "titleKey", "type"], where, errors);
requireText(column.key, `${where}.key`, errors);
requireText(column.titleKey, `${where}.titleKey`, errors);
if (column.type !== undefined) {
requireEnum(column.type, RESOURCE_COLUMN_TYPES, `${where}.type`, errors);
}
});
if (
raw.filters !== undefined &&
(!Array.isArray(raw.filters) ||
raw.filters.some((entry) => typeof entry !== "string"))
) {
errors.push(`${at}.filters must be an array of strings`);
}
}
/** Shape checks for contributes.operations, run by validateManifest. */
export function validateOperations(
value: unknown,
pluginId: string | undefined,
errors: string[],
): void {
if (value === undefined) return;
const root = "contributes.operations";
if (!isObject(value)) {
errors.push(`Field "${root}" must be an object`);
return;
}
rejectUnknown(value, ALLOWED_OPERATIONS, `"${root}"`, errors);
const ids = new Set<string>();
eachObject(value.actions, `${root}.actions`, errors, (raw, at) => {
requireId(raw.id, pluginId, at, ids, errors);
validateAction(raw, at, errors);
});
const eventIds = new Set<string>();
eachObject(value.events, `${root}.events`, errors, (raw, at) => {
requireId(raw.id, pluginId, at, eventIds, errors);
rejectUnknown(raw, ALLOWED_EVENT, at, errors);
requireText(raw.titleKey, `${at}.titleKey`, errors);
optionalText(raw.descriptionKey, `${at}.descriptionKey`, errors);
requireEnum(raw.scope, ["host", "server"], `${at}.scope`, errors);
if (raw.severity !== undefined) {
requireEnum(raw.severity, OPERATION_SEVERITIES, `${at}.severity`, errors);
}
validateFields(raw.payload, `${at}.payload`, errors);
});
const resourceIds = new Set<string>();
eachObject(value.resources, `${root}.resources`, errors, (raw, at) => {
requireId(raw.id, pluginId, at, resourceIds, errors);
validateResource(raw, at, errors);
});
const signalIds = new Set<string>();
eachObject(value.signals, `${root}.signals`, errors, (raw, at) => {
requireId(raw.id, pluginId, at, signalIds, errors);
rejectUnknown(raw, ALLOWED_SIGNAL, at, errors);
requireText(raw.titleKey, `${at}.titleKey`, errors);
requireText(raw.read, `${at}.read`, errors);
requireEnum(raw.scope, ["host", "server"], `${at}.scope`, errors);
requireEnum(raw.kind, SIGNAL_KINDS, `${at}.kind`, errors);
optionalText(raw.fix, `${at}.fix`, errors);
if (
raw.refreshSec !== undefined &&
(typeof raw.refreshSec !== "number" || raw.refreshSec < 10)
) {
errors.push(`${at}.refreshSec must be at least 10`);
}
});
if (value.invokes !== undefined) {
if (
!Array.isArray(value.invokes) ||
value.invokes.some(
(entry) => typeof entry !== "string" || !INVOKE_PATTERN.test(entry),
)
) {
errors.push(
`${root}.invokes must list action ids, "<pluginId>.*" or "*"`,
);
}
}
}
/**
* The cross-field rules: every id one declaration names must exist in this
* plugin, or a surface would offer a button that goes nowhere.
*/
export function checkOperationReferences(
operations: PluginOperationsContribution | undefined,
capabilities: readonly string[],
hasPermission: (name: string) => boolean,
errors: string[],
): void {
if (!operations) return;
const root = "contributes.operations";
const actions = new Map(
(operations.actions ?? []).map((action) => [action.id, action]),
);
const resources = new Set((operations.resources ?? []).map((r) => r.id));
const declaresAnything =
actions.size > 0 ||
(operations.events?.length ?? 0) > 0 ||
resources.size > 0 ||
(operations.signals?.length ?? 0) > 0;
if (declaresAnything && !capabilities.includes("operations:provide")) {
errors.push(`${root} needs the "operations:provide" capability`);
}
if (
operations.invokes?.length &&
!capabilities.includes("operations:invoke")
) {
errors.push(`${root}.invokes needs the "operations:invoke" capability`);
}
const readAction = (id: string, where: string) => {
const target = actions.get(id);
if (!target) errors.push(`${where} "${id}" is not a declared action`);
else if (target.risk !== "read") {
errors.push(`${where} "${id}" must be a read action`);
}
};
for (const action of actions.values()) {
const at = `${root}.actions["${action.id}"]`;
if (!hasPermission(action.permission)) {
errors.push(
`${at}.permission "${action.permission}" is not declared in contributes.permissions`,
);
}
if (action.resource && !resources.has(action.resource)) {
errors.push(`${at}.resource "${action.resource}" is not declared`);
}
if (action.check) readAction(action.check, `${at}.check`);
if (action.undo && !actions.has(action.undo)) {
errors.push(`${at}.undo "${action.undo}" is not a declared action`);
}
for (const field of action.inputs ?? []) {
if (field.optionsFrom) {
readAction(field.optionsFrom, `${at} input "${field.key}" optionsFrom`);
}
if (field.resource && !resources.has(field.resource)) {
errors.push(`${at} input "${field.key}" names an unknown resource`);
}
}
}
for (const resource of operations.resources ?? []) {
readAction(resource.list, `${root}.resources["${resource.id}"].list`);
}
for (const signal of operations.signals ?? []) {
const at = `${root}.signals["${signal.id}"]`;
readAction(signal.read, `${at}.read`);
if (signal.fix && !actions.has(signal.fix)) {
errors.push(`${at}.fix "${signal.fix}" is not a declared action`);
}
}
}
/** Whether a plugin's invokes list lets it run an action. */
export function invokeAllowed(
invokes: readonly string[] | undefined,
actionId: string,
): boolean {
for (const entry of invokes ?? []) {
if (entry === "*" || entry === actionId) return true;
if (entry.endsWith(".*") && actionId.startsWith(entry.slice(0, -1))) {
return true;
}
}
return false;
}
const RISK_RANK: Record<OperationRisk, number> = {
read: 0,
change: 1,
destructive: 2,
};
export function riskRank(risk: OperationRisk): number {
return RISK_RANK[risk];
}
/** A surface's own switch. The assistant and MCP are opt in, the rest opt out. */
export function onSurface(
action: Pick<OperationAction, "surfaces">,
surface: keyof OperationSurfaces,
): boolean {
const value = action.surfaces?.[surface];
if (surface === "assistant" || surface === "mcp") return value === true;
return value !== false;
}
/** Whether an action applies to a host, from fields any client can read. */
export function matchesWhen(
when: OperationWhen | undefined,
host: { settings?: Record<string, unknown>; os?: string | null },
): boolean {
if (!when) return true;
for (const [key, expected] of Object.entries(when.settings ?? {})) {
if (host.settings?.[key] !== expected) return false;
}
if (when.os?.length && host.os) {
if (!when.os.includes(host.os.toLowerCase())) return false;
}
return true;
}
function asSettingsField(field: OperationField): PluginSettingsField {
const type = field.type === "resource" ? "string" : field.type;
return { ...field, type } as PluginSettingsField;
}
function isEmpty(value: unknown): boolean {
return value === undefined || value === null || value === "";
}
export interface OperationInputResult {
values: Record<string, unknown>;
errors: string[];
}
/**
* Turns raw input into validated values. Keys no field declares are refused,
* so nothing a handler did not ask for ever reaches it.
*/
export function parseOperationInput(
fields: readonly OperationField[] | undefined,
raw: unknown,
): OperationInputResult {
const errors: string[] = [];
const values: Record<string, unknown> = {};
const list = fields ?? [];
if (raw !== undefined && raw !== null && !isObject(raw)) {
return { values, errors: ["Input must be an object"] };
}
const input = (raw ?? {}) as Record<string, unknown>;
for (const key of Object.keys(input)) {
if (!list.some((field) => field.key === key)) {
errors.push(`"${key}" is not an input of this action`);
}
}
for (const field of list) {
if (field.requires && input[field.requires] !== true) continue;
let value = input[field.key];
if (isEmpty(value)) {
if (field.default !== undefined) {
values[field.key] = field.default;
} else if (field.required) {
errors.push(`"${field.key}" is required`);
}
continue;
}
const settingsField = asSettingsField(field);
value = coerceSettingValue(settingsField, value);
if (field.type === "secret" && typeof value !== "string") {
errors.push(`"${field.key}" must be a string`);
continue;
}
if (field.type === "json" && typeof value === "string") {
try {
value = JSON.parse(value);
} catch {
errors.push(`"${field.key}" must be valid JSON`);
continue;
}
}
// A list known only at run time is checked by the handler.
const skipChoices =
field.optionsFrom &&
(field.type === "select" || field.type === "multiselect");
const problem = skipChoices
? field.type === "select"
? typeof value === "string"
? null
: `"${field.key}" must be a string`
: Array.isArray(value)
? null
: `"${field.key}" must be an array`
: validateSettingValue(settingsField, value);
if (problem) {
errors.push(problem);
continue;
}
if (
field.pattern &&
typeof value === "string" &&
!new RegExp(`^(?:${field.pattern})$`).test(value)
) {
errors.push(`"${field.key}" has the wrong format`);
continue;
}
values[field.key] = value;
}
return { values, errors };
}
/** A copy safe to store or show: secret and sensitive values are replaced. */
export function redactFields(
fields: readonly OperationField[] | undefined,
values: unknown,
): unknown {
if (!isObject(values)) return values;
const copy: Record<string, unknown> = { ...values };
for (const field of fields ?? []) {
if (field.type !== "secret" && !field.sensitive) continue;
if (field.key in copy) copy[field.key] = REDACTED_VALUE;
}
return copy;
}
/** Trims text to a byte-friendly length with a plain marker. */
export function capText(text: string, maxLength: number): string {
return text.length <= maxLength
? text
: `${text.slice(0, maxLength)}\n[cut at ${maxLength} characters]`;
}
function fieldSchema(field: OperationField): Record<string, unknown> {
const schema: Record<string, unknown> = {};
if (field.default !== undefined) schema.default = field.default;
const values = field.optionsFrom
? undefined
: field.options?.map((option) => option.value);
switch (field.type) {
case "boolean":
schema.type = "boolean";
break;
case "number":
schema.type = "number";
if (field.min !== undefined) schema.minimum = field.min;
if (field.max !== undefined) schema.maximum = field.max;
break;
case "select":
schema.type = "string";
if (values) schema.enum = values;
break;
case "multiselect":
schema.type = "array";
schema.items = values
? { type: "string", enum: values }
: { type: "string" };
break;
case "json":
break;
default:
schema.type = "string";
if (field.pattern) schema.pattern = `^(?:${field.pattern})$`;
}
if (field.type === "resource" && field.resource) {
schema["x-termix-resource"] = field.resource;
}
if (field.type === "secret") schema.writeOnly = true;
return schema;
}
/** JSON Schema for a field list, for AI tools, MCP and OpenAPI. */
export function toJsonSchema(
fields: readonly OperationField[] | undefined,
describe?: (field: OperationField) => string | undefined,
): Record<string, unknown> {
const properties: Record<string, unknown> = {};
const required: string[] = [];
for (const field of fields ?? []) {
const schema = fieldSchema(field);
const description = describe?.(field);
if (description) schema.description = description;
properties[field.key] = schema;
if (field.required && field.default === undefined && !field.requires) {
required.push(field.key);
}
}
return { type: "object", properties, required, additionalProperties: false };
}
+53 -1
View File
@@ -68,7 +68,11 @@ import type {
PluginSshKeyCredential, PluginSshKeyCredential,
PluginSshKeyCredentialInput, PluginSshKeyCredentialInput,
} from "./backend.js"; } from "./backend.js";
import type { PluginSyncHost, SyncEntityRegistration } from "./backend.js"; import type {
PluginOperationHandler,
PluginSyncHost,
SyncEntityRegistration,
} from "./backend.js";
import type { PluginDatabase } from "./backend.js"; import type { PluginDatabase } from "./backend.js";
import type { import type {
PluginExternalClientRequest, PluginExternalClientRequest,
@@ -355,6 +359,10 @@ export interface FakePluginContext {
}>; }>;
/** The hub the plugin registered with ctx.notify.serve, if any. */ /** The hub the plugin registered with ctx.notify.serve, if any. */
notifyHub: { current: PluginNotifyHub | null }; notifyHub: { current: PluginNotifyHub | null };
/** Handlers from ctx.operations.register, by action id. */
operationHandlers: Map<string, PluginOperationHandler>;
/** Every ctx.operations.emit call. */
operationEvents: { eventId: string; payload: unknown; hostId?: number }[];
/** Backing store behind ctx.secrets.get/set, keyed "<userId>:<key>". */ /** Backing store behind ctx.secrets.get/set, keyed "<userId>:<key>". */
secretStore: Map<string, string>; secretStore: Map<string, string>;
/** Every ctx.fetch call, in order. */ /** Every ctx.fetch call, in order. */
@@ -566,6 +574,8 @@ export function createFakeContext(
const createdSshKeys: FakePluginContext["createdSshKeys"] = []; const createdSshKeys: FakePluginContext["createdSshKeys"] = [];
const notifications: FakePluginContext["notifications"] = []; const notifications: FakePluginContext["notifications"] = [];
const notifyHub: FakePluginContext["notifyHub"] = { current: null }; const notifyHub: FakePluginContext["notifyHub"] = { current: null };
const operationHandlers: FakePluginContext["operationHandlers"] = new Map();
const operationEvents: FakePluginContext["operationEvents"] = [];
const fetches: FakePluginContext["fetches"] = []; const fetches: FakePluginContext["fetches"] = [];
const processRuns: FakePluginContext["processRuns"] = []; const processRuns: FakePluginContext["processRuns"] = [];
const binaries: FakePluginContext["binaries"] = []; const binaries: FakePluginContext["binaries"] = [];
@@ -1391,6 +1401,46 @@ export function createFakeContext(
}, },
}, },
operations: {
register: (id, handler) => {
operationHandlers.set(id, handler);
},
run: async (actionId, options = {}) => {
const handler = operationHandlers.get(actionId);
if (!handler) throw new Error(`No action "${actionId}"`);
const result = await handler.run(options.input ?? {}, {
hostId: options.hostId,
resourceKey: options.resourceKey,
runId: null,
dryRun: options.dryRun === true,
signal: new AbortController().signal,
log: () => {},
});
return { runId: null, status: "success", ...result };
},
invoke: async (actionId, options = {}) => {
const handler = operationHandlers.get(actionId);
if (!handler) throw new Error(`No action "${actionId}"`);
const result = await handler.run(options.input ?? {}, {
hostId: options.hostId,
resourceKey: options.resourceKey,
runId: null,
dryRun: options.dryRun === true,
signal: new AbortController().signal,
log: () => {},
});
return { runId: null, status: "success", ...result };
},
list: async () => ({
actions: manifest.contributes?.operations?.actions ?? [],
}),
emit: async (eventId, payload, options) => {
operationEvents.push({ eventId, payload, hostId: options?.hostId });
},
onEvent: () => () => {},
approvals: { provide: () => () => {} },
},
notify: { notify: {
channels: async () => { channels: async () => {
if (!actor) throw new Error("ctx.notify needs an acting user"); if (!actor) throw new Error("ctx.notify needs an acting user");
@@ -1554,6 +1604,8 @@ export function createFakeContext(
createdSshKeys, createdSshKeys,
notifications, notifications,
notifyHub, notifyHub,
operationHandlers,
operationEvents,
fetches, fetches,
processRuns, processRuns,
binaries, binaries,
+7 -1
View File
@@ -159,8 +159,10 @@ HostProtocolAuthInput (type): HostProtocolAuthInput
HostProtocolDef (type): HostProtocolDef HostProtocolDef (type): HostProtocolDef
HostProtocols (type): HostProtocols HostProtocols (type): HostProtocols
HostRowFields (type): HostRowFields HostRowFields (type): HostRowFields
HostSectionDef (type): HostSectionDef
HostSidebarFilterState (type): HostSidebarFilterState HostSidebarFilterState (type): HostSidebarFilterState
HostSidebarPreferences (type): HostSidebarPreferences HostSidebarPreferences (type): HostSidebarPreferences
HostTabEmbed (value): ({ host, type, label, data, isVisible, shell, }: { host: Host; type: string; label?: string; data?: Record<string, unknown>; isVisible?: boolean; shell: TabShellCallbacks; }) => Element
HostTrayTrigger (type): HostTrayTrigger HostTrayTrigger (type): HostTrayTrigger
HttpsSettings (type): HttpsSettings HttpsSettings (type): HttpsSettings
IconPicker (value): ({ value, color, onChange, }: { value: string; color?: string; onChange: (icon: string) => void; }) => Element IconPicker (value): ({ value, color, onChange, }: { value: string; color?: string; onChange: (icon: string) => void; }) => Element
@@ -354,7 +356,7 @@ changePassword (value): (oldPassword: string, newPassword: string) => Promise<an
clearLocalAdaptivePreferences (value): () => void clearLocalAdaptivePreferences (value): () => void
cn (value): (...inputs: ClassValue[]) => string cn (value): (...inputs: ClassValue[]) => string
copyToClipboard (value): (text: string) => Promise<boolean> copyToClipboard (value): (text: string) => Promise<boolean>
createApiKey (value): (name: string, userId: string, expiresAt?: string) => Promise<CreatedApiKey> createApiKey (value): (name: string, userId: string, expiresAt?: string, scopes?: string[]) => Promise<CreatedApiKey>
createCredential (value): (credentialData: Record<string, unknown>) => Promise<Record<string, unknown>> createCredential (value): (credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
createFrontendLogger (value): (serviceName: string, serviceIcon?: string, serviceColor?: string) => FrontendLogger createFrontendLogger (value): (serviceName: string, serviceIcon?: string, serviceColor?: string) => FrontendLogger
createQuickConnectHost (value): (input: QuickConnectInput) => Host createQuickConnectHost (value): (input: QuickConnectInput) => Host
@@ -457,6 +459,7 @@ hostBadgesFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[]
hostEditorToolbarItemsFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[] hostEditorToolbarItemsFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[]
hostMenuItemsFor (value): (all: HostContextMenuItemDef[], host: Host) => HostContextMenuItemDef[] hostMenuItemsFor (value): (all: HostContextMenuItemDef[], host: Host) => HostContextMenuItemDef[]
hostProtocolFlags (value): (host: { enableSsh?: boolean; pluginSettings?: Record<string, Record<string, unknown>>; }, list?: HostProtocolDef[]) => HostProtocols hostProtocolFlags (value): (host: { enableSsh?: boolean; pluginSettings?: Record<string, Record<string, unknown>>; }, list?: HostProtocolDef[]) => HostProtocols
hostSectionsFor (value): (all: HostSectionDef[], host: Host) => HostSectionDef[]
hostStatusIndicatorsFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[] hostStatusIndicatorsFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[]
hydrateLocalSharedHostAuth (value): <T>(host: T) => Promise<T> hydrateLocalSharedHostAuth (value): <T>(host: T) => Promise<T>
invalidateHostsAndStatusCaches (value): () => void invalidateHostsAndStatusCaches (value): () => void
@@ -475,6 +478,7 @@ linkExternalToPasswordAccount (value): (externalUserId: string, targetUsername:
linkedServerUrl (value): () => Promise<string> linkedServerUrl (value): () => Promise<string>
listHostActions (value): () => HostActionDef[] listHostActions (value): () => HostActionDef[]
listHostProtocols (value): () => HostProtocolDef[] listHostProtocols (value): () => HostProtocolDef[]
listHostSections (value): () => HostSectionDef[]
loadPaletteGroup (value): (group: PaletteGroupDef) => Promise<PaletteItemDef[]> loadPaletteGroup (value): (group: PaletteGroupDef) => Promise<PaletteItemDef[]>
logoutUser (value): () => Promise<{ success: boolean; message: string; }> logoutUser (value): () => Promise<{ success: boolean; message: string; }>
makeHostSshSubTabs (value): (t: (key: string, options?: { defaultValue?: string; }) => string, protocols?: Record<string, boolean>, defaultsMode?: boolean) => HostTab[] makeHostSshSubTabs (value): (t: (key: string, options?: { defaultValue?: string; }) => string, protocols?: Record<string, boolean>, defaultsMode?: boolean) => HostTab[]
@@ -488,6 +492,7 @@ moveSplitDrag (value): (x: number, y: number) => void
normalizeLanguageCode (value): (language?: string) => string normalizeLanguageCode (value): (language?: string) => string
notifySyncChanged (value): () => void notifySyncChanged (value): () => void
openInSplit (value): (target: SplitOpenTarget, open: () => void) => void openInSplit (value): (target: SplitOpenTarget, open: () => void) => void
operationsApi (value): AxiosInstance
paletteEntriesFor (value): (all: PaletteEntryDef[], scope: "global" | "host", host?: Host) => PaletteEntryDef[] paletteEntriesFor (value): (all: PaletteEntryDef[], scope: "global" | "host", host?: Host) => PaletteEntryDef[]
parseCustomKeybindings (value): (raw?: string) => CustomKeybinding[] parseCustomKeybindings (value): (raw?: string) => CustomKeybinding[]
parseServerTime (value): (ts: string) => Date parseServerTime (value): (ts: string) => Date
@@ -598,6 +603,7 @@ useHostContextMenuItems (value): () => HostContextMenuItemDef[]
useHostEditorSections (value): () => HostEditorSectionDef[] useHostEditorSections (value): () => HostEditorSectionDef[]
useHostEditorToolbarItems (value): () => HostBadgeDef[] useHostEditorToolbarItems (value): () => HostBadgeDef[]
useHostProtocols (value): () => HostProtocolDef[] useHostProtocols (value): () => HostProtocolDef[]
useHostSections (value): () => HostSectionDef[]
useHostStatus (value): (hostId: number, statusCheckEnabled?: boolean) => StatusValue useHostStatus (value): (hostId: number, statusCheckEnabled?: boolean) => StatusValue
useHostStatusIndicators (value): () => HostBadgeDef[] useHostStatusIndicators (value): () => HostBadgeDef[]
useInstalledPlugins (value): () => PluginSummary[] useInstalledPlugins (value): () => PluginSummary[]
+32
View File
@@ -537,6 +537,38 @@ describe("default look", () => {
).toMatchObject({ id: "look", defaultLook: true }); ).toMatchObject({ id: "look", defaultLook: true });
}); });
it("leaves out new plugins with no usable release when pinning all", () => {
const stable = {
version: "1.0.0",
url: "https://x/a.tmxplug",
sha256: SHA,
};
const index = {
plugins: [
{ id: "ready", versions: [stable] },
{
id: "beta-only",
versions: [],
prereleases: [{ ...stable, version: "1.0.0-beta.1" }],
},
{
id: "too-new",
versions: [],
gated: [{ ...stable, engine: { termix: ">=99.0.0" } }],
},
],
};
expect(
pinsFromIndex(index, null, [], "26.11.1").map(
(pin: { id: string }) => pin.id,
),
).toEqual(["ready"]);
expect(() =>
pinsFromIndex(index, null, [{ id: "beta-only" }], "26.11.1"),
).toThrow(/beta-only/);
expect(() => pinsFromIndex(index, ["beta-only"])).toThrow(/beta-only/);
});
it("copies a local build's theme files", () => { it("copies a local build's theme files", () => {
const repo = tempDir(); const repo = tempDir();
fs.mkdirSync(path.join(repo, "dist"), { recursive: true }); fs.mkdirSync(path.join(repo, "dist"), { recursive: true });
+6
View File
@@ -19,6 +19,12 @@ const SDK_UI = path.join(ROOT, "src", "ui", "plugin-host", "sdk-ui.ts");
const TSCONFIG = path.join(ROOT, "tsconfig.app.json"); const TSCONFIG = path.join(ROOT, "tsconfig.app.json");
const ALLOWED = [ const ALLOWED = [
"useHostSections",
"operationsApi",
"listHostSections",
"hostSectionsFor",
"HostTabEmbed",
"HostSectionDef",
"ACCENT_PRESET_COLORS", "ACCENT_PRESET_COLORS",
"APP_VERSION", "APP_VERSION",
"AccessRecord", "AccessRecord",
+8 -3
View File
@@ -278,14 +278,15 @@ function pluginsForProfile(plugins, profile) {
* pin list, so fields the registry does not know (onboarding, tier) carry over; * pin list, so fields the registry does not know (onboarding, tier) carry over;
* a plugin new to the list starts in the full tier. coreVersion, when given, * a plugin new to the list starts in the full tier. coreVersion, when given,
* skips releases whose engine.termix range does not include it, so the image * skips releases whose engine.termix range does not include it, so the image
* never ships a plugin its own core cannot run. * never ships a plugin its own core cannot run. With ids null, a plugin new to
* the list that has no usable release (only betas, say) is left out.
*/ */
function pinsFromIndex(index, ids, current = [], coreVersion = null) { function pinsFromIndex(index, ids, current = [], coreVersion = null) {
const plugins = Array.isArray(index?.plugins) ? index.plugins : []; const plugins = Array.isArray(index?.plugins) ? index.plugins : [];
const byId = new Map(plugins.map((plugin) => [plugin.id, plugin])); const byId = new Map(plugins.map((plugin) => [plugin.id, plugin]));
const currentById = new Map(current.map((entry) => [entry.id, entry])); const currentById = new Map(current.map((entry) => [entry.id, entry]));
const wanted = ids ?? plugins.map((plugin) => plugin.id); const wanted = ids ?? plugins.map((plugin) => plugin.id);
return [...wanted].sort().map((id) => { const pins = [...wanted].sort().map((id) => {
const listed = byId.get(id); const listed = byId.get(id);
// gated holds releases that need a newer Termix than old builds check for. // gated holds releases that need a newer Termix than old builds check for.
const latest = [...(listed?.versions ?? []), ...(listed?.gated ?? [])] const latest = [...(listed?.versions ?? []), ...(listed?.gated ?? [])]
@@ -300,7 +301,10 @@ function pinsFromIndex(index, ids, current = [], coreVersion = null) {
}), }),
) )
.sort((a, b) => semver.rcompare(a.version, b.version))[0]; .sort((a, b) => semver.rcompare(a.version, b.version))[0];
if (!latest) throw new Error(`${id} is not in the registry index`); if (!latest) {
if (ids === null && !currentById.has(id)) return null;
throw new Error(`${id} is not in the registry index`);
}
const onboarding = currentById.get(id)?.onboarding; const onboarding = currentById.get(id)?.onboarding;
const defaultLook = currentById.get(id)?.defaultLook === true; const defaultLook = currentById.get(id)?.defaultLook === true;
const tier = currentById.get(id)?.tier ?? "full"; const tier = currentById.get(id)?.tier ?? "full";
@@ -314,6 +318,7 @@ function pinsFromIndex(index, ids, current = [], coreVersion = null) {
...(defaultLook ? { defaultLook } : {}), ...(defaultLook ? { defaultLook } : {}),
}; };
}); });
return pins.filter(Boolean);
} }
const LOCAL_FILES = [ const LOCAL_FILES = [
+1 -1
View File
@@ -4,7 +4,7 @@
* re-released. * re-released.
* *
* node scripts/pin-bundled-plugins.cjs refresh the plugins already listed * node scripts/pin-bundled-plugins.cjs refresh the plugins already listed
* node scripts/pin-bundled-plugins.cjs --all also add plugins new to the index (tier full) * node scripts/pin-bundled-plugins.cjs --all also add plugins new to the index that have a stable release (tier full)
* --core-version X skip releases whose engine.termix excludes X * --core-version X skip releases whose engine.termix excludes X
* *
* The release workflow runs it with --all and the version being released. * The release workflow runs it with --all and the version being released.
+2
View File
@@ -30,6 +30,7 @@ import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
import { syncForwarder } from "../sync/provider.js"; import { syncForwarder } from "../sync/provider.js";
import { syncChangeWatcher } from "../sync/server/change-watcher.js"; import { syncChangeWatcher } from "../sync/server/change-watcher.js";
import dashboardRoutes from "./routes/dashboard-routes.js"; import dashboardRoutes from "./routes/dashboard-routes.js";
import operationsRoutes from "./routes/operations-routes.js";
import { import {
mountPluginApi, mountPluginApi,
mountPluginLegacyPaths, mountPluginLegacyPaths,
@@ -1627,6 +1628,7 @@ app.use("/ui-preferences", uiPreferencesRoutes);
registerAuditLogRoutes(app, authenticateJWT); registerAuditLogRoutes(app, authenticateJWT);
app.use("/sync", syncForwarder); app.use("/sync", syncForwarder);
app.use("/dashboard", dashboardRoutes); app.use("/dashboard", dashboardRoutes);
app.use("/operations", operationsRoutes);
app.use("/plugins", pluginRoutes); app.use("/plugins", pluginRoutes);
app.use( app.use(
"/plugin-assets", "/plugin-assets",
+65
View File
@@ -599,6 +599,70 @@ async function initializeCompleteDatabase(): Promise<void> {
UNIQUE (role_name, permission) UNIQUE (role_name, permission)
); );
CREATE TABLE IF NOT EXISTS operation_runs (
id TEXT PRIMARY KEY,
action_id TEXT NOT NULL,
action_version INTEGER NOT NULL DEFAULT 1,
plugin_id TEXT NOT NULL,
plugin_version TEXT NOT NULL,
user_id TEXT REFERENCES users (id) ON DELETE SET NULL,
username TEXT NOT NULL,
origin TEXT NOT NULL,
host_id INTEGER,
host_name TEXT,
batch_id TEXT,
dry_run INTEGER NOT NULL DEFAULT 0,
risk TEXT NOT NULL,
status TEXT NOT NULL,
summary TEXT,
changed INTEGER,
input TEXT,
sealed_input TEXT,
output TEXT,
undo_input TEXT,
error TEXT,
error_code TEXT,
undo_of TEXT,
idempotency_key TEXT,
created_at TEXT NOT NULL,
started_at TEXT,
finished_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_operation_runs_created ON operation_runs (created_at);
CREATE INDEX IF NOT EXISTS idx_operation_runs_user_created ON operation_runs (user_id, created_at);
CREATE INDEX IF NOT EXISTS idx_operation_runs_host_created ON operation_runs (host_id, created_at);
CREATE INDEX IF NOT EXISTS idx_operation_runs_status ON operation_runs (status);
CREATE INDEX IF NOT EXISTS idx_operation_runs_batch ON operation_runs (batch_id);
CREATE TABLE IF NOT EXISTS operation_approvals (
id TEXT PRIMARY KEY,
run_id TEXT NOT NULL REFERENCES operation_runs (id) ON DELETE CASCADE,
requested_by TEXT,
reason TEXT,
allow_self INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL,
decided_by TEXT,
comment TEXT,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
decided_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_operation_approvals_status ON operation_approvals (status, expires_at);
CREATE INDEX IF NOT EXISTS idx_operation_approvals_run ON operation_approvals (run_id);
CREATE TABLE IF NOT EXISTS operation_events (
id TEXT PRIMARY KEY,
event_id TEXT NOT NULL,
plugin_id TEXT NOT NULL,
host_id INTEGER,
severity TEXT,
payload TEXT,
run_id TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_operation_events_created ON operation_events (created_at);
CREATE INDEX IF NOT EXISTS idx_operation_events_host_created ON operation_events (host_id, created_at);
CREATE TABLE IF NOT EXISTS api_keys ( CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
@@ -961,6 +1025,7 @@ const enforceUniqueUsernames = () => {
const migrateSchema = () => { const migrateSchema = () => {
repairSnippetsColumns(sqlite); repairSnippetsColumns(sqlite);
addColumnIfNotExists("api_keys", "scopes", "TEXT");
addColumnIfNotExists("user_preferences", "theme", "TEXT"); addColumnIfNotExists("user_preferences", "theme", "TEXT");
addColumnIfNotExists("user_preferences", "font_size", "TEXT"); addColumnIfNotExists("user_preferences", "font_size", "TEXT");
addColumnIfNotExists("user_preferences", "accent_color", "TEXT"); addColumnIfNotExists("user_preferences", "accent_color", "TEXT");
+92
View File
@@ -673,10 +673,102 @@ export const apiKeys = mysqlTable(
expiresAt: text("expires_at"), expiresAt: text("expires_at"),
lastUsedAt: text("last_used_at"), lastUsedAt: text("last_used_at"),
isActive: boolean("is_active").notNull().default(true), isActive: boolean("is_active").notNull().default(true),
// JSON list. Null means full access, which every older key has.
scopes: text("scopes"),
}, },
(table) => [index("idx_api_keys_user_id").on(table.userId)], (table) => [index("idx_api_keys_user_id").on(table.userId)],
); );
export const operationRuns = mysqlTable(
"operation_runs",
{
id: varchar("id", { length: 255 }).primaryKey(),
actionId: text("action_id").notNull(),
actionVersion: int("action_version").notNull().default(1),
pluginId: text("plugin_id").notNull(),
pluginVersion: text("plugin_version").notNull(),
// Nullable on purpose: history outlives the account, like audit_logs.
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "set null" }),
username: text("username").notNull(),
origin: text("origin").notNull(),
hostId: int("host_id"),
hostName: text("host_name"),
batchId: varchar("batch_id", { length: 255 }),
dryRun: boolean("dry_run").notNull().default(false),
risk: text("risk").notNull(),
status: varchar("status", { length: 255 }).notNull(),
summary: text("summary"),
changed: boolean("changed"),
input: text("input"),
// Only until the run ends, then wiped.
sealedInput: text("sealed_input"),
output: text("output"),
undoInput: text("undo_input"),
error: text("error"),
errorCode: text("error_code"),
undoOf: text("undo_of"),
idempotencyKey: text("idempotency_key"),
createdAt: varchar("created_at", { length: 255 }).notNull(),
startedAt: text("started_at"),
finishedAt: text("finished_at"),
},
(table) => [
index("idx_operation_runs_created").on(table.createdAt),
index("idx_operation_runs_user_created").on(table.userId, table.createdAt),
index("idx_operation_runs_host_created").on(table.hostId, table.createdAt),
index("idx_operation_runs_status").on(table.status),
index("idx_operation_runs_batch").on(table.batchId),
],
);
export const operationApprovals = mysqlTable(
"operation_approvals",
{
id: varchar("id", { length: 255 }).primaryKey(),
runId: varchar("run_id", { length: 255 })
.notNull()
.references(() => operationRuns.id, { onDelete: "cascade" }),
requestedBy: text("requested_by"),
reason: text("reason"),
allowSelf: boolean("allow_self")
.notNull()
.default(false),
status: varchar("status", { length: 255 }).notNull(),
decidedBy: text("decided_by"),
comment: text("comment"),
createdAt: text("created_at").notNull(),
expiresAt: varchar("expires_at", { length: 255 }).notNull(),
decidedAt: text("decided_at"),
},
(table) => [
index("idx_operation_approvals_status").on(table.status, table.expiresAt),
index("idx_operation_approvals_run").on(table.runId),
],
);
export const operationEvents = mysqlTable(
"operation_events",
{
id: varchar("id", { length: 255 }).primaryKey(),
eventId: text("event_id").notNull(),
pluginId: text("plugin_id").notNull(),
hostId: int("host_id"),
severity: text("severity"),
payload: text("payload"),
runId: text("run_id"),
createdAt: varchar("created_at", { length: 255 }).notNull(),
},
(table) => [
index("idx_operation_events_created").on(table.createdAt),
index("idx_operation_events_host_created").on(
table.hostId,
table.createdAt,
),
],
);
export const userOpenTabs = mysqlTable( export const userOpenTabs = mysqlTable(
"user_open_tabs", "user_open_tabs",
{ {
+92
View File
@@ -673,10 +673,102 @@ export const apiKeys = pgTable(
expiresAt: text("expires_at"), expiresAt: text("expires_at"),
lastUsedAt: text("last_used_at"), lastUsedAt: text("last_used_at"),
isActive: boolean("is_active").notNull().default(true), isActive: boolean("is_active").notNull().default(true),
// JSON list. Null means full access, which every older key has.
scopes: text("scopes"),
}, },
(table) => [index("idx_api_keys_user_id").on(table.userId)], (table) => [index("idx_api_keys_user_id").on(table.userId)],
); );
export const operationRuns = pgTable(
"operation_runs",
{
id: varchar("id", { length: 255 }).primaryKey(),
actionId: text("action_id").notNull(),
actionVersion: integer("action_version").notNull().default(1),
pluginId: text("plugin_id").notNull(),
pluginVersion: text("plugin_version").notNull(),
// Nullable on purpose: history outlives the account, like audit_logs.
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "set null" }),
username: text("username").notNull(),
origin: text("origin").notNull(),
hostId: integer("host_id"),
hostName: text("host_name"),
batchId: varchar("batch_id", { length: 255 }),
dryRun: boolean("dry_run").notNull().default(false),
risk: text("risk").notNull(),
status: varchar("status", { length: 255 }).notNull(),
summary: text("summary"),
changed: boolean("changed"),
input: text("input"),
// Only until the run ends, then wiped.
sealedInput: text("sealed_input"),
output: text("output"),
undoInput: text("undo_input"),
error: text("error"),
errorCode: text("error_code"),
undoOf: text("undo_of"),
idempotencyKey: text("idempotency_key"),
createdAt: varchar("created_at", { length: 255 }).notNull(),
startedAt: text("started_at"),
finishedAt: text("finished_at"),
},
(table) => [
index("idx_operation_runs_created").on(table.createdAt),
index("idx_operation_runs_user_created").on(table.userId, table.createdAt),
index("idx_operation_runs_host_created").on(table.hostId, table.createdAt),
index("idx_operation_runs_status").on(table.status),
index("idx_operation_runs_batch").on(table.batchId),
],
);
export const operationApprovals = pgTable(
"operation_approvals",
{
id: varchar("id", { length: 255 }).primaryKey(),
runId: varchar("run_id", { length: 255 })
.notNull()
.references(() => operationRuns.id, { onDelete: "cascade" }),
requestedBy: text("requested_by"),
reason: text("reason"),
allowSelf: boolean("allow_self")
.notNull()
.default(false),
status: varchar("status", { length: 255 }).notNull(),
decidedBy: text("decided_by"),
comment: text("comment"),
createdAt: text("created_at").notNull(),
expiresAt: varchar("expires_at", { length: 255 }).notNull(),
decidedAt: text("decided_at"),
},
(table) => [
index("idx_operation_approvals_status").on(table.status, table.expiresAt),
index("idx_operation_approvals_run").on(table.runId),
],
);
export const operationEvents = pgTable(
"operation_events",
{
id: varchar("id", { length: 255 }).primaryKey(),
eventId: text("event_id").notNull(),
pluginId: text("plugin_id").notNull(),
hostId: integer("host_id"),
severity: text("severity"),
payload: text("payload"),
runId: text("run_id"),
createdAt: varchar("created_at", { length: 255 }).notNull(),
},
(table) => [
index("idx_operation_events_created").on(table.createdAt),
index("idx_operation_events_host_created").on(
table.hostId,
table.createdAt,
),
],
);
export const userOpenTabs = pgTable( export const userOpenTabs = pgTable(
"user_open_tabs", "user_open_tabs",
{ {
+92
View File
@@ -663,10 +663,102 @@ export const apiKeys = sqliteTable(
expiresAt: text("expires_at"), expiresAt: text("expires_at"),
lastUsedAt: text("last_used_at"), lastUsedAt: text("last_used_at"),
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true), isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
// JSON list. Null means full access, which every older key has.
scopes: text("scopes"),
}, },
(table) => [index("idx_api_keys_user_id").on(table.userId)], (table) => [index("idx_api_keys_user_id").on(table.userId)],
); );
export const operationRuns = sqliteTable(
"operation_runs",
{
id: text("id").primaryKey(),
actionId: text("action_id").notNull(),
actionVersion: integer("action_version").notNull().default(1),
pluginId: text("plugin_id").notNull(),
pluginVersion: text("plugin_version").notNull(),
// Nullable on purpose: history outlives the account, like audit_logs.
userId: text("user_id").references(() => users.id, { onDelete: "set null" }),
username: text("username").notNull(),
origin: text("origin").notNull(),
hostId: integer("host_id"),
hostName: text("host_name"),
batchId: text("batch_id"),
dryRun: integer("dry_run", { mode: "boolean" }).notNull().default(false),
risk: text("risk").notNull(),
status: text("status").notNull(),
summary: text("summary"),
changed: integer("changed", { mode: "boolean" }),
input: text("input"),
// Only until the run ends, then wiped.
sealedInput: text("sealed_input"),
output: text("output"),
undoInput: text("undo_input"),
error: text("error"),
errorCode: text("error_code"),
undoOf: text("undo_of"),
idempotencyKey: text("idempotency_key"),
createdAt: text("created_at").notNull(),
startedAt: text("started_at"),
finishedAt: text("finished_at"),
},
(table) => [
index("idx_operation_runs_created").on(table.createdAt),
index("idx_operation_runs_user_created").on(table.userId, table.createdAt),
index("idx_operation_runs_host_created").on(table.hostId, table.createdAt),
index("idx_operation_runs_status").on(table.status),
index("idx_operation_runs_batch").on(table.batchId),
],
);
export const operationApprovals = sqliteTable(
"operation_approvals",
{
id: text("id").primaryKey(),
runId: text("run_id")
.notNull()
.references(() => operationRuns.id, { onDelete: "cascade" }),
requestedBy: text("requested_by"),
reason: text("reason"),
allowSelf: integer("allow_self", { mode: "boolean" })
.notNull()
.default(false),
status: text("status").notNull(),
decidedBy: text("decided_by"),
comment: text("comment"),
createdAt: text("created_at").notNull(),
expiresAt: text("expires_at").notNull(),
decidedAt: text("decided_at"),
},
(table) => [
index("idx_operation_approvals_status").on(table.status, table.expiresAt),
index("idx_operation_approvals_run").on(table.runId),
],
);
export const operationEvents = sqliteTable(
"operation_events",
{
id: text("id").primaryKey(),
eventId: text("event_id").notNull(),
pluginId: text("plugin_id").notNull(),
hostId: integer("host_id"),
severity: text("severity"),
payload: text("payload"),
runId: text("run_id"),
createdAt: text("created_at").notNull(),
},
(table) => [
index("idx_operation_events_created").on(table.createdAt),
index("idx_operation_events_host_created").on(
table.hostId,
table.createdAt,
),
],
);
export const userOpenTabs = sqliteTable( export const userOpenTabs = sqliteTable(
"user_open_tabs", "user_open_tabs",
{ {
@@ -17,6 +17,8 @@ export interface ApiKeyListRecord {
expiresAt: string | null; expiresAt: string | null;
lastUsedAt: string | null; lastUsedAt: string | null;
isActive: boolean; isActive: boolean;
/** JSON list, or null for full access. */
scopes: string | null;
} }
export class ApiKeyRepository { export class ApiKeyRepository {
@@ -43,6 +45,7 @@ export class ApiKeyRepository {
expiresAt: apiKeys.expiresAt, expiresAt: apiKeys.expiresAt,
lastUsedAt: apiKeys.lastUsedAt, lastUsedAt: apiKeys.lastUsedAt,
isActive: apiKeys.isActive, isActive: apiKeys.isActive,
scopes: apiKeys.scopes,
}) })
.from(apiKeys) .from(apiKeys)
.leftJoin(users, eq(apiKeys.userId, users.id)) .leftJoin(users, eq(apiKeys.userId, users.id))
@@ -13,6 +13,7 @@ import { HostSidebarPreferenceRepository } from "./host-sidebar-preference-repos
import { CredentialSidebarPreferenceRepository } from "./credential-sidebar-preference-repository.js"; import { CredentialSidebarPreferenceRepository } from "./credential-sidebar-preference-repository.js";
import { UiPreferenceRepository } from "./ui-preference-repository.js"; import { UiPreferenceRepository } from "./ui-preference-repository.js";
import { OpenTabRepository } from "./open-tab-repository.js"; import { OpenTabRepository } from "./open-tab-repository.js";
import { OperationRepository } from "./operation-repository.js";
import { PluginRepository } from "./plugin-repository.js"; import { PluginRepository } from "./plugin-repository.js";
import { PluginStorageRepository } from "./plugin-storage-repository.js"; import { PluginStorageRepository } from "./plugin-storage-repository.js";
import { PluginSettingsRepository } from "./plugin-settings-repository.js"; import { PluginSettingsRepository } from "./plugin-settings-repository.js";
@@ -134,6 +135,14 @@ export function createCurrentAuditLogRepository(): AuditLogRepository {
); );
} }
export function createCurrentOperationRepository(): OperationRepository {
return new OperationRepository(
createCurrentRepositoryContext(),
// Rows land on every run. A rollout saves once on its own when it starts.
createCurrentRepositoryLazyWriteHook("operation_repository_write"),
);
}
export function createCurrentCredentialRepository(): CredentialRepository { export function createCurrentCredentialRepository(): CredentialRepository {
return new CredentialRepository( return new CredentialRepository(
createCurrentRepositoryContext(), createCurrentRepositoryContext(),
@@ -0,0 +1,255 @@
import { and, desc, eq, inArray, lt, or, sql } from "drizzle-orm";
import {
operationApprovals,
operationEvents,
operationRuns,
} from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { countValue, rowsAffected } from "./mutation-result.js";
export type OperationRunRecord = typeof operationRuns.$inferSelect;
export type NewOperationRunRecord = typeof operationRuns.$inferInsert;
export type OperationApprovalRecord = typeof operationApprovals.$inferSelect;
export type NewOperationApprovalRecord = typeof operationApprovals.$inferInsert;
export type OperationEventRecord = typeof operationEvents.$inferSelect;
export type NewOperationEventRecord = typeof operationEvents.$inferInsert;
export interface OperationRunFilters {
userId?: string;
/** Rows also visible because the viewer owns the host. */
orHostIds?: number[];
hostId?: number;
actionId?: string;
status?: string;
batchId?: string;
before?: string;
}
const MAX_PAGE = 200;
export class OperationRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async insertRun(run: NewOperationRunRecord): Promise<void> {
await this.context.drizzle.insert(operationRuns).values(run);
await this.onWrite?.();
}
async findRun(id: string): Promise<OperationRunRecord | null> {
const rows = await this.context.drizzle
.select()
.from(operationRuns)
.where(eq(operationRuns.id, id))
.limit(1);
return rows[0] ?? null;
}
async findByIdempotencyKey(
userId: string,
key: string,
): Promise<OperationRunRecord | null> {
const rows = await this.context.drizzle
.select()
.from(operationRuns)
.where(
and(
eq(operationRuns.userId, userId),
eq(operationRuns.idempotencyKey, key),
),
)
.limit(1);
return rows[0] ?? null;
}
async listRuns(
filters: OperationRunFilters,
limit = 50,
): Promise<OperationRunRecord[]> {
const owner = filters.userId
? filters.orHostIds?.length
? or(
eq(operationRuns.userId, filters.userId),
inArray(operationRuns.hostId, filters.orHostIds),
)
: eq(operationRuns.userId, filters.userId)
: undefined;
const conditions = [
owner,
filters.hostId !== undefined
? eq(operationRuns.hostId, filters.hostId)
: undefined,
filters.actionId
? eq(operationRuns.actionId, filters.actionId)
: undefined,
filters.status ? eq(operationRuns.status, filters.status) : undefined,
filters.batchId ? eq(operationRuns.batchId, filters.batchId) : undefined,
filters.before ? lt(operationRuns.createdAt, filters.before) : undefined,
].filter((condition) => condition !== undefined);
return this.context.drizzle
.select()
.from(operationRuns)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(desc(operationRuns.createdAt))
.limit(Math.min(Math.max(limit, 1), MAX_PAGE));
}
/** Moves a run on only from the status given, so two writers cannot race. */
async updateRunFrom(
id: string,
fromStatus: string,
patch: Partial<NewOperationRunRecord>,
): Promise<boolean> {
const result = await this.context.drizzle
.update(operationRuns)
.set(patch)
.where(
and(eq(operationRuns.id, id), eq(operationRuns.status, fromStatus)),
);
await this.onWrite?.();
return rowsAffected(result) > 0;
}
/** Anything still running when the server stopped has an unknown outcome. */
async markRunningInterrupted(finishedAt: string): Promise<number> {
const result = await this.context.drizzle
.update(operationRuns)
.set({
status: "interrupted",
finishedAt,
sealedInput: null,
error: "The server stopped during this run. Check the host.",
})
.where(eq(operationRuns.status, "running"));
await this.onWrite?.();
return rowsAffected(result);
}
async insertApproval(approval: NewOperationApprovalRecord): Promise<void> {
await this.context.drizzle.insert(operationApprovals).values(approval);
await this.onWrite?.();
}
async findApproval(id: string): Promise<OperationApprovalRecord | null> {
const rows = await this.context.drizzle
.select()
.from(operationApprovals)
.where(eq(operationApprovals.id, id))
.limit(1);
return rows[0] ?? null;
}
async findApprovalForRun(
runId: string,
): Promise<OperationApprovalRecord | null> {
const rows = await this.context.drizzle
.select()
.from(operationApprovals)
.where(eq(operationApprovals.runId, runId))
.limit(1);
return rows[0] ?? null;
}
async listPendingApprovals(limit = 100): Promise<OperationApprovalRecord[]> {
return this.context.drizzle
.select()
.from(operationApprovals)
.where(eq(operationApprovals.status, "pending"))
.orderBy(desc(operationApprovals.createdAt))
.limit(Math.min(Math.max(limit, 1), MAX_PAGE));
}
/** One shot: only the first decision on a pending row wins. */
async decideApproval(
id: string,
status: "approved" | "rejected" | "expired",
decidedBy: string | null,
comment: string | null,
decidedAt: string,
): Promise<boolean> {
const result = await this.context.drizzle
.update(operationApprovals)
.set({ status, decidedBy, comment, decidedAt })
.where(
and(
eq(operationApprovals.id, id),
eq(operationApprovals.status, "pending"),
),
);
await this.onWrite?.();
return rowsAffected(result) > 0;
}
async listExpiredApprovals(now: string): Promise<OperationApprovalRecord[]> {
return this.context.drizzle
.select()
.from(operationApprovals)
.where(
and(
eq(operationApprovals.status, "pending"),
lt(operationApprovals.expiresAt, now),
),
)
.limit(MAX_PAGE);
}
async insertEvent(event: NewOperationEventRecord): Promise<void> {
await this.context.drizzle.insert(operationEvents).values(event);
await this.onWrite?.();
}
async listEvents(
filters: { hostId?: number; before?: string },
limit = 50,
): Promise<OperationEventRecord[]> {
const conditions = [
filters.hostId !== undefined
? eq(operationEvents.hostId, filters.hostId)
: undefined,
filters.before
? lt(operationEvents.createdAt, filters.before)
: undefined,
].filter((condition) => condition !== undefined);
return this.context.drizzle
.select()
.from(operationEvents)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(desc(operationEvents.createdAt))
.limit(Math.min(Math.max(limit, 1), MAX_PAGE));
}
/** Drops rows older than the cutoff, finished runs only. */
async prune(before: string): Promise<number> {
const runs = await this.context.drizzle
.delete(operationRuns)
.where(
and(
lt(operationRuns.createdAt, before),
sql`${operationRuns.status} NOT IN ('running', 'pending_approval')`,
),
);
const events = await this.context.drizzle
.delete(operationEvents)
.where(lt(operationEvents.createdAt, before));
await this.onWrite?.();
return rowsAffected(runs) + rowsAffected(events);
}
async countRuns(): Promise<number> {
const rows = await this.context.drizzle
.select({ value: sql<number>`count(*)` })
.from(operationRuns);
return countValue(rows[0]?.value);
}
/** History outlives the account, but the name goes with it. */
async anonymizeUser(userId: string): Promise<void> {
await this.context.drizzle
.update(operationRuns)
.set({ username: "deleted user" })
.where(eq(operationRuns.userId, userId));
await this.onWrite?.();
}
}
@@ -3,6 +3,7 @@ import { recentActivity } from "../../plugins/activity-provider.js";
import { import {
createCurrentApiKeyRepository, createCurrentApiKeyRepository,
createCurrentAuditLogRepository, createCurrentAuditLogRepository,
createCurrentOperationRepository,
createCurrentCredentialRepository, createCurrentCredentialRepository,
createCurrentHostFolderRepository, createCurrentHostFolderRepository,
createCurrentHostRepository, createCurrentHostRepository,
@@ -61,6 +62,7 @@ export async function deleteUserAndRelatedData(
await createCurrentRoleRepository().removeAllRolesFromUser(userId); await createCurrentRoleRepository().removeAllRolesFromUser(userId);
await createCurrentAuditLogRepository().anonymizeByUserId(userId); await createCurrentAuditLogRepository().anonymizeByUserId(userId);
await createCurrentOperationRepository().anonymizeUser(userId);
await createCurrentSshCredentialUsageRepository().deleteByUserId(userId); await createCurrentSshCredentialUsageRepository().deleteByUserId(userId);
@@ -0,0 +1,760 @@
import express, { type Request, type Response } from "express";
import type {
ResourceListResult,
SignalResult,
} from "@termix-ssh/plugin-sdk/operations";
import { AuthManager } from "../../utils/auth-manager.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import { databaseLogger } from "../../utils/logger.js";
import { getErrorMessage } from "../../utils/error-message.js";
import { createCurrentOperationRepository } from "../repositories/factory.js";
import type { OperationRunRecord } from "../repositories/operation-repository.js";
import { buildCatalog } from "../../operations/catalog.js";
import {
cancelOperationRun,
decideOperationApproval,
OperationError,
runOperation,
} from "../../operations/gate.js";
import { getAction } from "../../operations/registry.js";
import {
flattenResourceRows,
readAcrossHosts,
} from "../../operations/resources.js";
import type { ActorOrigin } from "../../plugins/actor.js";
const router = express.Router();
const authenticateJWT = AuthManager.getInstance().createAuthMiddleware();
const permissions = PermissionManager.getInstance();
/** Sees every run on the server, not only their own. */
export const VIEW_ALL_RUNS = "admin.operations.view";
type OperationsRequest = Request & {
userId: string;
apiKeyId?: string;
apiKeyScopes?: string[] | null;
actingAdminUserId?: string;
};
function originOf(req: OperationsRequest): ActorOrigin {
return { kind: req.apiKeyId ? "api" : "ui" };
}
function sendError(res: Response, error: unknown, operation: string): void {
if (error instanceof OperationError) {
res.status(error.status).json({
error: error.message,
code: error.code,
details: error.details,
});
return;
}
databaseLogger.error("Operations route failed", error, { operation });
res.status(500).json({ error: getErrorMessage(error, "Request failed") });
}
function parseJson(text: string | null): unknown {
if (!text) return undefined;
try {
return JSON.parse(text);
} catch {
return undefined;
}
}
/** Never includes the sealed input. */
function publicRun(run: OperationRunRecord) {
return {
id: run.id,
actionId: run.actionId,
pluginId: run.pluginId,
pluginVersion: run.pluginVersion,
userId: run.userId,
username: run.username,
origin: parseJson(run.origin),
hostId: run.hostId,
hostName: run.hostName,
batchId: run.batchId,
dryRun: run.dryRun,
risk: run.risk,
status: run.status,
summary: run.summary,
changed: run.changed,
input: parseJson(run.input),
output: parseJson(run.output),
undoAvailable: run.undoInput !== null,
error: run.error,
errorCode: run.errorCode,
undoOf: run.undoOf,
createdAt: run.createdAt,
startedAt: run.startedAt,
finishedAt: run.finishedAt,
};
}
async function canSeeRun(
run: OperationRunRecord,
userId: string,
): Promise<boolean> {
if (run.userId === userId) return true;
if (await permissions.hasPermission(userId, VIEW_ALL_RUNS)) return true;
if (run.hostId === null) return false;
const access = await permissions.canAccessHost(userId, run.hostId, "manage");
return access.hasAccess;
}
function hostIdsFrom(value: unknown): number[] | undefined {
if (typeof value !== "string" || value === "") return undefined;
const ids = value
.split(",")
.map(Number)
.filter((id) => Number.isInteger(id));
return ids.length > 0 ? ids : undefined;
}
function limitFrom(value: unknown): number | undefined {
const limit = Number(value);
return Number.isInteger(limit) && limit > 0 ? limit : undefined;
}
/**
* @openapi
* /operations/catalog:
* get:
* summary: List what this user can do
* description: Every action, event, resource and signal from running plugins that the caller has permission for, with titles in the requested language.
* tags:
* - Operations
* parameters:
* - in: query
* name: lang
* schema:
* type: string
* - in: query
* name: surface
* schema:
* type: string
* enum: [assistant, mcp, palette, automations, mobile, cli]
* responses:
* 200:
* description: The catalog.
*/
router.get("/catalog", authenticateJWT, async (req, res) => {
const { userId } = req as OperationsRequest;
try {
res.json(
await buildCatalog({
userId,
lang: typeof req.query.lang === "string" ? req.query.lang : undefined,
surface:
typeof req.query.surface === "string"
? (req.query.surface as never)
: undefined,
}),
);
} catch (error) {
sendError(res, error, "operations_catalog");
}
});
/**
* @openapi
* /operations/catalog/{id}:
* get:
* summary: Describe one action
* tags:
* - Operations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The action.
* 404:
* description: No such action, or the caller may not use it.
*/
router.get("/catalog/:id", authenticateJWT, async (req, res) => {
const { userId } = req as OperationsRequest;
try {
const catalog = await buildCatalog({
userId,
lang: typeof req.query.lang === "string" ? req.query.lang : undefined,
});
const action = catalog.actions.find((entry) => entry.id === req.params.id);
if (!action) {
res.status(404).json({ error: "Action not found" });
return;
}
res.json(action);
} catch (error) {
sendError(res, error, "operations_catalog_action");
}
});
/**
* @openapi
* /operations/runs:
* get:
* summary: List runs
* description: The caller's own runs, runs on hosts they manage, or every run with the view permission.
* tags:
* - Operations
* parameters:
* - in: query
* name: hostId
* schema:
* type: integer
* - in: query
* name: actionId
* schema:
* type: string
* - in: query
* name: status
* schema:
* type: string
* - in: query
* name: batchId
* schema:
* type: string
* - in: query
* name: before
* schema:
* type: string
* - in: query
* name: limit
* schema:
* type: integer
* responses:
* 200:
* description: Runs, newest first.
*/
router.get("/runs", authenticateJWT, async (req, res) => {
const { userId } = req as OperationsRequest;
try {
const viewAll = await permissions.hasPermission(userId, VIEW_ALL_RUNS);
const hostId = limitFrom(req.query.hostId);
let managed = false;
if (!viewAll && hostId !== undefined) {
managed = (await permissions.canAccessHost(userId, hostId, "manage"))
.hasAccess;
}
const runs = await createCurrentOperationRepository().listRuns(
{
userId: viewAll || managed ? undefined : userId,
hostId,
actionId:
typeof req.query.actionId === "string"
? req.query.actionId
: undefined,
status:
typeof req.query.status === "string" ? req.query.status : undefined,
batchId:
typeof req.query.batchId === "string" ? req.query.batchId : undefined,
before:
typeof req.query.before === "string" ? req.query.before : undefined,
},
limitFrom(req.query.limit),
);
res.json({ runs: runs.map(publicRun) });
} catch (error) {
sendError(res, error, "operations_runs");
}
});
/**
* @openapi
* /operations/runs/{id}:
* get:
* summary: Get a run
* tags:
* - Operations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The run.
* 404:
* description: Not found, or not visible to the caller.
*/
router.get("/runs/:id", authenticateJWT, async (req, res) => {
const { userId } = req as OperationsRequest;
try {
const run = await createCurrentOperationRepository().findRun(
String(req.params.id),
);
if (!run || !(await canSeeRun(run, userId))) {
res.status(404).json({ error: "Run not found" });
return;
}
res.json(publicRun(run));
} catch (error) {
sendError(res, error, "operations_run");
}
});
/**
* @openapi
* /operations/runs/{id}/cancel:
* post:
* summary: Cancel a run
* description: Stops a running action, or withdraws one waiting for approval.
* tags:
* - Operations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Whether anything was cancelled.
* 404:
* description: Not found, or not visible to the caller.
*/
router.post("/runs/:id/cancel", authenticateJWT, async (req, res) => {
const { userId } = req as OperationsRequest;
try {
const run = await createCurrentOperationRepository().findRun(
String(req.params.id),
);
if (!run || !(await canSeeRun(run, userId))) {
res.status(404).json({ error: "Run not found" });
return;
}
res.json({ cancelled: await cancelOperationRun(run) });
} catch (error) {
sendError(res, error, "operations_cancel");
}
});
/**
* @openapi
* /operations/runs/{id}/undo:
* post:
* summary: Undo a run
* description: Runs the action's undo with the input the run recorded. It goes through the same checks as any run.
* tags:
* - Operations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The new run.
* 404:
* description: Not found, or nothing to undo.
*/
router.post("/runs/:id/undo", authenticateJWT, async (req, res) => {
const authReq = req as OperationsRequest;
try {
const run = await createCurrentOperationRepository().findRun(
String(req.params.id),
);
const undo = run ? getAction(run.actionId)?.action.undo : undefined;
if (
!run ||
!undo ||
!run.undoInput ||
!(await canSeeRun(run, authReq.userId))
) {
res.status(404).json({ error: "Nothing to undo" });
return;
}
res.json(
await runOperation({
actionId: undo,
hostId: run.hostId ?? undefined,
input: parseJson(run.undoInput),
confirmed: req.body?.confirmed === true,
undoOf: run.id,
userId: authReq.userId,
origin: originOf(authReq),
apiKeyScopes: authReq.apiKeyScopes,
}),
);
} catch (error) {
sendError(res, error, "operations_undo");
}
});
/**
* @openapi
* /operations/approvals:
* get:
* summary: List waiting approvals
* description: Requests the caller could decide, with the exact input that would run.
* tags:
* - Operations
* responses:
* 200:
* description: Waiting approvals.
*/
router.get("/approvals", authenticateJWT, async (req, res) => {
const { userId } = req as OperationsRequest;
try {
const repository = createCurrentOperationRepository();
const approvals = [];
for (const approval of await repository.listPendingApprovals()) {
const run = await repository.findRun(approval.runId);
if (run && (await canSeeRun(run, userId))) {
approvals.push({ ...approval, run: publicRun(run) });
}
}
res.json({ approvals });
} catch (error) {
sendError(res, error, "operations_approvals");
}
});
/**
* @openapi
* /operations/approvals/{id}:
* post:
* summary: Approve or reject a request
* description: One shot. What runs is the sealed request, never anything sent here.
* tags:
* - Operations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [approve]
* properties:
* approve:
* type: boolean
* comment:
* type: string
* responses:
* 200:
* description: The run's outcome.
* 403:
* description: The caller may not decide this.
* 409:
* description: Already decided, expired, or the host changed.
*/
router.post("/approvals/:id", authenticateJWT, async (req, res) => {
const authReq = req as OperationsRequest;
if (typeof req.body?.approve !== "boolean") {
res.status(400).json({ error: "approve must be true or false" });
return;
}
// A key could be handed to a script, so a person decides from a session.
if (authReq.apiKeyScopes) {
res.status(403).json({ error: "This API key may not approve" });
return;
}
try {
res.json(
await decideOperationApproval({
approvalId: String(req.params.id),
approverId: authReq.userId,
approve: req.body.approve,
comment:
typeof req.body.comment === "string" ? req.body.comment : undefined,
impersonated: !!authReq.actingAdminUserId,
}),
);
} catch (error) {
sendError(res, error, "operations_approve");
}
});
/**
* @openapi
* /operations/events:
* get:
* summary: List declared events
* tags:
* - Operations
* responses:
* 200:
* description: Declared events from running plugins, plus core's run events.
*/
router.get("/events", authenticateJWT, async (req, res) => {
const { userId } = req as OperationsRequest;
try {
const catalog = await buildCatalog({
userId,
lang: typeof req.query.lang === "string" ? req.query.lang : undefined,
});
res.json({ events: catalog.events });
} catch (error) {
sendError(res, error, "operations_events");
}
});
/**
* @openapi
* /operations/timeline:
* get:
* summary: Recent events
* description: Events the caller may see, newest first. Events tied to a host need access to it.
* tags:
* - Operations
* parameters:
* - in: query
* name: hostId
* schema:
* type: integer
* - in: query
* name: before
* schema:
* type: string
* - in: query
* name: limit
* schema:
* type: integer
* responses:
* 200:
* description: Events.
*/
router.get("/timeline", authenticateJWT, async (req, res) => {
const { userId } = req as OperationsRequest;
try {
const rows = await createCurrentOperationRepository().listEvents(
{
hostId: limitFrom(req.query.hostId),
before:
typeof req.query.before === "string" ? req.query.before : undefined,
},
limitFrom(req.query.limit),
);
const viewAll = await permissions.hasPermission(userId, VIEW_ALL_RUNS);
const access = new Map<number, boolean>();
const events = [];
for (const row of rows) {
if (row.hostId === null) {
if (viewAll) events.push(row);
continue;
}
let allowed = access.get(row.hostId);
if (allowed === undefined) {
allowed = (
await permissions.canAccessHost(userId, row.hostId, "connect")
).hasAccess;
access.set(row.hostId, allowed);
}
if (allowed) events.push(row);
}
res.json({
events: events.map((event) => ({
...event,
payload: parseJson(event.payload),
})),
});
} catch (error) {
sendError(res, error, "operations_timeline");
}
});
/**
* @openapi
* /operations/signals:
* get:
* summary: Read status tiles
* description: Answers from memory right away and refreshes stale hosts behind it, so a page should poll. Reads hosts the caller can reach.
* tags:
* - Operations
* parameters:
* - in: query
* name: hostIds
* description: Comma separated. Leave out for every host.
* schema:
* type: string
* - in: query
* name: ids
* description: Comma separated signal ids. Leave out for all.
* schema:
* type: string
* responses:
* 200:
* description: One entry per signal with per host results.
*/
router.get("/signals", authenticateJWT, async (req, res) => {
const authReq = req as OperationsRequest;
try {
const catalog = await buildCatalog({ userId: authReq.userId });
const wanted =
typeof req.query.ids === "string" && req.query.ids
? new Set(req.query.ids.split(","))
: null;
const hostIds = hostIdsFrom(req.query.hostIds);
const signals = [];
for (const signal of catalog.signals) {
if (wanted && !wanted.has(signal.id)) continue;
if (signal.scope === "server") {
const outcome = await runOperation({
actionId: signal.read,
internal: true,
userId: authReq.userId,
origin: originOf(authReq),
}).catch(() => null);
signals.push({
id: signal.id,
hosts: [],
result: outcome?.data as SignalResult | undefined,
});
continue;
}
signals.push({
id: signal.id,
hosts: await readAcrossHosts<SignalResult>({
userId: authReq.userId,
actionId: signal.read,
hostIds,
origin: originOf(authReq),
}),
});
}
res.json({ signals });
} catch (error) {
sendError(res, error, "operations_signals");
}
});
/**
* @openapi
* /operations/resources/{id}:
* get:
* summary: Read one kind of thing across hosts
* description: Rows from cache plus each host's state (ok, loading, offline, error, no-access). Answers right away and refreshes stale hosts behind it, so a page should poll until no host is loading.
* tags:
* - Operations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* - in: query
* name: hostIds
* description: Comma separated. Leave out for every host.
* schema:
* type: string
* - in: query
* name: refresh
* schema:
* type: boolean
* responses:
* 200:
* description: Rows and per host state.
* 404:
* description: No such resource, or the caller may not read it.
*/
router.get("/resources/:id", authenticateJWT, async (req, res) => {
const authReq = req as OperationsRequest;
try {
const catalog = await buildCatalog({ userId: authReq.userId });
const resource = catalog.resources.find(
(entry) => entry.id === req.params.id,
);
if (!resource) {
res.status(404).json({ error: "Resource not found" });
return;
}
const reads = await readAcrossHosts<ResourceListResult>({
userId: authReq.userId,
actionId: resource.list,
hostIds: hostIdsFrom(req.query.hostIds),
refresh: req.query.refresh === "true",
origin: originOf(authReq),
});
res.json(flattenResourceRows(reads, resource.key));
} catch (error) {
sendError(res, error, "operations_resources");
}
});
/**
* @openapi
* /operations/{id}/run:
* post:
* summary: Run an action
* description: Goes through permission, host access, input and approval checks. A change returns as soon as it starts unless wait is true, then poll the run. A read always answers inline.
* tags:
* - Operations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* resourceKey:
* type: string
* input:
* type: object
* dryRun:
* type: boolean
* confirmed:
* type: boolean
* idempotencyKey:
* type: string
* wait:
* type: boolean
* responses:
* 200:
* description: The outcome, or a run waiting for approval.
* 400:
* description: Invalid input.
* 403:
* description: Not allowed.
* 409:
* description: Needs a confirm, or safe mode is on.
*/
router.post("/:id/run", authenticateJWT, async (req, res) => {
const authReq = req as OperationsRequest;
const body = (req.body ?? {}) as Record<string, unknown>;
try {
res.json(
await runOperation({
actionId: String(req.params.id),
hostId: typeof body.hostId === "number" ? body.hostId : undefined,
resourceKey:
typeof body.resourceKey === "string" ? body.resourceKey : undefined,
input: body.input,
dryRun: body.dryRun === true,
confirmed: body.confirmed === true,
idempotencyKey:
typeof body.idempotencyKey === "string"
? body.idempotencyKey.slice(0, 128)
: undefined,
userId: authReq.userId,
origin: originOf(authReq),
apiKeyScopes: authReq.apiKeyScopes,
}),
);
} catch (error) {
sendError(res, error, "operations_run_action");
}
});
export default router;
@@ -1,5 +1,6 @@
import type { RequestHandler, Router } from "express"; import type { RequestHandler, Router } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js"; import type { AuthenticatedRequest } from "../../../types/index.js";
import { API_KEY_SCOPES } from "../../operations/key-scopes.js";
import crypto from "crypto"; import crypto from "crypto";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
@@ -42,6 +43,12 @@ export function registerUserApiKeyRoutes(
* type: string * type: string
* format: date-time * format: date-time
* description: Optional expiration date. Null means the key never expires. * description: Optional expiration date. Null means the key never expires.
* scopes:
* type: array
* items:
* type: string
* enum: [operations:read, operations:run]
* description: Limits the key to actions only. Leave out for full access.
* responses: * responses:
* 201: * 201:
* description: API key created. Contains the full token (shown only once). * description: API key created. Contains the full token (shown only once).
@@ -56,7 +63,7 @@ export function registerUserApiKeyRoutes(
*/ */
router.post("/api-keys", requireAdmin, async (req, res) => { router.post("/api-keys", requireAdmin, async (req, res) => {
try { try {
const { name, userId: targetUserId, expiresAt } = req.body; const { name, userId: targetUserId, expiresAt, scopes } = req.body;
const apiKeyRepository = createCurrentApiKeyRepository(); const apiKeyRepository = createCurrentApiKeyRepository();
const userRepository = createCurrentUserRepository(); const userRepository = createCurrentUserRepository();
@@ -86,6 +93,21 @@ export function registerUserApiKeyRoutes(
expiresAtValue = parsed.toISOString(); expiresAtValue = parsed.toISOString();
} }
let scopesValue: string[] | null = null;
if (scopes !== undefined && scopes !== null) {
if (
!Array.isArray(scopes) ||
scopes.some(
(scope) =>
!(API_KEY_SCOPES as readonly string[]).includes(String(scope)),
)
) {
return res.status(400).json({ error: "Unknown API key scope" });
}
scopesValue =
scopes.length > 0 ? [...new Set(scopes as string[])] : null;
}
const rawToken = "tmx_" + crypto.randomBytes(32).toString("hex"); const rawToken = "tmx_" + crypto.randomBytes(32).toString("hex");
const tokenPrefix = rawToken.substring(0, 12); const tokenPrefix = rawToken.substring(0, 12);
const tokenHash = await bcrypt.hash(rawToken, 10); const tokenHash = await bcrypt.hash(rawToken, 10);
@@ -102,6 +124,7 @@ export function registerUserApiKeyRoutes(
expiresAt: expiresAtValue, expiresAt: expiresAtValue,
lastUsedAt: null, lastUsedAt: null,
isActive: true, isActive: true,
scopes: scopesValue ? JSON.stringify(scopesValue) : null,
}); });
const actorId = (req as AuthenticatedRequest).userId; const actorId = (req as AuthenticatedRequest).userId;
@@ -119,6 +142,7 @@ export function registerUserApiKeyRoutes(
details: JSON.stringify({ details: JSON.stringify({
targetUserId, targetUserId,
targetUsername: targetUser.username, targetUsername: targetUser.username,
scopes: scopesValue,
}), }),
ipAddress, ipAddress,
userAgent, userAgent,
@@ -133,6 +157,7 @@ export function registerUserApiKeyRoutes(
tokenPrefix, tokenPrefix,
createdAt: now, createdAt: now,
expiresAt: expiresAtValue, expiresAt: expiresAtValue,
scopes: scopesValue,
token: rawToken, token: rawToken,
}); });
} catch (err) { } catch (err) {
+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();
}
+231
View File
@@ -0,0 +1,231 @@
/**
* Reads for All hosts tables and status tiles.
*
* Asking every host over SSH on each page load cannot hold up, so a request
* answers right away from memory and refreshes stale hosts behind it. The page
* polls and the table fills in as hosts answer. Results stay in memory, never
* SQLite, since a save rewrites the whole database.
*
* Keys include the user: someone a host is shared with can have their own
* login there and see different rows.
*/
import type {
ResourceListResult,
SignalResult,
SignalStatus,
} from "@termix-ssh/plugin-sdk/operations";
import { RESOURCE_FRESH_MS } from "./constants.js";
import { coalesce } from "./limits.js";
import { getAction } from "./registry.js";
import { matchesWhen } from "@termix-ssh/plugin-sdk/operations";
import { loadOperationHost } from "./hosts.js";
import type { ActorOrigin } from "../plugins/actor.js";
import { hostStatus } from "../plugins/host-status-provider.js";
import { PermissionManager } from "../utils/permission-manager.js";
import { loadProbeTargets } from "../plugins/ctx-probe.js";
export type HostReadState =
"ok" | "loading" | "offline" | "error" | "no-access" | "not-applicable";
interface CacheEntry {
value: unknown;
error?: string;
fetchedAt: number;
}
export interface HostRead<T> {
hostId: number;
state: HostReadState;
value?: T;
error?: string;
fetchedAt?: string;
}
const cache = new Map<string, CacheEntry>();
const MAX_ENTRIES = 20_000;
function cacheKey(userId: string, actionId: string, hostId: number): string {
return `${userId}|${actionId}|${hostId}`;
}
/** After a change, the next read of this host goes back to the host. */
export function dropResourceCache(hostId: number): void {
const suffix = `|${hostId}`;
for (const key of cache.keys()) {
if (key.endsWith(suffix)) cache.delete(key);
}
}
function remember(key: string, entry: CacheEntry): void {
if (cache.size >= MAX_ENTRIES) {
const oldest = cache.keys().next().value;
if (oldest) cache.delete(oldest);
}
cache.delete(key);
cache.set(key, entry);
}
function isOffline(hostId: number): boolean {
const status = hostStatus.get(hostId) as
string | { status?: string } | null | undefined;
const value = typeof status === "string" ? status : status?.status;
return value === "offline";
}
interface ReadOptions {
userId: string;
actionId: string;
hostIds?: number[];
refresh?: boolean;
input?: Record<string, unknown>;
origin: ActorOrigin;
}
function refreshHost(
options: ReadOptions,
hostId: number,
key: string,
): Promise<unknown> {
return coalesce(key, async () => {
const { runOperation, OperationError } = await import("./gate.js");
try {
const outcome = await runOperation({
actionId: options.actionId,
hostId,
input: options.input,
internal: true,
userId: options.userId,
origin: options.origin,
});
if (outcome.status !== "success") {
remember(key, {
value: undefined,
error: outcome.error ?? outcome.status,
fetchedAt: Date.now(),
});
return;
}
remember(key, { value: outcome.data, fetchedAt: Date.now() });
} catch (error) {
remember(key, {
value: undefined,
error:
error instanceof OperationError || error instanceof Error
? error.message
: String(error),
fetchedAt: Date.now(),
});
}
});
}
/** One entry per host the user can reach. Never waits on a host. */
export async function readAcrossHosts<T>(
options: ReadOptions,
): Promise<HostRead<T>[]> {
const registration = getAction(options.actionId);
if (!registration) return [];
const { action } = registration;
const { getPluginRuntime } = await import("../plugins/index.js");
const manifest = getPluginRuntime().loader.get(
registration.pluginId,
)?.manifest;
if (!manifest) return [];
const permissions = PermissionManager.getInstance();
const targets = await loadProbeTargets(options.userId, options.hostIds);
const reads: HostRead<T>[] = [];
for (const target of targets) {
const hostId = target.id;
const access = await permissions.canAccessHost(
options.userId,
hostId,
action.hostAccess ?? "connect",
);
if (!access.hasAccess) {
reads.push({ hostId, state: "no-access" });
continue;
}
if (action.when) {
const host = await loadOperationHost(hostId, manifest);
if (!host || !matchesWhen(action.when, host)) continue;
}
const key = cacheKey(options.userId, options.actionId, hostId);
const cached = cache.get(key);
const fresh = cached && Date.now() - cached.fetchedAt < RESOURCE_FRESH_MS;
if (isOffline(hostId)) {
reads.push({
hostId,
state: "offline",
value: cached?.value as T | undefined,
fetchedAt: cached
? new Date(cached.fetchedAt).toISOString()
: undefined,
});
continue;
}
if (!fresh || options.refresh) void refreshHost(options, hostId, key);
if (!cached) {
reads.push({ hostId, state: "loading" });
} else if (cached.error) {
reads.push({
hostId,
state: "error",
error: cached.error,
fetchedAt: new Date(cached.fetchedAt).toISOString(),
});
} else {
reads.push({
hostId,
state: "ok",
value: cached.value as T,
fetchedAt: new Date(cached.fetchedAt).toISOString(),
});
}
}
return reads;
}
export interface ResourceRow {
hostId: number;
key: string;
row: Record<string, unknown>;
}
/** Flattens per host reads into table rows, keeping each host's state. */
export function flattenResourceRows(
reads: HostRead<ResourceListResult>[],
keyField: string,
): { rows: ResourceRow[]; hosts: Omit<HostRead<unknown>, "value">[] } {
const rows: ResourceRow[] = [];
const hosts: Omit<HostRead<unknown>, "value">[] = [];
for (const read of reads) {
const { value, ...state } = read;
hosts.push(state);
for (const row of value?.rows ?? []) {
rows.push({ hostId: read.hostId, key: String(row[keyField]), row });
}
}
return { rows, hosts };
}
const WORST: SignalStatus[] = ["fail", "warn", "unknown", "ok"];
/** The worst status across hosts, for a server level tile. */
export function worstStatus(
results: (SignalResult | undefined)[],
): SignalStatus {
for (const status of WORST) {
if (results.some((result) => result?.status === status)) return status;
}
return "unknown";
}
/** Test seam. */
export function clearResourceCache(): void {
cache.clear();
}
+49 -2
View File
@@ -20,7 +20,28 @@
import { AsyncLocalStorage } from "node:async_hooks"; import { AsyncLocalStorage } from "node:async_hooks";
interface ActorStore { /** How a call reached core, for run history and approval rules. */
export interface ActorOrigin {
kind: "ui" | "api" | "plugin";
/** Which plugin asked, set by core from the ctx that made the call. */
pluginId?: string;
/** What the plugin was doing: automation, assistant, mcp, rollout and so on. */
via?: string;
}
/** Follows a call through plugin work, so nothing loses who started it. */
export interface ActorTrail {
origin?: ActorOrigin;
apiKeyId?: string;
/** Null means a key with full access. */
apiKeyScopes?: string[] | null;
/** The operation run this work belongs to. */
runId?: string;
/** How many operations or events led here. */
depth?: number;
}
interface ActorStore extends ActorTrail {
userId: string; userId: string;
/** Where the identity came from, for the audit line. */ /** Where the identity came from, for the audit line. */
source: "request" | "asUser" | "service"; source: "request" | "asUser" | "service";
@@ -35,8 +56,34 @@ export function runAsActor<T>(
source: ActorStore["source"], source: ActorStore["source"],
fn: () => T, fn: () => T,
sessionId?: string, sessionId?: string,
trail?: ActorTrail,
): T { ): T {
return storage.run({ userId, source, sessionId }, fn); const parent = storage.getStore();
return storage.run(
{
origin: parent?.origin,
apiKeyId: parent?.apiKeyId,
apiKeyScopes: parent?.apiKeyScopes,
runId: parent?.runId,
depth: parent?.depth,
...trail,
userId,
source,
sessionId,
},
fn,
);
}
export function getActorTrail(): ActorTrail {
const store = storage.getStore();
return {
origin: store?.origin,
apiKeyId: store?.apiKeyId,
apiKeyScopes: store?.apiKeyScopes,
runId: store?.runId,
depth: store?.depth,
};
} }
export function getActorSessionId(): string | undefined { export function getActorSessionId(): string | undefined {
+195
View File
@@ -0,0 +1,195 @@
/**
* ctx.operations: declaring what a plugin can do, and asking for another
* plugin's action to run.
*
* invoke acts for the current user, so on its own it would let any plugin spend
* that user's full authority. It needs the capability, the action named in the
* manifest's invokes list, and the origin always names the calling plugin, set
* here from the closure rather than from anything the plugin passes.
*/
import type {
PluginOperations,
PluginOperationInvokeOptions,
} from "@termix-ssh/plugin-sdk/backend";
import {
invokeAllowed,
type OperationAction,
} from "@termix-ssh/plugin-sdk/operations";
import type { PluginManifest } from "./manifest.js";
import { assertCapability, capabilityRefused } from "./permissions.js";
import { getActor, getActorTrail, type ActorOrigin } from "./actor.js";
import type { DisposableBag } from "./disposables.js";
import {
registerAction,
revokeAction,
type OperationHandler,
} from "../operations/registry.js";
import { provideApprovalPolicy } from "../operations/approvals.js";
import {
emitOperationEvent,
subscribeOperationEvents,
} from "../operations/events.js";
import {
drainPlugin,
OperationError,
runOperation,
} from "../operations/gate.js";
import { buildCatalog } from "../operations/catalog.js";
interface Deps {
manifest: PluginManifest;
bag: DisposableBag;
}
export function createPluginOperations({
manifest,
bag,
}: Deps): PluginOperations {
const pluginId = manifest.id;
const declared = manifest.capabilities;
const contribution = manifest.contributes?.operations;
let drainRegistered = false;
const declaredAction = (id: string): OperationAction => {
const action = contribution?.actions?.find((entry) => entry.id === id);
if (!action) {
throw new Error(
`Plugin ${pluginId} cannot register action "${id}": it is not declared in contributes.operations`,
);
}
return action;
};
const actingUser = (): string => {
const userId = getActor();
if (!userId) {
throw new Error(
"ctx.operations needs an acting user: call it inside a request or ctx.asUser",
);
}
return userId;
};
return {
register: (id, handler) => {
if (!declared.includes("operations:provide")) {
throw capabilityRefused(pluginId, "operations:provide");
}
const registration = registerAction(
pluginId,
declaredAction(id),
handler as OperationHandler,
);
bag.add(() => void revokeAction(registration), `action "${id}"`);
// Added once and after the actions, so disposal, which runs newest
// first, drains before anything the handlers use is torn down.
if (!drainRegistered) {
drainRegistered = true;
bag.add(() => drainPlugin(pluginId), "operations drain");
}
},
// A plugin's own route running its own action, so a button press lands in
// the same history. The caller stays the origin, not this plugin.
run: async (actionId, options = {}) => {
if (!declared.includes("operations:provide")) {
throw capabilityRefused(pluginId, "operations:provide");
}
declaredAction(actionId);
const trail = getActorTrail();
return runOperation({
actionId,
hostId: options.hostId,
resourceKey: options.resourceKey,
input: options.input,
dryRun: options.dryRun,
confirmed: options.confirmed,
idempotencyKey: options.idempotencyKey,
batchId: options.batchId,
userId: actingUser(),
origin: trail.origin ?? { kind: "plugin", pluginId },
apiKeyScopes: trail.apiKeyScopes,
depth: trail.depth,
});
},
invoke: async (actionId, options = {}) => {
await assertCapability(pluginId, "operations:invoke", declared);
if (!invokeAllowed(contribution?.invokes, actionId)) {
throw new OperationError(
"FORBIDDEN",
`${pluginId} did not declare that it runs ${actionId}`,
);
}
const trail = getActorTrail();
const origin: ActorOrigin = {
kind: "plugin",
pluginId,
via: (options as PluginOperationInvokeOptions).via,
};
return runOperation({
actionId,
hostId: options.hostId,
resourceKey: options.resourceKey,
input: options.input,
dryRun: options.dryRun,
confirmed: options.confirmed,
idempotencyKey: options.idempotencyKey,
batchId: options.batchId,
userId: actingUser(),
origin,
apiKeyScopes: trail.apiKeyScopes,
depth: trail.depth,
});
},
list: async (options) => {
await assertCapability(pluginId, "operations:invoke", declared);
return buildCatalog({
userId: actingUser(),
lang: options?.lang,
surface: options?.surface,
});
},
emit: async (eventId, payload, options) => {
if (!declared.includes("operations:provide")) {
throw capabilityRefused(pluginId, "operations:provide");
}
const event = contribution?.events?.find((entry) => entry.id === eventId);
if (!event) {
throw new Error(
`Plugin ${pluginId} cannot emit "${eventId}": it is not declared in contributes.operations`,
);
}
await emitOperationEvent({
eventId,
pluginId,
hostId: options?.hostId,
severity: event.severity,
payload,
});
},
onEvent: (subscription) => {
if (!declared.includes("operations:invoke")) {
throw capabilityRefused(pluginId, "operations:invoke");
}
const remove = subscribeOperationEvents(pluginId, subscription);
bag.add(remove, `events "${subscription.pattern}"`);
return remove;
},
approvals: {
provide: (provider) => {
if (!declared.includes("operations:approve")) {
throw capabilityRefused(pluginId, "operations:approve");
}
const remove = provideApprovalPolicy(pluginId, manifest, provider);
bag.add(remove, "approval rules");
return remove;
},
},
};
}
+3
View File
@@ -68,6 +68,7 @@ import { createPluginAuth, createPluginSsh } from "./ctx-ssh-auth.js";
import { createPluginHosts } from "./ctx-hosts.js"; import { createPluginHosts } from "./ctx-hosts.js";
import { createPluginSchedule } from "./schedule.js"; import { createPluginSchedule } from "./schedule.js";
import { createPluginFetch, createPluginNotify } from "./ctx-notify.js"; import { createPluginFetch, createPluginNotify } from "./ctx-notify.js";
import { createPluginOperations } from "./ctx-operations.js";
import { createPluginProcess } from "./ctx-process.js"; import { createPluginProcess } from "./ctx-process.js";
import { createPluginSystem } from "./ctx-system.js"; import { createPluginSystem } from "./ctx-system.js";
import { createPluginPlugins } from "./ctx-plugins.js"; import { createPluginPlugins } from "./ctx-plugins.js";
@@ -976,6 +977,8 @@ export function createPluginContext(
void reportRuntimeError(pluginId, error); void reportRuntimeError(pluginId, error);
}), }),
ssh: createPluginSsh({ manifest, bag: handle.bag, audit: auditCall }), ssh: createPluginSsh({ manifest, bag: handle.bag, audit: auditCall }),
operations: createPluginOperations({ manifest, bag: handle.bag }),
notify: createPluginNotify({ notify: createPluginNotify({
manifest, manifest,
audit: auditCall, audit: auditCall,
+8 -2
View File
@@ -354,15 +354,21 @@ export function createPluginRouter({
// After auth, so the actor is the user core authenticated rather than // After auth, so the actor is the user core authenticated rather than
// whatever the request claimed. // whatever the request claimed.
outer.use((req: Request, _res: Response, next: NextFunction) => { outer.use((req: Request, _res: Response, next: NextFunction) => {
const { userId, sessionId } = req as Request & { const { userId, sessionId, apiKeyId, apiKeyScopes } = req as Request & {
userId?: string; userId?: string;
sessionId?: string; sessionId?: string;
apiKeyId?: string;
apiKeyScopes?: string[] | null;
}; };
if (!userId) { if (!userId) {
next(); next();
return; return;
} }
runAsActor(userId, "request", () => next(), sessionId); runAsActor(userId, "request", () => next(), sessionId, {
origin: { kind: apiKeyId ? "api" : "ui" },
apiKeyId,
apiKeyScopes,
});
}); });
outer.use(inner); outer.use(inner);
+13 -2
View File
@@ -5,7 +5,7 @@
import { pluginLogger } from "../utils/logger.js"; import { pluginLogger } from "../utils/logger.js";
import { getPluginRuntime } from "./index.js"; import { getPluginRuntime } from "./index.js";
import { onboardingDefaults } from "./bundled-index.js"; import { isDefaultLookPlugin, onboardingDefaults } from "./bundled-index.js";
import { import {
isLinkedDesktop, isLinkedDesktop,
serialized, serialized,
@@ -99,6 +99,11 @@ export async function offerPluginSetup(fresh: boolean): Promise<void> {
await settings.set(PLUGIN_SETUP_OFFERED_KEY, "1"); await settings.set(PLUGIN_SETUP_OFFERED_KEY, "1");
} }
// The shipped default look always stays on, so the picker never offers it.
function inPicker(plugin: { id: string; source: string }): boolean {
return !(plugin.source === "bundled" && isDefaultLookPlugin(plugin.id));
}
function choicePlugins(): ChoicePlugin[] { function choicePlugins(): ChoicePlugin[] {
return getPluginRuntime() return getPluginRuntime()
.loader.list() .loader.list()
@@ -118,6 +123,7 @@ export async function describeOnboardingPlugins(): Promise<OnboardingPluginList>
); );
const plugins = getPluginRuntime() const plugins = getPluginRuntime()
.loader.list() .loader.list()
.filter(inPicker)
.map((plugin): OnboardingPluginInfo => { .map((plugin): OnboardingPluginInfo => {
const defaults = const defaults =
plugin.source === "bundled" plugin.source === "bundled"
@@ -158,7 +164,12 @@ export function applyOnboardingChoices(
): Promise<OnboardingApplyResult> { ): Promise<OnboardingApplyResult> {
return serialized(async () => { return serialized(async () => {
const plugins = choicePlugins(); const plugins = choicePlugins();
const installed = new Set(plugins.map((plugin) => plugin.id)); const installed = new Set(
getPluginRuntime()
.loader.list()
.filter(inPicker)
.map((plugin) => plugin.id),
);
const wanted: Record<string, PluginChoice> = {}; const wanted: Record<string, PluginChoice> = {};
for (const [id, choice] of Object.entries(choices)) { for (const [id, choice] of Object.entries(choices)) {
if (installed.has(id)) wanted[id] = choice; if (installed.has(id)) wanted[id] = choice;
+3
View File
@@ -243,6 +243,9 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
// Last, so a plugin's activate() sees a fully wired server. A plugin that // Last, so a plugin's activate() sees a fully wired server. A plugin that
// fails to load must not stop the backend, so this never rejects. // fails to load must not stop the backend, so this never rejects.
try { try {
const { startOperationsMaintenance } =
await import("./operations/maintenance.js");
await startOperationsMaintenance();
const { initializePlugins } = await import("./plugins/index.js"); const { initializePlugins } = await import("./plugins/index.js");
const loaded = await initializePlugins({ freshInstall }); const loaded = await initializePlugins({ freshInstall });
if (loaded.length > 0) { if (loaded.length > 0) {
@@ -0,0 +1,277 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
userId: "user-1",
apiKeyId: undefined as string | undefined,
apiKeyScopes: undefined as string[] | null | undefined,
actingAdminUserId: undefined as string | undefined,
viewAll: false,
hostAccess: false,
runs: new Map<string, Record<string, unknown>>(),
runOperation: vi.fn(),
decide: vi.fn(),
cancel: vi.fn(async () => true),
}));
vi.mock("../../../utils/logger.js", () => ({
databaseLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() },
pluginLogger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() },
}));
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() =>
(
req: Record<string, unknown>,
_res: unknown,
next: () => void,
): void => {
req.userId = state.userId;
req.apiKeyId = state.apiKeyId;
req.apiKeyScopes = state.apiKeyScopes;
req.actingAdminUserId = state.actingAdminUserId;
next();
},
}),
},
}));
vi.mock("../../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
hasPermission: async () => state.viewAll,
canAccessHost: async () => ({ hasAccess: state.hostAccess }),
}),
},
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentOperationRepository: () => ({
findRun: async (id: string) => state.runs.get(id) ?? null,
listRuns: async (filters: { userId?: string }) =>
[...state.runs.values()].filter(
(run) => !filters.userId || run.userId === filters.userId,
),
listPendingApprovals: async () => [],
listEvents: async () => [],
}),
}));
vi.mock("../../../operations/catalog.js", () => ({
buildCatalog: vi.fn(async () => ({
actions: [{ id: "demo.restart" }],
events: [],
resources: [],
signals: [],
})),
}));
vi.mock("../../../operations/resources.js", () => ({
flattenResourceRows: vi.fn(),
readAcrossHosts: vi.fn(async () => []),
}));
vi.mock("../../../operations/gate.js", () => {
class OperationError extends Error {
status: number;
constructor(
readonly code: string,
message: string,
status = 403,
) {
super(message);
this.status = status;
}
}
return {
OperationError,
runOperation: state.runOperation,
decideOperationApproval: state.decide,
cancelOperationRun: state.cancel,
};
});
const { default: router } =
await import("../../../database/routes/operations-routes.js");
const { OperationError } = await import("../../../operations/gate.js");
const app = express();
app.use(express.json());
app.use("/operations", router);
const run = (overrides: Record<string, unknown> = {}) => ({
id: "run-1",
actionId: "demo.restart",
pluginId: "demo",
pluginVersion: "1.0.0",
userId: "user-1",
username: "name",
origin: '{"kind":"ui"}',
hostId: 1,
hostName: "web",
batchId: null,
dryRun: false,
risk: "change",
status: "success",
summary: "done",
changed: true,
input: '{"token":"[redacted]"}',
sealedInput: "sealed:secret",
output: null,
undoInput: null,
error: null,
errorCode: null,
undoOf: null,
createdAt: "2026-01-01",
startedAt: null,
finishedAt: null,
...overrides,
});
beforeEach(() => {
state.userId = "user-1";
state.apiKeyId = undefined;
state.apiKeyScopes = undefined;
state.actingAdminUserId = undefined;
state.viewAll = false;
state.hostAccess = false;
state.runs.clear();
state.runOperation.mockReset();
state.runOperation.mockResolvedValue({ runId: "r", status: "success" });
state.decide.mockReset();
state.decide.mockResolvedValue({ runId: "r", status: "success" });
});
describe("POST /operations/:id/run", () => {
it("passes a session as a ui origin", async () => {
await request(app)
.post("/operations/demo.restart/run")
.send({ hostId: 1, input: { name: "x" } })
.expect(200);
expect(state.runOperation).toHaveBeenCalledWith(
expect.objectContaining({
actionId: "demo.restart",
hostId: 1,
userId: "user-1",
origin: { kind: "ui" },
}),
);
});
it("passes a key as an api origin with its scopes", async () => {
state.apiKeyId = "key";
state.apiKeyScopes = ["operations:read"];
await request(app)
.post("/operations/demo.restart/run")
.send({})
.expect(200);
expect(state.runOperation).toHaveBeenCalledWith(
expect.objectContaining({
origin: { kind: "api" },
apiKeyScopes: ["operations:read"],
}),
);
});
it("does not take the user or origin from the body", async () => {
await request(app)
.post("/operations/demo.restart/run")
.send({ userId: "admin", origin: { kind: "plugin", via: "automation" } })
.expect(200);
expect(state.runOperation).toHaveBeenCalledWith(
expect.objectContaining({ userId: "user-1", origin: { kind: "ui" } }),
);
});
it("sends a refusal's status and code", async () => {
state.runOperation.mockRejectedValue(
new (
OperationError as never as new (code: string, message: string) => Error
)("FORBIDDEN", "No"),
);
const response = await request(app)
.post("/operations/demo.restart/run")
.send({})
.expect(403);
expect(response.body).toMatchObject({ code: "FORBIDDEN", error: "No" });
});
});
describe("runs", () => {
it("never returns the sealed input", async () => {
state.runs.set("run-1", run());
const response = await request(app)
.get("/operations/runs/run-1")
.expect(200);
expect(JSON.stringify(response.body)).not.toContain("secret");
});
it("hides another user's run", async () => {
state.runs.set("run-1", run({ userId: "someone" }));
await request(app).get("/operations/runs/run-1").expect(404);
});
it("shows a run to someone who manages its host", async () => {
state.runs.set("run-1", run({ userId: "someone" }));
state.hostAccess = true;
await request(app).get("/operations/runs/run-1").expect(200);
});
it("lists only a user's own runs", async () => {
state.runs.set("a", run({ id: "a" }));
state.runs.set("b", run({ id: "b", userId: "someone" }));
const response = await request(app).get("/operations/runs").expect(200);
expect(response.body.runs.map((row: { id: string }) => row.id)).toEqual([
"a",
]);
});
it("lists everything with the view permission", async () => {
state.viewAll = true;
state.runs.set("a", run({ id: "a" }));
state.runs.set("b", run({ id: "b", userId: "someone" }));
const response = await request(app).get("/operations/runs").expect(200);
expect(response.body.runs).toHaveLength(2);
});
it("does not cancel another user's run", async () => {
state.runs.set("run-1", run({ userId: "someone" }));
await request(app).post("/operations/runs/run-1/cancel").expect(404);
expect(state.cancel).not.toHaveBeenCalled();
});
});
describe("POST /operations/approvals/:id", () => {
it("needs a clear answer", async () => {
await request(app)
.post("/operations/approvals/a")
.send({ approve: "yes" })
.expect(400);
expect(state.decide).not.toHaveBeenCalled();
});
it("refuses a scoped key", async () => {
state.apiKeyScopes = ["operations:run"];
await request(app)
.post("/operations/approvals/a")
.send({ approve: true })
.expect(403);
expect(state.decide).not.toHaveBeenCalled();
});
it("marks an impersonated request", async () => {
state.actingAdminUserId = "admin";
await request(app)
.post("/operations/approvals/a")
.send({ approve: true })
.expect(200);
expect(state.decide).toHaveBeenCalledWith(
expect.objectContaining({ impersonated: true }),
);
});
});
describe("GET /operations/catalog", () => {
it("answers", async () => {
const response = await request(app).get("/operations/catalog").expect(200);
expect(response.body.actions).toEqual([{ id: "demo.restart" }]);
});
});
+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([]);
});
});
@@ -0,0 +1,210 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginManifest } from "../../plugins/manifest.js";
const gate = vi.hoisted(() => ({
runOperation: vi.fn(async () => ({ runId: "r", status: "success" })),
drainPlugin: vi.fn(async () => {}),
}));
vi.mock("../../utils/logger.js", () => ({
pluginLogger: {
warn: vi.fn(),
info: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
vi.mock("../../operations/gate.js", () => ({
...gate,
OperationError: class extends Error {
constructor(_code: string, message: string) {
super(message);
}
},
}));
vi.mock("../../operations/catalog.js", () => ({
buildCatalog: vi.fn(async () => ({ actions: [] })),
}));
vi.mock("../../operations/events.js", () => ({
emitOperationEvent: vi.fn(async () => {}),
subscribeOperationEvents: vi.fn(() => () => {}),
}));
vi.mock("../../plugins/permissions.js", () => ({
assertCapability: vi.fn(
async (_id: string, capability: string, declared: string[]) => {
if (!declared.includes(capability)) throw new Error("refused");
},
),
capabilityRefused: (pluginId: string, capability: string) =>
new Error(`${pluginId} lacks ${capability}`),
}));
const { createPluginOperations } =
await import("../../plugins/ctx-operations.js");
const { DisposableBag } = await import("../../plugins/disposables.js");
const { clearOperationRegistry, getAction } =
await import("../../operations/registry.js");
const { runAsActor } = await import("../../plugins/actor.js");
const action = {
id: "demo.restart",
titleKey: "t",
scope: "host",
risk: "change",
permission: "use",
hostAccess: "edit",
};
function build(capabilities: string[], invokes?: string[]) {
const bag = new DisposableBag("demo");
const manifest = {
id: "demo",
capabilities,
contributes: { operations: { actions: [action], invokes } },
} as unknown as PluginManifest;
return { bag, operations: createPluginOperations({ manifest, bag }) };
}
const handler = { run: async () => ({ summary: "ok" }) };
beforeEach(() => {
clearOperationRegistry();
gate.runOperation.mockClear();
gate.drainPlugin.mockClear();
});
describe("register", () => {
it("needs the capability", () => {
const { operations } = build([]);
expect(() => operations.register("demo.restart", handler)).toThrow(
"operations:provide",
);
});
it("refuses an undeclared action", () => {
const { operations } = build(["operations:provide"]);
expect(() => operations.register("demo.other", handler)).toThrow(
"not declared",
);
});
it("drains, then revokes, when the plugin stops", async () => {
const { bag, operations } = build(["operations:provide"]);
operations.register("demo.restart", handler);
expect(getAction("demo.restart")).toBeDefined();
await bag.disposeAll();
expect(gate.drainPlugin).toHaveBeenCalledWith("demo");
expect(getAction("demo.restart")).toBeUndefined();
});
it("keeps the new handler when an old disposer runs after a restart", async () => {
const first = build(["operations:provide"]);
first.operations.register("demo.restart", handler);
const second = build(["operations:provide"]);
const fresh = { run: async () => ({ summary: "new" }) };
second.operations.register("demo.restart", fresh);
await first.bag.disposeAll();
expect(getAction("demo.restart")?.handler).toBe(fresh);
});
});
describe("invoke", () => {
const asUser = <T>(fn: () => Promise<T>) =>
runAsActor("user", "request", fn, undefined, {
origin: { kind: "ui" },
apiKeyScopes: ["operations:read"],
});
it("needs the capability", async () => {
const { operations } = build([], ["*"]);
await expect(
asUser(() => operations.invoke("docker.restart")),
).rejects.toThrow("refused");
});
it("only reaches what the manifest lists", async () => {
const { operations } = build(["operations:invoke"], ["docker.*"]);
await expect(
asUser(() => operations.invoke("firewall.allow")),
).rejects.toThrow("did not declare");
expect(gate.runOperation).not.toHaveBeenCalled();
});
it("names the plugin as origin and keeps the key's limits", async () => {
const { operations } = build(["operations:invoke"], ["docker.*"]);
await asUser(() =>
operations.invoke("docker.restart", { via: "automation" }),
);
expect(gate.runOperation).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user",
origin: { kind: "plugin", pluginId: "demo", via: "automation" },
apiKeyScopes: ["operations:read"],
}),
);
});
it("does not let a plugin pick its own origin", async () => {
const { operations } = build(["operations:invoke"], ["*"]);
await asUser(() =>
operations.invoke("docker.restart", {
origin: { kind: "ui" },
} as never),
);
expect(gate.runOperation).toHaveBeenCalledWith(
expect.objectContaining({
origin: expect.objectContaining({ kind: "plugin", pluginId: "demo" }),
}),
);
});
it("needs an acting user", async () => {
const { operations } = build(["operations:invoke"], ["*"]);
await expect(operations.invoke("docker.restart")).rejects.toThrow(
"acting user",
);
});
});
describe("run", () => {
it("keeps the caller as origin for a plugin's own action", async () => {
const { operations } = build(["operations:provide"]);
await runAsActor(
"user",
"request",
() => operations.run("demo.restart", { hostId: 1 }),
undefined,
{ origin: { kind: "ui" } },
);
expect(gate.runOperation).toHaveBeenCalledWith(
expect.objectContaining({ origin: { kind: "ui" }, hostId: 1 }),
);
});
it("refuses another plugin's action", async () => {
const { operations } = build(["operations:provide"]);
await expect(
runAsActor("user", "request", () => operations.run("docker.restart")),
).rejects.toThrow("not declared");
});
});
describe("emit", () => {
it("refuses an undeclared event", async () => {
const { operations } = build(["operations:provide"]);
await expect(operations.emit("demo.nothing")).rejects.toThrow(
"not declared",
);
});
});
describe("approvals", () => {
it("needs the capability", () => {
const { operations } = build([]);
expect(() => operations.approvals.provide(() => null)).toThrow(
"operations:approve",
);
});
});
@@ -197,6 +197,13 @@ describe("describeOnboardingPlugins", () => {
}); });
expect(byId.extra).toMatchObject({ recommended: true, consent: true }); expect(byId.extra).toMatchObject({ recommended: true, consent: true });
}); });
it("leaves out the default look", async () => {
writeIndex({ extra: { defaultLook: true } });
await chain();
const list = await onboarding.describeOnboardingPlugins();
expect(list.plugins.map((p) => p.id).sort()).toEqual(["addon", "base"]);
});
}); });
describe("applyOnboardingChoices", () => { describe("applyOnboardingChoices", () => {
@@ -263,6 +270,22 @@ describe("applyOnboardingChoices", () => {
expect(db.settings.get(onboarding.PLUGIN_SETUP_PENDING_KEY)).toBe("1"); expect(db.settings.get(onboarding.PLUGIN_SETUP_PENDING_KEY)).toBe("1");
}); });
it("never turns off or removes the default look", async () => {
writeIndex({ extra: { defaultLook: true } });
const loader = await chain();
const result = await onboarding.applyOnboardingChoices({
extra: "remove",
base: "remove",
addon: "remove",
});
expect(result.failed).toEqual([]);
expect(result.removed).toEqual(["addon", "base"]);
expect(loader.get("extra")?.state).toBe("active");
await onboarding.applyOnboardingChoices({ extra: "disabled" });
expect(loader.get("extra")?.state).toBe("active");
});
it("ignores ids that are not installed", async () => { it("ignores ids that are not installed", async () => {
await chain(); await chain();
const result = await onboarding.applyOnboardingChoices({ const result = await onboarding.applyOnboardingChoices({
@@ -0,0 +1,297 @@
import { describe, expect, it } from "vitest";
import { parseManifest } from "@termix-ssh/plugin-sdk/manifest";
import {
invokeAllowed,
matchesWhen,
onSurface,
parseOperationInput,
redactFields,
toJsonSchema,
type OperationAction,
type OperationField,
} from "@termix-ssh/plugin-sdk/operations";
const action = (overrides: Partial<OperationAction> = {}): OperationAction => ({
id: "demo.allow-port",
titleKey: "ops.allow",
scope: "host",
risk: "change",
permission: "use",
hostAccess: "edit",
...overrides,
});
function manifest(operations: unknown, capabilities = ["operations:provide"]) {
return {
id: "demo",
name: "Demo",
version: "1.0.0",
description: "A demo.",
author: { name: "test" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=26.11.1-0", api: "^1.13" },
capabilities,
contributes: {
permissions: [
{ name: "use", titleKey: "perm.use", descriptionKey: "perm.useDesc" },
],
operations,
},
};
}
function errorsFor(operations: unknown, capabilities?: string[]): string[] {
return parseManifest(manifest(operations, capabilities)).errors ?? [];
}
describe("contributes.operations", () => {
it("accepts a full declaration", () => {
const errors = errorsFor({
actions: [
action({
check: "demo.port-allowed",
inputs: [
{ key: "port", type: "number", required: true, min: 1, max: 65535 },
{ key: "name", type: "resource", resource: "demo.rule" },
],
}),
action({
id: "demo.port-allowed",
risk: "read",
hostAccess: "connect",
}),
action({
id: "demo.list-rules",
risk: "read",
hostAccess: "connect",
}),
],
events: [{ id: "demo.turned-off", titleKey: "e", scope: "host" }],
resources: [
{
id: "demo.rule",
titleKey: "r",
list: "demo.list-rules",
key: "id",
name: "name",
},
],
signals: [
{
id: "demo.rules",
titleKey: "s",
read: "demo.port-allowed",
scope: "host",
kind: "count",
fix: "demo.allow-port",
},
],
});
expect(errors).toEqual([]);
});
it("needs the capability", () => {
expect(errorsFor({ actions: [action()] }, [])).toContainEqual(
expect.stringContaining("operations:provide"),
);
});
it("refuses an id outside the plugin", () => {
expect(
errorsFor({ actions: [action({ id: "docker.stop" })] }),
).toContainEqual(expect.stringContaining('must start with "demo."'));
});
it("refuses a destructive action a connect level user could run", () => {
expect(
errorsFor({
actions: [action({ risk: "destructive", hostAccess: "connect" })],
}),
).toContainEqual(expect.stringContaining("edit or manage"));
});
it("lets a change declare its own level", () => {
expect(errorsFor({ actions: [action({ hostAccess: "connect" })] })).toEqual(
[],
);
});
it("refuses an undeclared permission", () => {
expect(
errorsFor({ actions: [action({ permission: "nope" })] }),
).toContainEqual(expect.stringContaining("is not declared"));
});
it("refuses a check that is not a read", () => {
expect(
errorsFor({
actions: [
action({ check: "demo.other" }),
action({ id: "demo.other" }),
],
}),
).toContainEqual(expect.stringContaining("must be a read action"));
});
it("refuses references that go nowhere", () => {
const errors = errorsFor({
actions: [action({ undo: "demo.gone" })],
resources: [
{
id: "demo.rule",
titleKey: "r",
list: "demo.gone",
key: "id",
name: "n",
},
],
});
expect(errors.length).toBeGreaterThanOrEqual(2);
});
it("refuses unknown fields", () => {
expect(
errorsFor({ actions: [{ ...action(), extra: true }] }),
).toContainEqual(expect.stringContaining('Unknown field "extra"'));
});
it("refuses a default on a secret", () => {
expect(
errorsFor({
actions: [
action({
inputs: [{ key: "token", type: "secret", default: "abc" }],
}),
],
}),
).toContainEqual(expect.stringContaining("not allowed on a secret"));
});
it("needs operations:invoke for invokes", () => {
expect(errorsFor({ invokes: ["docker.*"] }, [])).toContainEqual(
expect.stringContaining("operations:invoke"),
);
});
});
describe("parseOperationInput", () => {
const fields: OperationField[] = [
{ key: "port", type: "number", required: true, min: 1, max: 65535 },
{
key: "proto",
type: "select",
default: "tcp",
options: [
{ value: "tcp", labelKey: "t" },
{ value: "udp", labelKey: "u" },
],
},
{ key: "name", type: "string", pattern: "[a-z]+" },
{ key: "data", type: "json" },
];
it("coerces strings and fills defaults", () => {
expect(parseOperationInput(fields, { port: "443" })).toEqual({
values: { port: 443, proto: "tcp" },
errors: [],
});
});
it("refuses keys no field declares", () => {
expect(
parseOperationInput(fields, { port: 1, sudo: true }).errors,
).toContainEqual(expect.stringContaining('"sudo"'));
});
it("needs required fields", () => {
expect(parseOperationInput(fields, {}).errors).toEqual([
'"port" is required',
]);
});
it("checks bounds, options and the whole pattern", () => {
expect(parseOperationInput(fields, { port: 0 }).errors).toHaveLength(1);
expect(
parseOperationInput(fields, { port: 1, proto: "icmp" }).errors,
).toHaveLength(1);
expect(
parseOperationInput(fields, { port: 1, name: "abc; rm -rf" }).errors,
).toEqual(['"name" has the wrong format']);
});
it("parses json strings", () => {
expect(
parseOperationInput(fields, { port: 1, data: '{"a":1}' }).values.data,
).toEqual({ a: 1 });
});
it("refuses a redacted echo for a secret", () => {
const secret: OperationField[] = [{ key: "token", type: "secret" }];
expect(
parseOperationInput(secret, { token: { set: true } }).errors,
).toHaveLength(1);
});
it("refuses input that is not an object", () => {
expect(parseOperationInput(fields, "nope").errors).toHaveLength(1);
});
});
describe("helpers", () => {
it("redacts secrets and sensitive outputs", () => {
expect(
redactFields(
[
{ key: "token", type: "secret" },
{ key: "conf", type: "textarea", sensitive: true },
{ key: "port", type: "number" },
],
{ token: "abc", conf: "key", port: 1 },
),
).toEqual({ token: "[redacted]", conf: "[redacted]", port: 1 });
});
it("builds JSON Schema", () => {
const schema = toJsonSchema([
{ key: "port", type: "number", required: true, min: 1 },
{ key: "token", type: "secret" },
]) as { properties: Record<string, unknown>; required: string[] };
expect(schema.required).toEqual(["port"]);
expect(schema.properties.port).toEqual({ type: "number", minimum: 1 });
expect(schema.properties.token).toEqual({
type: "string",
writeOnly: true,
});
});
it("keeps AI and MCP opt in, the rest opt out", () => {
expect(onSurface({}, "assistant")).toBe(false);
expect(onSurface({}, "mcp")).toBe(false);
expect(onSurface({}, "cli")).toBe(true);
expect(onSurface({ surfaces: { assistant: true } }, "assistant")).toBe(
true,
);
expect(onSurface({ surfaces: { cli: false } }, "cli")).toBe(false);
});
it("matches invokes patterns", () => {
expect(invokeAllowed(["docker.*"], "docker.stop")).toBe(true);
expect(invokeAllowed(["docker.*"], "dockerx.stop")).toBe(false);
expect(invokeAllowed(["docker.stop"], "docker.start")).toBe(false);
expect(invokeAllowed(["*"], "anything.at-all")).toBe(true);
expect(invokeAllowed(undefined, "docker.stop")).toBe(false);
});
it("tests when against a host", () => {
const when = { settings: { enableDocker: true }, os: ["linux"] };
expect(
matchesWhen(when, { settings: { enableDocker: true }, os: "Linux" }),
).toBe(true);
expect(matchesWhen(when, { settings: {} })).toBe(false);
expect(
matchesWhen(when, { settings: { enableDocker: true }, os: "windows" }),
).toBe(false);
expect(matchesWhen(when, { settings: { enableDocker: true } })).toBe(true);
});
});
+12
View File
@@ -17,6 +17,7 @@ import { inArray, isNotNull } from "drizzle-orm";
import type { ExternalSessionRef } from "../auth/types.js"; import type { ExternalSessionRef } from "../auth/types.js";
import { listSecondFactors } from "../auth/registry.js"; import { listSecondFactors } from "../auth/registry.js";
import { SECOND_FACTOR_SETUP } from "../auth/policy.js"; import { SECOND_FACTOR_SETUP } from "../auth/policy.js";
import { keyScopeAllows, parseKeyScopes } from "../operations/key-scopes.js";
import type { DeviceType } from "./user-agent-parser.js"; import type { DeviceType } from "./user-agent-parser.js";
import { getDb } from "../database/db/index.js"; import { getDb } from "../database/db/index.js";
import { sessions } from "../database/db/schema.js"; import { sessions } from "../database/db/schema.js";
@@ -69,6 +70,7 @@ interface AuthenticatedRequest extends Request {
userId?: string; userId?: string;
sessionId?: string; sessionId?: string;
apiKeyId?: string; apiKeyId?: string;
apiKeyScopes?: string[] | null;
pendingTOTP?: boolean; pendingTOTP?: boolean;
dataKey?: Buffer; dataKey?: Buffer;
actingAdminUserId?: string; actingAdminUserId?: string;
@@ -879,8 +881,18 @@ class AuthManager {
}); });
}); });
const scopes = parseKeyScopes(matchedKey.scopes);
if (!keyScopeAllows(scopes, req.method, req.originalUrl ?? "")) {
res.status(403).json({
error: "This API key may not use this route",
code: "API_KEY_SCOPE",
});
return;
}
req.userId = matchedKey.userId; req.userId = matchedKey.userId;
req.apiKeyId = matchedKey.id; req.apiKeyId = matchedKey.id;
req.apiKeyScopes = scopes;
next(); next();
} catch (error) { } catch (error) {
databaseLogger.error("API key authentication failed", error, { databaseLogger.error("API key authentication failed", error, {
+1
View File
@@ -54,6 +54,7 @@ export const PERMISSION_CATALOG: PermissionCatalogEntry[] = [
"admin.roles.manage", "admin.roles.manage",
"admin.settings.manage", "admin.settings.manage",
"admin.sessions.manage", "admin.sessions.manage",
"admin.operations.view",
"admin.plugins.manage", "admin.plugins.manage",
], ],
}, },
+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. */ /** What clicking a host does when it already has an open tab. */
export type HostClickBehavior = export type HostClickBehavior =
"newTab" | "focusExisting" | "focusExistingDoubleClickNew"; "newTab" | "focusExisting" | "focusExistingDoubleClickNew" | "hostPage";
export interface HostSidebarFilterState { export interface HostSidebarFilterState {
status: ("online" | "offline" | "pinned")[]; status: ("online" | "offline" | "pinned")[];
@@ -136,6 +136,7 @@ const HOST_CLICK_BEHAVIORS: HostClickBehavior[] = [
"newTab", "newTab",
"focusExisting", "focusExisting",
"focusExistingDoubleClickNew", "focusExistingDoubleClickNew",
"hostPage",
]; ];
const STATUS_COLOR_SCHEMES: StatusColorScheme[] = ["accent", "status"]; const STATUS_COLOR_SCHEMES: StatusColorScheme[] = ["accent", "status"];
const FILTER_STATUS: HostSidebarFilterState["status"] = [ const FILTER_STATUS: HostSidebarFilterState["status"] = [
+4
View File
@@ -87,6 +87,8 @@ export interface ApiKey {
expiresAt: string | null; expiresAt: string | null;
lastUsedAt: string | null; lastUsedAt: string | null;
isActive: boolean; isActive: boolean;
/** Null for full access. */
scopes?: string[] | null;
} }
export interface CreatedApiKey extends ApiKey { export interface CreatedApiKey extends ApiKey {
@@ -97,12 +99,14 @@ export async function createApiKey(
name: string, name: string,
userId: string, userId: string,
expiresAt?: string, expiresAt?: string,
scopes?: string[] | null,
): Promise<CreatedApiKey> { ): Promise<CreatedApiKey> {
try { try {
const response = await authApi.post("/users/api-keys", { const response = await authApi.post("/users/api-keys", {
name, name,
userId, userId,
expiresAt: expiresAt ?? null, expiresAt: expiresAt ?? null,
scopes: scopes?.length ? scopes : null,
}); });
return response.data; return response.data;
} catch (error) { } catch (error) {
+1 -1
View File
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const alertVariants = cva( const alertVariants = cva(
"relative w-full border border-border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", "relative w-full border border-border px-4 py-3 text-sm not-has-[>svg]:block grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{ {
variants: { variants: {
variant: { variant: {
+4 -4
View File
@@ -18,19 +18,19 @@ export const PasswordInput = React.forwardRef<
<Input <Input
ref={ref} ref={ref}
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
className={cn("h-11 text-base pr-12", className)} // extra padding-right className={cn("h-8 pr-8", className)}
{...props} {...props}
/> />
<button <button
type="button" type="button"
onClick={() => setShowPassword((prev) => !prev)} onClick={() => setShowPassword((prev) => !prev)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition" className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition"
aria-label={showPassword ? "Hide password" : "Show password"} aria-label={showPassword ? "Hide password" : "Show password"}
> >
{showPassword ? ( {showPassword ? (
<EyeOff className="h-5 w-5" /> <EyeOff className="size-4" />
) : ( ) : (
<Eye className="h-5 w-5" /> <Eye className="size-4" />
)} )}
</button> </button>
</div> </div>
+28
View File
@@ -79,6 +79,34 @@ export function createPluginApi(pluginId: string): AxiosInstance {
}) as AxiosInstance; }) as AxiosInstance;
} }
/**
* An axios client rooted at core's /operations routes: the action catalog,
* runs, approvals, events, signals and cross host resources.
*/
export function createOperationsApi(): AxiosInstance {
return new Proxy(authApi, {
get(target, property, receiver) {
const value = Reflect.get(target, property, receiver);
if (typeof value !== "function") return value;
if (
property === "get" ||
property === "delete" ||
property === "post" ||
property === "put" ||
property === "patch"
) {
return (url: string, ...rest: unknown[]) =>
(value as (...args: unknown[]) => unknown).call(
target,
`/operations${url.startsWith("/") ? url : `/${url}`}`,
...rest,
);
}
return value.bind(target);
},
}) as AxiosInstance;
}
/** /**
* A raw fetch on this plugin's /plugin-api mount point, for a response the * A raw fetch on this plugin's /plugin-api mount point, for a response the
* caller reads as a stream (server-sent events). axios buffers the whole * caller reads as a stream (server-sent events). axios buffers the whole
+13
View File
@@ -609,6 +609,7 @@
"hostClickBehavior": "Clicking a host", "hostClickBehavior": "Clicking a host",
"hostClickBehaviorDesc": "What happens when you click a host that already has an open tab. Ctrl/Cmd click, middle click, or \"Open in new tab\" always start a new one.", "hostClickBehaviorDesc": "What happens when you click a host that already has an open tab. Ctrl/Cmd click, middle click, or \"Open in new tab\" always start a new one.",
"hostClickNewTab": "Always open a new tab", "hostClickNewTab": "Always open a new tab",
"hostClickHostPage": "Open the host page, when a plugin offers one",
"hostClickFocusExisting": "Switch to the open tab", "hostClickFocusExisting": "Switch to the open tab",
"hostClickFocusExistingDoubleNew": "Click to switch, double click for a new tab", "hostClickFocusExistingDoubleNew": "Click to switch, double click for a new tab",
"openInNewTab": "Open in new tab", "openInNewTab": "Open in new tab",
@@ -1214,6 +1215,18 @@
"title": "Send alerts", "title": "Send alerts",
"consequence": "To you or other users, and through the channels they set up." "consequence": "To you or other users, and through the channels they set up."
}, },
"operations:provide": {
"title": "Offer actions",
"consequence": "Its actions can be run from Automations, the CLI, the phone app and other places, by people allowed to use them."
},
"operations:invoke": {
"title": "Run other plugins' actions",
"consequence": "Acts for the signed-in user on the actions its manifest lists. Approval rules still apply."
},
"operations:approve": {
"title": "Decide what needs approval",
"consequence": "Can let actions run with no approval, or block them, for every user."
},
"notify:hub": { "notify:hub": {
"title": "Receive every alert", "title": "Receive every alert",
"consequence": "Stores and delivers the alerts all plugins send, including their contents." "consequence": "Stores and delivers the alerts all plugins send, including their contents."
+6 -2
View File
@@ -19,6 +19,7 @@ import {
} from "./onboarding-registry"; } from "./onboarding-registry";
import { import {
computeFlow, computeFlow,
isStepRelevant,
ONBOARDING_ADMIN_PERMISSION, ONBOARDING_ADMIN_PERMISSION,
seenFor, seenFor,
} from "./onboarding-flow"; } from "./onboarding-flow";
@@ -109,10 +110,13 @@ export function OnboardingStage({
const live = computeFlow(candidates, seenAtStart.current, ctx); const live = computeFlow(candidates, seenAtStart.current, ctx);
for (const step of live) sticky.current.add(step.id); for (const step of live) sticky.current.add(step.id);
// A step that was in the run stays in it (the picker stops being // A step that was in the run stays in it (the picker stops being
// pending once applied), unless its plugin went away. // pending once applied), unless its plugin went away or it no longer
// applies, like Security once no sign in plugin offers anything.
const kept = allSteps.filter( const kept = allSteps.filter(
(step) => (step) =>
sticky.current.has(step.id) && !live.some((s) => s.id === step.id), sticky.current.has(step.id) &&
!live.some((s) => s.id === step.id) &&
(!!step.forcePending || isStepRelevant(step, ctx)),
); );
return sortSteps([...live, ...kept]); return sortSteps([...live, ...kept]);
}, [allSteps, ctx]); }, [allSteps, ctx]);
+5 -4
View File
@@ -10,8 +10,9 @@ export function currentChoice(plugin: OnboardingPluginInfo): PluginChoice {
/** /**
* What the picker starts on. On a fresh install recommended plugins are on * What the picker starts on. On a fresh install recommended plugins are on
* and the rest are set to be removed; consent ones start on. Later runs * and the rest stay installed but off, so a full image keeps every plugin
* start from how things are now. * unless the admin picks Remove; consent ones start on. Later runs start from
* how things are now.
*/ */
export function defaultChoices( export function defaultChoices(
list: OnboardingPluginList, list: OnboardingPluginList,
@@ -26,7 +27,7 @@ export function defaultChoices(
return [plugin.id, currentChoice(plugin)]; return [plugin.id, currentChoice(plugin)];
} }
if (plugin.consent) return [plugin.id, "enabled"]; if (plugin.consent) return [plugin.id, "enabled"];
return [plugin.id, plugin.recommended ? "enabled" : "remove"]; return [plugin.id, plugin.recommended ? "enabled" : "disabled"];
}), }),
); );
} }
@@ -37,7 +38,7 @@ export function recommendedChoices(
return Object.fromEntries( return Object.fromEntries(
list.plugins.map((plugin) => [ list.plugins.map((plugin) => [
plugin.id, plugin.id,
plugin.recommended || plugin.consent ? "enabled" : "remove", plugin.recommended || plugin.consent ? "enabled" : "disabled",
]), ]),
); );
} }
+19
View File
@@ -41,6 +41,10 @@ import {
type KeybindingEditorProps, type KeybindingEditorProps,
} from "@/shell/keybinding-registry"; } from "@/shell/keybinding-registry";
import { registerHostProtocol } from "@/sidebar/host-protocols"; import { registerHostProtocol } from "@/sidebar/host-protocols";
import {
registerHostSection,
type HostSectionRenderProps,
} from "@/shell/host-sections";
import { import {
registerDashboardCard, registerDashboardCard,
type DashboardCardRenderProps, type DashboardCardRenderProps,
@@ -424,6 +428,21 @@ export function createPluginApp(
); );
}, },
registerHostSection(section) {
return track(
registerHostSection({
id: section.id,
pluginId,
titleKey: key(section.titleKey),
order: section.order,
when: hostCallback(section.when ?? (() => true)) as never,
component: scoped(
section.component as unknown as ComponentType<HostSectionRenderProps>,
),
}),
);
},
registerHostContextMenuItem(item) { registerHostContextMenuItem(item) {
return track( return track(
registerHostContextMenuItem({ registerHostContextMenuItem({
+10
View File
@@ -823,3 +823,13 @@ export {
rightDockableIds, rightDockableIds,
useRailItems, useRailItems,
} from "@/sidebar/rail-items"; } from "@/sidebar/rail-items";
export {
hostSectionsFor,
type HostSectionDef,
listHostSections,
useHostSections,
} from "@/shell/host-sections";
export { HostTabEmbed } from "@/shell/HostTabEmbed";
import { createOperationsApi } from "@/lib/plugin-transport";
/** Core's /operations routes, with the user's own session. */
export const operationsApi = createOperationsApi();
+1
View File
@@ -471,6 +471,7 @@ export {
resetHostContributions, resetHostContributions,
} from "@/sidebar/host-contributions"; } from "@/sidebar/host-contributions";
export { registerHostProtocol } from "@/sidebar/host-protocols"; export { registerHostProtocol } from "@/sidebar/host-protocols";
export { registerHostSection, resetHostSections } from "@/shell/host-sections";
export { export {
RAIL_ITEMS, RAIL_ITEMS,
registerRailItem, registerRailItem,
+45
View File
@@ -0,0 +1,45 @@
import { useMemo } from "react";
import type { Host, Tab } from "@/types/ui-types";
import type { TabShellCallbacks } from "@/shell/tab-registry";
import { renderTabContent } from "@/shell/tabUtils";
/**
* Draws a registered tab type inside another view, such as a host page, with
* the same props the tab stage gives it. The embed has its own instance id, so
* it never collides with the same tool opened as a normal tab.
*/
export function HostTabEmbed({
host,
type,
label,
data,
isVisible = true,
shell,
}: {
host: Host;
type: string;
label?: string;
data?: Record<string, unknown>;
isVisible?: boolean;
shell: TabShellCallbacks;
}) {
const tab = useMemo<Tab>(
() => ({
id: `embed-${type}-${host.id}`,
instanceId: `embed-${type}-${host.id}`,
type,
label: label ?? host.name ?? host.ip,
host,
openedAt: Date.now(),
data,
}),
// A new host or tool is a new tab; label and data changes keep it.
// eslint-disable-next-line react-hooks/exhaustive-deps
[type, host.id],
);
return (
<div className="h-full min-h-0 flex flex-col">
{renderTabContent(tab, { shell, isVisible, isFocusedPane: isVisible })}
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
import type { ComponentType } from "react";
import type { Host } from "@/types/ui-types";
import { byOrderThenId, createRegistry } from "@/lib/registry";
import type { TabShellCallbacks } from "@/shell/tab-registry";
export interface HostSectionRenderProps {
host: Host;
isVisible: boolean;
shell: TabShellCallbacks;
}
/**
* A card a plugin adds to a host page. The page itself belongs to a plugin;
* core only keeps the list so any plugin can fill it.
*/
export interface HostSectionDef {
id: string;
pluginId?: string;
titleKey: string;
order?: number;
when: (host: Host) => boolean;
component: ComponentType<HostSectionRenderProps>;
}
const sections = createRegistry<HostSectionDef>(byOrderThenId);
export const registerHostSection = sections.register;
export const useHostSections = sections.useList;
export const listHostSections = sections.list;
export const resetHostSections = sections.reset;
export function hostSectionsFor(
all: HostSectionDef[],
host: Host,
): HostSectionDef[] {
return all.filter((section) => {
try {
return section.when(host);
} catch {
return false;
}
});
}
@@ -80,6 +80,7 @@ import { OnboardingStage } from "@/onboarding/OnboardingStage";
import { UiPreferencesProvider } from "@/contexts/UiPreferencesContext"; import { UiPreferencesProvider } from "@/contexts/UiPreferencesContext";
import { setPermissionsForTesting } from "@/hooks/use-permissions"; import { setPermissionsForTesting } from "@/hooks/use-permissions";
import { onboardingSteps } from "@/onboarding/onboarding-registry"; import { onboardingSteps } from "@/onboarding/onboarding-registry";
import { registerSecondFactor } from "@/plugin-host/auth-registry";
import { import {
defaultUiPreferences, defaultUiPreferences,
type UiPreferences, type UiPreferences,
@@ -216,4 +217,34 @@ describe("OnboardingStage", () => {
); );
await waitFor(() => expect(onDone).toHaveBeenCalled()); await waitFor(() => expect(onDone).toHaveBeenCalled());
}); });
it("drops a step that stops applying after it joined the run", async () => {
const offFactor = registerSecondFactor({
id: "totp",
pluginId: "totp",
titleKey: "totp",
component: () => null,
enrollment: () => <p>enroll body</p>,
});
const offStep = onboardingSteps.register({
id: "x:factor",
pluginId: "x",
version: 1,
titleKey: "factor",
Component: () => <Plain label="factor" />,
audience: "all",
position: 50,
isRelevant: (ctx) => ctx.canEnrollSecondFactor === true,
});
renderStage("full");
await screen.findByText("preset body");
act(() => offFactor());
next();
await screen.findByText("picker body");
await act(async () => next());
await screen.findByText("appearance body");
next();
await screen.findByText("security body");
expect(screen.queryByText("factor body")).toBeNull();
offStep();
});
}); });
@@ -49,11 +49,11 @@ const LIST: OnboardingPluginList = {
}; };
describe("defaultChoices", () => { describe("defaultChoices", () => {
it("keeps recommended plugins and removes the rest on a fresh install", () => { it("keeps recommended plugins on and the rest off on a fresh install", () => {
expect(defaultChoices(LIST)).toEqual({ expect(defaultChoices(LIST)).toEqual({
term: "enabled", term: "enabled",
base: "remove", base: "disabled",
addon: "remove", addon: "disabled",
stats: "enabled", stats: "enabled",
}); });
}); });
@@ -139,8 +139,8 @@ describe("PluginsStep", () => {
resolved: {}, resolved: {},
adjustments: [], adjustments: [],
enabled: [], enabled: [],
disabled: [], disabled: ["base", "addon"],
removed: ["base", "addon"], removed: [],
failed: [], failed: [],
}); });
api.getOnboardingPlugins.mockResolvedValueOnce(LIST).mockResolvedValue({ api.getOnboardingPlugins.mockResolvedValueOnce(LIST).mockResolvedValue({
@@ -155,8 +155,8 @@ describe("PluginsStep", () => {
expect(ok).toBe(true); expect(ok).toBe(true);
expect(api.applyOnboardingPlugins).toHaveBeenCalledWith({ expect(api.applyOnboardingPlugins).toHaveBeenCalledWith({
term: "enabled", term: "enabled",
base: "remove", base: "disabled",
addon: "remove", addon: "disabled",
stats: "enabled", stats: "enabled",
}); });
expect(loader.syncPlugins).toHaveBeenCalled(); expect(loader.syncPlugins).toHaveBeenCalled();