* feat: optional auto reconnect for dropped ssh sessions (#1321)

* feat: tighter compact host rows with inline tags (#1325)

* feat: bring back the alert feed homepage widget

* feat: pick target terminals for snippets again

* feat: share button on connection tabs again

* feat: recent servers list when linking the desktop app

* feat: list the fixed app shortcuts in keyboard shortcuts

* fix: translate hardcoded toasts and drop em dashes from ui text

* fix: translate leftover hardcoded ui text

* fix: toolbar files button opens at the shell directory again

* fix: show the remote desktop note under connection origin again

* chore: openapi docs for host metrics manager routes

* chore: openapi docs for undocumented routes and fix two broken doc blocks

* fix: declare terminal.listSessions use for snippet targets

* revert: drop the alert feed widget, the notification center replaces it

* feat: restore the alert feed homepage widget

* fix: quick connect fills the host address from plugin auth editors like tailscale

* fix: docker container logs use the base background color

* chore: fix dependabot alerts and apply safe npm upgrades

* fix: proxmox stats tab reads its switch from plugin host settings

* fix: count plugin ssh logins toward host status and stop showing reachable as a warning

* fix: hide the built-in source pill in the host editor

* fix: host status shows online when the port answers and only warns on a failed login

* fix: drop the top border on the split layout menu

* fix: host defaults tabs sit where the host editor puts them and skip plugins with nothing to default

* fix: remote desktop auth method labels and notes for vnc and telnet

* fix: add description to the web endpoint enable switch

* fix: crowdin sync and pretranslate for plugin locales, run plugin tests on release

* feat: move telemetry out of core into a usage statistics plugin

* fix: recent activity card no longer cuts off host names early

* fix: remove duplicate top border on split screen menu

* fix: redesign session log player controls, fix tooltip overlap and drop dot separators

* fix: file manager and sftp text showing raw i18n keys

* fix: onboarding rerunning for existing users after upgrading

* fix: fleet detail buttons overflowing the sidebar

* fix: session log player scrolling off screen horizontally

* fix: network graph lag when adding hosts and on every render

* fix: maintenance index name, shared host read only view and badge polling

* fix: meeting tabs, workspace tab merging, rail panels as tabs and general ui lag

* chore: update release notes

* fix: remote desktop tabs reconnecting on every tab switch

* fix: build plugin sdk before linting in release workflow

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

* chore: skip crowdin pre-translate in release for now

* chore: switch to calver, bump version to 26.10.0

* fix(sso): send the PKCE verifier for GitHub login (#1534)

* fix(remote-desktop): clip cursor overflow without disabling zoom (#1535)

* test(remote-desktop): preserve sessions beyond one hour (#1536)

* fix(hosts): expose controls for overflowing editor tabs (#1537)

* fix(hosts): expose controls for overflowing editor tabs

* test(hosts): mock resize observation in admin panel tests

* fix(status): skip TCP probes while host sessions are active (#1538)

* fix(database): batch session activity persistence (#1539)

* docs(api): describe cookie and API key authentication (#1540)

* fix(snippets): repair missing note column on SQLite upgrades (#1541)

* fix(docker): retain stored SSH credential association (#1543)

* fix(file-manager): render transfer toasts without plugin hooks (#1546)

* feat(hosts): keep compact row actions inline (#1547)

* fix(auth): allow retrying unavailable second-factor interfaces (#1549)

* fix(auth): reject unresolved legacy identity provisioning (#1550)

* fix(desktop): ship the plugin SDK inside Mac App Store builds (#1551)

* fix(tmux): pass full session info to the terminal's session picker (#1552)

The tmux.sessions service reduced detected sessions to bare names, but the
picker reads session.name, so every entry rendered blank and selecting one
sent an empty name, which created a new session instead of attaching.

* fix(ssh): read force keyboard-interactive as a boolean when resolving hosts (#1553)

The column holds the text "true"/"false", and the resolver passed it
through as-is. The string "false" is truthy, so the password provider
treated every saved host as forced keyboard-interactive and left the
password out. Jump hops are built straight from the resolved host, so a
password hop whose server doesn't offer keyboard-interactive failed with
"All configured authentication methods failed".

* fix(database): batch database saves for bulk host writes (#1554)

* fix(database): yield to the event loop between database saves

Each SQLite save serializes and encrypts the whole database into new
buffers, which V8 only releases once the event loop turns. Boot
migrations and bulk host import write plugin settings per host and per
key in an awaited loop, so hundreds of full copies piled up and the
container ran out of memory.

* fix(database): save once per bulk host write instead of once per row

On SQLite every repository write force-saves the whole database. Boot
plugin data migrations, host defaults materialization and the bulk
host routes write one row per host per setting, so they serialized and
encrypted the full database hundreds of times in a row.

DatabaseSaveTrigger.batched wraps a function so force saves made while
it runs collapse into a single save when it finishes. Nested scopes fold
into the outer one, and work that outlives its scope saves normally.

* feat(hosts): add instance-wide predefined tag suggestions (#1548)

* feat(hosts): add shared predefined tag suggestions

* style(hosts): format tag catalog routes

* test(database): advance fake timers past the save yield (#1555)

* fix(i18n): complete Simplified Chinese core and plugin translations (#1542)

* fix(i18n): complete Chinese onboarding and navigation labels

* fix(i18n): translate remaining Chinese core and plugin interfaces

* fix(i18n): translate host editor tab overflow controls

* fix(i18n): use consistent Chinese fleet terminology

* fix(i18n): localize hardcoded controls and plugin views

* fix(i18n): translate built-in homepage widget catalog

* test(homepage): follow translated timezone placeholder

* fix(i18n): translate plugin-provided homepage widgets

* fix(i18n): localize feature settings section titles

* fix: 2.8 oidc accounts unable to sign in after upgrading (#1381)

* fix: plugins losing the saved ssh login when copying a host (#1391)

* fix: fleets, proxmox and automations not getting the host sudo password

* fix: host imports running a defaults pass and metrics restart per host (#1384)

* fix: high cpu from status probes and full database saves on every sample (#1300)

* fix: user data export freezing the server (#1393)

* fix: op:// secret references rejected as ssh keys on credentials (#1394)

* fix: slow file deletes from the trash lookups on every delete (#1390)

* fix: add openapi docs and error handling to host tag routes

* test: compare download stream buffers directly so it stops timing out

* chore: drop em dash from host row comment

* chore: update release notes for 2.9.1

* feat(ai): add remote coding agent sessions for SSH hosts (#1545)

* feat(ai): add host coding agent sessions with configured providers

* Wait for agent initialization and shutdown before resuming sessions

* Keep Pi transcripts outside its configuration migration directory

* Cover Pi session directory separation

* Update core permission catalog expectations for remote agents

* Install remote agent runtimes from verified official sources

* Wait for the identity panel credentials request in its test

* Add scoped SSH forwarding setup for remote agents

* Verify forwarding setup through a fresh SSH connection

* Bound the SSH forwarding verification timeout

* Return the forwarding verification callback result

* fix(ai): support agent send and interrupt keyboard shortcuts

* feat(ai): add agent queues attachments worktrees and session recovery

* fix(ai): handle deleted workspace paths and duplicate status events

* fix(ai): accept file references in root workspaces

* fix(ai): refresh paused queues after agent errors

* fix(ai): store agent sessions in plugin tables and lock down OpenCode

Agent sessions lived in a single ctx.kv value holding up to 2000 events,
which passed the 256 KB kv limit within minutes of a normal run. Every
save after that failed, so history, nativeId, queue and archive changes
were lost and every event wrote a kv audit row.

Sessions, events and queued prompts now have their own tables. A running
agent appends events in batches at most once a second with a lazy save;
session and queue changes save immediately. Prompts, drafts and stored
event text are capped in UTF-8 bytes to fit MySQL TEXT. kv:own is no
longer requested.

The OpenCode server now requires a per-session password, so other local
accounts on the host cannot drive the agent through its loopback port.

* test(upgrade): include agent session tables in the reviewed upgrade SQL

* test: run the posix-only ai agent tests on linux only

* fix: restore space to add host tags and redesign predefined tags editor

* fix: host key silently accepted when the host is missing from the database (#1397)

* fix: clearer host login failed status label and fix its translations (#1396)

* chore: add host key and status label fixes to release notes

* fix: sso and ldap provider dialogs overflowing the screen and using mismatched toggles

* chore: increment ver

* fix(audit): store plugin entries with no acting user as a null user_id (#1556)

Plugin audit entries written outside a request used the literal "system"
as user_id. That column references users.id, so the insert was refused
(Postgres logs it as an FK violation) and the entry was silently dropped.
Write null instead and keep "system" / plugin:<id> in username.

* chore: add sso/ldap dialog and audit log fixes to release notes

* fix: tunnels and host settings missing from shared hosts on desktop

* fix: remote desktop logins missing from shared hosts on desktop

* fix: simplify host status to online/offline and keep it live without a refresh

* chore: sync Crowdin translations for 2.9.1

* fix: build docker frontend/backend natively to stop arm64 hang, build sdk before openapi

* chore: rework plugin readmes, json changelogs and standalone repo setup

* chore: move plugins to their own repos and drop the plugins folder

* chore(deps-dev): bump globals (#1560)

Bumps the dev-minor-updates group with 1 update: [globals](https://github.com/sindresorhus/globals).


Updates `globals` from 17.12.0 to 17.13.0
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v17.12.0...v17.13.0)

---
updated-dependencies:
- dependency-name: globals
  dependency-version: 17.13.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* feat: drop the 2.8 columns, moves and aliases kept for a downgrade

* chore: remove the 2.9 finish list

* feat: port the demo redesign with Manage and Settings tabs

* chore: pin the plugin signing key and use TERMIX_PAT in workflows

* chore: rename the plugin sdk to @termix-ssh/plugin-sdk

* feat: add a publish workflow for the plugin sdk

* chore: cleanup

* fix: harden plugin sockets, public routes, prebuilt binaries and login fallback

* chore: add a 2.9.1 upgrade check for postgres and mysql

* fix: stop full database rewrites on every plugin db call

* fix: stop full database rewrites on every plugin db call

* fix: restore release video link in 2.9.2 notes

* fix: use TERMIX_PAT in workflows

* feat: pin the official plugins in the docker image

* feat: implement plugin management UI

* feat: shared list kit, settings search, rail order and ui consistency fixes

* chore: run all workflows on blacksmith runners

* chore: split dashboard quick actions into quarters and drop host status row lines

* fix: open plain settings from the profile button, navigation only from the rail menu

* feat: consent check, developer mode file installs, install counts and issue links for plugins

* feat: row detail options for hosts and credentials, open manage from sidebar header

* fix: unify connection failure screens and rebuild stale local plugins

* feat: dev:all runner for web, electron and docker with plugin hot reload, termix-plugin dev

* chore: clean up npm scripts, make npm run dev the full runner and drop concurrently

* fix: settle quick connect panel loads in tests so they stop flaking

* fix: stop the dev runner rebuilding plugins when windows reports file reads as changes

* chore: update the plugin sdk ui export snapshot

* feat: full screen onboarding with a plugin picker and per step versions

* feat: changelog.md release notes, v tags, plugin release notes and videos

* chore: add temp changelog

* chore: release the plugin sdk as 1.0.1

* feat: show only the latest plugin release notes and cap long ones

* chore: publish docker images to termix-ssh on ghcr and termixssh on docker hub

* chore: rename docker hub token secret to DOCKERHUB_BUGATTIGUY527_TOKEN

* chore: renew mac app store provisioning profile

* chore: drop docs job from release workflow

* chore: run sdk publish on github hosted runner

* chore: remove dependabot

* chore: general file cleanup

* chore: rewrite readme and taglines for plugin-based termix

* chore: drop optional from ai plugin row in readme

* feat: per-repo issue forms and prefilled bug reports instead of the support repo

* feat: add report a bug link to the your account settings section

* fix: open settings goes to the plugin's page and hides when it has none

* feat: show plugin features from the manifest on the plugin page

* fix: retry failed or blocked plugins on restart instead of leaving them disabled

* fix: refresh plugin settings after enabling a plugin and match select and disabled bar styling

* feat: request a feature link for plugins and use the bug form for unverified plugins

* feat: beta channels for core and plugins with one stable/beta release workflow

* fix(sync): resolve shared hosts for connect-only recipients (#1568)

* fix(ssh): accept full passwords in keyboard-interactive prompts (#1569)

* fix(ssh): normalize PKCS8 private keys before connecting (#1570)

* fix(sidebar): reclaim space from hidden folder actions (#1571)

* fix(hosts): display protocol credential usernames for non-SSH hosts (#1572)

* fix: ask for a push/TOTP menu choice as text instead of a push confirm

* fix: keep client tunnels open when one of their connections fails (Support#1417)

* fix: send plugin form uploads to the plugin's route (Support#1406, Support#1420)

* fix: add missing sync ids to old snippet tables so snippets sync again (Support#1419)

* fix: start the macOS local terminal when the shell or spawn helper is broken (Support#1418)

* fix: refresh cached plugin translations when only a translation changes (Support#1411)

* fix: import sshpk as a default so the backend starts under ESM

* feat: add a plugin sdk changelog and tag sdk releases, release 1.0.2

* fix: drop the ./ from the plugin sdk bin path so npm stops warning

* chore: name core and sdk releases Termix vX and Plugin SDK vX

* fix: lock the desktop backend to the app's own origin (GHSA-frx8-qqgg-7j7g)

* fix: trust devices by a server-issued token, not a client header (GHSA-6gr3-r7jx-wfmh)

* fix: keep global settings out of non-admin data exports (GHSA-v3vp-ffww-q7pw)

* fix: refuse sessionless tokens and keep the secrets file owner only

* feat: add TRUSTED_PROXIES to choose who nginx takes client IPs from (GHSA-7j7m-85p4-2cpm)

* chore: standardize repo setup files

* chore: remove unused knip config

* chore: add LICENSE and use Node 24 from .nvmrc

* chore: update translation guidance, only en.json needs editing

* feat: plugin docs and env manifest fields, in-app docs links, SDK 1.0.3

* fix: header separators render as borders and center the status divider

* fix: lighter confirm prompt overlay to stop lag when it opens

* fix: put plugin-only tailwind classes in core's utilities layer, SDK 1.0.4

* fix: dev stages local plugin builds on paths with spaces and builds them with the local SDK

* fix: keep plugin tailwind variants in core's layer so overrides keep their order

* fix: manage tab refetches before giving up on a host or credential it cannot find

* fix: tab buttons like share show as soon as the terminal is ready

* fix: folder rows no longer nest action buttons inside a button

* fix: repin remote-desktop after its 1.0.0 re-release

* chore: repin bundled plugins after the 1.0.0 re-release

* fix: recent activity rows use the same separator as host status

* feat: upload banner

* fix: reject expired sessions on sockets, api key desktop links and 192.0.0.0/24

* fix: plugin sdk 1.0.5 with optional ui peers, safer timeouts and validate checks

* fix: bundled plugins path for source runs and backend cleanup

* fix: load plugin strings on guest pages, plugin wording and drop unused keys

* fix: beta version stamping, homebrew cask bump, dockerignore and readme platforms

* docs: 26.10.0 changelog

* fix: current version in the version route example

* chore: stop pushing the release- docker tag

* feat: remove the donation reminder and its column

* docs: note the removed donation reminder

* fix: link server wizard always opens inline, onboarding card grows for it

* feat: plugin text columns are longtext on mysql so they are not capped at 64KB

* fix: linked desktops keep a plugin when its update from the server fails

* chore: default mobile download links to v tags

* fix: sso only accounts cannot sign in with a passkey or other local method

* feat: note in the plugins tab that community plugins are coming

* fix: plugin validate accepts beta versions

* chore: pin bundled plugins rebuilt on sdk 1.0.5

* fix: skip locale fetches for disabled plugins

* fix: show translated names for the built-in admin and user roles

* fix: show host keys as SHA256 fingerprints like ssh-keygen

* fix: host editor scrolls to a newly enabled protocol section

* fix: pass the host to dashboard host row plugins

* fix: plugin cards show dividers between author, version and category

* fix: refuse plugin archives with paths outside the plugin folder

* fix: theme picker shows one dropdown arrow

* fix: read activity and audit times as UTC so they show in local time

* fix: show the real error for a wrong password or code on plugin sign in

* fix: explain that external sign in needs Auto-create external accounts

* fix: clear leftover plugin unpack folders at startup

* fix: shared session links open for guests again

* fix: share panels no longer list yourself as a share target

* fix: a host update without an auth type keeps the saved password

* fix: guest pages get the tab ids a guest plugin registers

* fix: refuse a 2.8 database, which already had a plugins table

* fix: dashboard cards fit on a phone

* fix: desktop setup no longer asks to secure the local account

* fix: the desktop app keeps the desktop layout in a narrow window

* fix: the desktop app shows the rail and no phone bar in a narrow window

* fix: name every sync type in the Sync panel and pluralize the link prompt

* chore: update the sdk ui snapshot for the host key dialog

* fix: turning a plugin back on restarts the plugins it turned off

* fix: quick connect asks to trust the host key

* fix: revoking a manager also removes the shares they handed out

* fix: database export works for users with TOTP on (#1426)

* fix: rdp and vnc logins use the folder credential when none is picked (#1429)

* fix: imported hosts get status checks without a restart

* fix: re-pin bundled plugins after 1.0.0 re-release

* fix: re-pin bundled plugins after search bar fix

* chore: update app icon to smaller version

* chore: round the readme logo, keep square source for app icons

* feat: grid columns and table view in the plugins tab

* fix: match the host key copy button to the fingerprint height

* fix: use brand color for toggles in sync settings

* fix: re-pin bundled plugins after 1.0.0 re-release

* fix: web terminals failing auth behind a proxy that rewrites Host (Support#651)

* fix: re-pin snippets and termix-identity after 1.0.0 overwrite

* fix: re-pin file-manager after 1.0.0 overwrite

* fix: re-pin file-manager after 1.0.0 overwrite

* chore: remove Crowdin from the release workflow

* chore: remove crowdin config

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-10 14:58:27 -05:00
1 parent a075ff4808
commit 0149260926
3656 files changed
+116210 -460999

No files matched your search

+67
View File
@@ -0,0 +1,67 @@
import zlib from "node:zlib";
const MAX_UNPACKED_BYTES = 512 * 1024 * 1024;
/**
* Reads one file out of a .tmxplug (a gzipped tar) without unpacking it to
* disk. Returns null when the archive has no such file.
*/
export function readArtifactFile(
buffer: Buffer,
wanted: string,
): Buffer | null {
const tar = zlib.gunzipSync(buffer, { maxOutputLength: MAX_UNPACKED_BYTES });
let offset = 0;
while (offset + 512 <= tar.length) {
const header = tar.subarray(offset, offset + 512);
if (header.every((byte) => byte === 0)) break;
const field = (start: number, length: number) =>
header
.subarray(start, start + length)
.toString("utf8")
.replace(/\0.*$/s, "");
const prefix = field(345, 155);
const name = (
prefix ? `${prefix}/${field(0, 100)}` : field(0, 100)
).replace(/^\.\//, "");
const size = Number.parseInt(field(124, 12).trim() || "0", 8);
if (!Number.isFinite(size) || size < 0) break;
const start = offset + 512;
if (name === wanted) return tar.subarray(start, start + size);
offset = start + Math.ceil(size / 512) * 512;
}
return null;
}
/** The manifest.json packed in a .tmxplug, parsed but not validated. */
export function readArtifactManifest(buffer: Buffer): Record<string, unknown> {
let raw: Buffer | null;
try {
raw = readArtifactFile(buffer, "manifest.json");
} catch {
throw new Error("the file is not a valid .tmxplug archive");
}
if (!raw) throw new Error("the archive has no manifest.json");
let parsed: unknown;
try {
parsed = JSON.parse(raw.toString("utf8"));
} catch {
throw new Error("manifest.json is not valid JSON");
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("manifest.json is not an object");
}
return parsed as Record<string, unknown>;
}
/** Whether two capability lists name the same set, order ignored. */
export function sameCapabilities(
a: readonly string[],
b: readonly string[],
): boolean {
const left = new Set(a);
const right = new Set(b);
if (left.size !== right.size) return false;
for (const capability of left) if (!right.has(capability)) return false;
return true;
}
+18 -6
View File
@@ -15,7 +15,7 @@ import {
DEFAULT_FRONTEND_ENTRY,
DEFAULT_LOCALES_DIR,
type PluginManifest,
} from "@termix/plugin-sdk/manifest";
} from "@termix-ssh/plugin-sdk/manifest";
import type { LoadedPlugin } from "./loader.js";
type AssetPlugin = Pick<LoadedPlugin, "id" | "dir" | "manifest"> &
@@ -76,13 +76,18 @@ export function describePluginFrontend(
if (!plugin) return none;
const locales: string[] = [];
const localeFiles: string[] = [];
const dir = localesDir(plugin);
if (dir && fileExists(path.join(dir, "en.json"))) locales.push("en");
if (dir && fileExists(path.join(dir, "en.json"))) {
locales.push("en");
localeFiles.push(path.join(dir, "en.json"));
}
if (dir) {
try {
for (const name of fs.readdirSync(path.join(dir, "translated"))) {
if (/^[a-z]{2}_[A-Z]{2}\.json$/.test(name)) {
locales.push(name.slice(0, -".json".length));
localeFiles.push(path.join(dir, "translated", name));
}
}
} catch {
@@ -95,8 +100,9 @@ export function describePluginFrontend(
const cssFile = path.join(entry.dir, "frontend.css");
const css = fileExists(cssFile);
// Locales share the version, so a translations-only update must change it.
const assetVersion = contentVersion(
css ? [entry.file, cssFile] : [entry.file],
[...(css ? [entry.file, cssFile] : [entry.file]), ...localeFiles.sort()],
plugin.manifest.version,
);
@@ -113,8 +119,8 @@ const versionCache = new Map<string, { stamp: string; version: string }>();
/**
* A hash of the bundle's bytes, not its mtime: assets are cached as immutable
* for a year, and a rebuilt .tmxplug has a fixed mtime, so a same-size rebuild
* would otherwise keep serving the stale copy. The CSS is hashed with the JS
* because both are served under the same version.
* would otherwise keep serving the stale copy. The CSS and locales are hashed
* with the JS because all of them are served under the same version.
*/
function contentVersion(files: string[], version: string): string {
// ctime, unlike mtime, cannot be set by tar, so an unpack always changes it.
@@ -276,7 +282,13 @@ export function createPluginAssetsRouter(
: "no-cache",
);
res.type(CONTENT_TYPES[path.extname(file)] ?? "application/octet-stream");
res.sendFile(file);
// Installed plugins unpack under .unpacked/, which send would refuse as a
// dotfile path. resolvePluginAsset already kept the file in the plugin.
res.sendFile(file, { dotfiles: "allow" }, (error) => {
if (error && !res.headersSent) {
res.status(404).json({ error: "Not found" });
}
});
},
);
+63
View File
@@ -0,0 +1,63 @@
/**
* The registry timer: refreshes the store's install counts and applies
* plugin updates for plugins with auto-update on. An update that asks for a
* new capability is never applied here; admins get an alert and review it in
* the Plugins tab. Nothing is ever installed from here.
*/
import { pluginLogger } from "../utils/logger.js";
const FIRST_CHECK_MS = 2 * 60_000;
const CHECK_EVERY_MS = 6 * 60 * 60_000;
let timer: NodeJS.Timeout | null = null;
export async function runPluginAutoUpdate(): Promise<void> {
const { syncInstallCounts } = await import("./install-counts.js");
await syncInstallCounts();
const { isLinkedDesktop, updateAllPlugins } = await import("./manage.js");
if (await isLinkedDesktop()) return;
const result = await updateAllPlugins({ userId: null, onlyAutoUpdate: true });
for (const done of result.updated) {
pluginLogger.info(`Auto-updated plugin ${done.id} to ${done.version}`, {
operation: "plugin_auto_update",
});
}
for (const failed of result.failed) {
pluginLogger.warn(`Auto-update of ${failed.id} failed: ${failed.error}`, {
operation: "plugin_auto_update",
});
}
if (result.needsReview.length === 0) return;
const { sendCoreAlert } = await import("../notify/core-notify.js");
for (const entry of result.needsReview) {
await sendCoreAlert({
title: `A plugin update needs review`,
body: `The new version of ${entry.id} asks for more access, so it was not installed on its own. Review it in Plugins.`,
severity: "info",
category: "termix.plugin-update",
dedupeKey: `plugin-review:${entry.id}:${entry.capabilities.join(",")}`,
audience: "admins",
});
}
}
export function startPluginAutoUpdate(): void {
if (timer) return;
const run = () =>
void runPluginAutoUpdate().catch((error) => {
pluginLogger.warn(
`Plugin auto-update check failed: ${
error instanceof Error ? error.message : String(error)
}`,
{ operation: "plugin_auto_update" },
);
});
setTimeout(run, FIRST_CHECK_MS).unref?.();
timer = setInterval(run, CHECK_EVERY_MS);
timer.unref?.();
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Plugins to start at boot. One that failed or was blocked last run is still
* one the admin turned on, so a restart tries it again rather than leaving it
* looking disabled.
*/
export function startablePluginIds(
records: { id: string; state: string }[],
): Set<string> {
return new Set(
records
.filter((record) =>
["enabled", "failed", "blocked"].includes(record.state),
)
.map((record) => record.id),
);
}
+101
View File
@@ -0,0 +1,101 @@
/**
* The onboarding defaults of the bundled plugins, from
* dist/plugins/bundled-index.json (written by scripts/build-plugins.cjs from
* docker/bundled-plugins.json). Core never names plugins, so which ones a
* fresh install keeps lives in that file and not here.
*/
import fs from "node:fs";
import path from "node:path";
import { getBundledPluginsDir } from "./paths.js";
export const BUNDLED_INDEX_FILE = "bundled-index.json";
export interface OnboardingDefaults {
recommended: boolean;
consent: boolean;
}
type Recommended = boolean | "desktop";
interface BundledIndex {
plugins: Record<
string,
{
onboarding?: {
recommended?: Recommended;
consent?: boolean;
enabledByEnv?: string[];
};
}
>;
}
let cached: BundledIndex | null | undefined;
export function readBundledIndex(): BundledIndex | null {
if (cached !== undefined) return cached;
try {
const raw = JSON.parse(
fs.readFileSync(
path.join(getBundledPluginsDir(), BUNDLED_INDEX_FILE),
"utf8",
),
);
cached =
raw && typeof raw.plugins === "object" && raw.plugins
? { plugins: raw.plugins }
: null;
} catch {
cached = null;
}
return cached;
}
export function resetBundledIndexCache(): void {
cached = undefined;
}
function isDesktop(): boolean {
return process.env.ELECTRON_EMBEDDED === "true";
}
/**
* Without an index (a build that skipped build-plugins) every plugin counts
* as recommended, which is how installs behaved before the picker. A plugin
* whose enabledByEnv variables are set counts as recommended too, so a server
* set up for it through the environment works from the first boot.
*/
export function onboardingDefaults(pluginId: string): OnboardingDefaults {
const index = readBundledIndex();
if (!index) return { recommended: true, consent: false };
const entry = index.plugins[pluginId]?.onboarding;
const recommended = entry?.recommended;
const configured = (entry?.enabledByEnv ?? []).some(
(name) => typeof name === "string" && !!process.env[name]?.trim(),
);
return {
recommended:
recommended === true ||
(recommended === "desktop" && isDesktop()) ||
configured,
consent: entry?.consent === true,
};
}
/**
* The state a newly seen plugin's row starts in. Bundled plugins start
* enabled, except on a fresh install, where only recommended ones do and
* consent plugins wait for the admin. A plugin dropped into the data
* directory always starts disabled.
*/
export function initialPluginState(
pluginId: string,
source: "bundled" | "user",
freshInstall: boolean,
): "enabled" | "disabled" {
if (source !== "bundled") return "disabled";
if (!freshInstall) return "enabled";
const { recommended, consent } = onboardingDefaults(pluginId);
return recommended && !consent ? "enabled" : "disabled";
}
+2 -2
View File
@@ -23,9 +23,9 @@
import type {
PluginCredentials,
PluginSshKeyCredential,
} from "@termix/plugin-sdk/backend";
} from "@termix-ssh/plugin-sdk/backend";
import ssh2 from "ssh2";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
import { assertCapability, capabilityRefused } from "./permissions.js";
import { getActor } from "./actor.js";
import type { DisposableBag } from "./disposables.js";
+3 -3
View File
@@ -20,9 +20,9 @@ import type {
PluginShareableUser,
PluginShareableRole,
PluginHostJumpHost,
} from "@termix/plugin-sdk/backend";
import { PLUGIN_HOST_INPUT_KEYS } from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
} from "@termix-ssh/plugin-sdk/backend";
import { PLUGIN_HOST_INPUT_KEYS } from "@termix-ssh/plugin-sdk/backend";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
import { assertCapability, capabilityRefused } from "./permissions.js";
import { hostSessionStatus } from "../hosts/host-session-status.js";
import { getActor } from "./actor.js";
+2 -2
View File
@@ -10,8 +10,8 @@ import type {
PluginNotificationAudience,
PluginNotify,
PluginNotifyResult,
} from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
} from "@termix-ssh/plugin-sdk/backend";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
import { assertCapability, capabilityRefused } from "./permissions.js";
import { getActor } from "./actor.js";
import { activeNotifyHub, registerNotifyHub } from "./notify-hub.js";
+2 -2
View File
@@ -3,8 +3,8 @@
* plugins:read. Ids and versions only, nothing operational.
*/
import type { PluginPlugins } from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import type { PluginPlugins } from "@termix-ssh/plugin-sdk/backend";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
import { assertCapability } from "./permissions.js";
export function createPluginPlugins(manifest: PluginManifest): PluginPlugins {
+27 -2
View File
@@ -16,8 +16,8 @@ import type {
PluginBinarySpec,
PluginProcess,
PluginProcessHandle,
} from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
} from "@termix-ssh/plugin-sdk/backend";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
import { assertCapability } from "./permissions.js";
import { getPluginDataDir } from "./paths.js";
import type { DisposableBag } from "./disposables.js";
@@ -41,6 +41,30 @@ interface Deps {
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
const SAFE_NAME = /^[A-Za-z0-9._-]+$/;
/** Larger than any CLI a plugin ships, small enough not to stall a boot. */
const MAX_PREBUILT_BYTES = 512 * 1024 * 1024;
/**
* Only a regular file carrying the binary's own name is hashed, so a plugin
* cannot use prebuilt to fingerprint arbitrary files on the server.
*/
async function isPrebuiltCandidate(
candidate: string,
name: string,
): Promise<boolean> {
if (!path.isAbsolute(candidate) || path.basename(candidate) !== name) {
return false;
}
try {
const real = await fs.realpath(candidate);
if (path.basename(real) !== name) return false;
const stat = await fs.stat(real);
return stat.isFile() && stat.size <= MAX_PREBUILT_BYTES;
} catch {
return false;
}
}
async function sha256Of(file: string): Promise<string | null> {
try {
const hash = createHash("sha256");
@@ -217,6 +241,7 @@ export function createPluginProcess(deps: Deps): PluginProcess {
}
for (const candidate of spec.prebuilt ?? []) {
if (!(await isPrebuiltCandidate(candidate, spec.name))) continue;
if ((await sha256Of(candidate)) === expected) return candidate;
}
+11 -10
View File
@@ -12,9 +12,9 @@ import type {
PluginSsh,
PluginSshConnectOptions,
PluginSshHost,
} from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import { PluginSshInteractionError } from "@termix/plugin-sdk/backend";
} from "@termix-ssh/plugin-sdk/backend";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
import { PluginSshInteractionError } from "@termix-ssh/plugin-sdk/backend";
import {
assertCapability,
capabilityRefused,
@@ -601,12 +601,13 @@ export function toCoreIdentity(
returnTo: identity.returnTo,
rememberMe: identity.rememberMe,
legacyIdentifier: identity.legacy?.identifier,
ssoProviderId:
identity.logoutClaims?.providerId ??
identity.legacy?.providerRowId ??
null,
oidcSub: identity.logoutClaims?.sub ?? null,
oidcSid: identity.logoutClaims?.sid ?? null,
externalSession: identity.logoutClaims
? {
providerId: identity.logoutClaims.providerId ?? null,
sub: identity.logoutClaims.sub ?? null,
sid: identity.logoutClaims.sid ?? null,
}
: null,
roleSync: identity.roles
? {
desired: [...identity.roles.desired],
@@ -864,7 +865,7 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
const { AuthManager } = await import("../utils/auth-manager.js");
const revoked =
await AuthManager.getInstance().revokeSessionsByExternalSession({
ssoProviderId: match.providerId ?? null,
providerId: match.providerId ?? null,
sub: match.sub ?? null,
sid: match.sid ?? null,
});
+2 -2
View File
@@ -4,8 +4,8 @@
* say that it renews it. Every call is audited.
*/
import type { PluginSystem } from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import type { PluginSystem } from "@termix-ssh/plugin-sdk/backend";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
import { assertCapability } from "./permissions.js";
import type { DisposableBag } from "./disposables.js";
+3 -3
View File
@@ -42,14 +42,14 @@ import {
import { registerPluginWsRoute, registerPluginWsUpgrade } from "./ws.js";
import { resolvePermission } from "./rbac.js";
import * as pluginSettings from "./settings.js";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
import {
type PluginContext,
type PluginModule,
type PluginOpenIsolatedWindowRequest,
type PluginExternalClientRequest,
} from "@termix/plugin-sdk/backend";
import type { PluginTableDefinition } from "@termix/plugin-sdk/db";
} from "@termix-ssh/plugin-sdk/backend";
import type { PluginTableDefinition } from "@termix-ssh/plugin-sdk/db";
import * as syncRegistry from "./sync-registry.js";
import {
needsExplicitPersist,
+30 -2
View File
@@ -11,8 +11,8 @@ import { sql } from "drizzle-orm";
import { pluginLogger } from "../utils/logger.js";
import { resolveDatabaseDialect } from "../database/db/dialect.js";
import type { DatabaseDialect } from "../database/db/dialect.js";
import type { PluginTableDefinition } from "@termix/plugin-sdk/db";
import { prefixedTableName, tablePrefix } from "@termix/plugin-sdk/db";
import type { PluginTableDefinition } from "@termix-ssh/plugin-sdk/db";
import { prefixedTableName, tablePrefix } from "@termix-ssh/plugin-sdk/db";
import { buildTable } from "./table-builder.js";
import {
applyPluginMigrations,
@@ -263,6 +263,34 @@ export async function removePluginData(
return { tables: dropped, kvKeys, migrations };
}
/** The plugin's tables in the live database, with how many rows each holds. */
export async function describePluginTables(
pluginId: string,
options: { knownPluginIds?: string[]; dialect?: DatabaseDialect } = {},
): Promise<Array<{ name: string; rows: number | null }>> {
const dialect = options.dialect ?? resolveDatabaseDialect();
const tables = ownedTableNames(
pluginId,
await listDatabaseTables(dialect),
options.knownPluginIds ?? [],
).sort();
const { selectRows } = await import("../utils/crypto-migration/raw-rows.js");
const result: Array<{ name: string; rows: number | null }> = [];
for (const table of tables) {
const quoted = dialect === "mysql" ? "`" + table + "`" : `"${table}"`;
try {
const [row] = await selectRows<Record<string, unknown>>(
sql.raw(`SELECT COUNT(*) AS count FROM ${quoted}`),
);
result.push({ name: table, rows: Number(row?.count ?? row?.COUNT ?? 0) });
} catch {
result.push({ name: table, rows: null });
}
}
return result;
}
/**
* The tables in `all` that belong to this plugin: its prefix, minus any that
* sit under a longer prefix another plugin owns.
+66
View File
@@ -0,0 +1,66 @@
/**
* IPC between the backend and scripts/dev.mjs, which forks it with
* TERMIX_DEV_RELOAD=true. The runner asks for a plugin to be loaded again
* after it rebuilt and copied it into the bundled folder.
*/
import { pluginLogger } from "../utils/logger.js";
interface ReloadRequest {
type: "plugin-reload";
id: string;
}
type Send = (message: unknown) => void;
type Reload = (id: string) => Promise<{ version: string; state: string }>;
function isReloadRequest(msg: unknown): msg is ReloadRequest {
return (
!!msg &&
typeof msg === "object" &&
(msg as { type?: unknown }).type === "plugin-reload" &&
typeof (msg as { id?: unknown }).id === "string"
);
}
export function isDevReloadEnabled(): boolean {
return (
process.env.TERMIX_DEV_RELOAD === "true" &&
typeof process.send === "function"
);
}
export async function handleDevMessage(
msg: unknown,
send: Send,
reload: Reload,
): Promise<void> {
if (!isReloadRequest(msg)) return;
try {
const result = await reload(msg.id);
pluginLogger.info(`Reloaded ${msg.id}@${result.version}`, {
operation: "plugin_dev_reload",
});
send({ type: "plugin-reloaded", id: msg.id, state: result.state });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
pluginLogger.error(`Could not reload ${msg.id}`, error, {
operation: "plugin_dev_reload",
});
send({ type: "plugin-reload-failed", id: msg.id, error: message });
}
}
export function listenForDevReloads(): void {
if (!isDevReloadEnabled()) return;
process.on("message", (msg: unknown) => {
void handleDevMessage(
msg,
(reply) => process.send?.(reply),
async (id) => {
const { reloadBundledPlugin } = await import("./manage.js");
return reloadBundledPlugin(id);
},
);
});
}
+42
View File
@@ -0,0 +1,42 @@
import fs from "node:fs";
// Windows refuses a rename while anything holds a file open, and antivirus
// or the search indexer often does for a moment right after files are
// written. These are worth waiting out.
const TRANSIENT = new Set(["EPERM", "EACCES", "EBUSY"]);
const DELAYS_MS = [50, 100, 200, 400, 800, 1600];
function isTransient(error: unknown): boolean {
return TRANSIENT.has((error as NodeJS.ErrnoException)?.code ?? "");
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* Renames, retrying while the error looks like a passing lock. A directory
* that still cannot be renamed is copied and the source removed instead.
*/
export async function moveWithRetry(
from: string,
to: string,
delays: readonly number[] = DELAYS_MS,
): Promise<void> {
let lastError: unknown;
for (let attempt = 0; attempt <= delays.length; attempt++) {
try {
await fs.promises.rename(from, to);
return;
} catch (error) {
if (!isTransient(error)) throw error;
lastError = error;
if (attempt < delays.length) await sleep(delays[attempt]);
}
}
const stat = await fs.promises.stat(from).catch(() => null);
if (!stat) throw lastError;
await fs.promises.cp(from, to, { recursive: true, force: true });
await fs.promises
.rm(from, { recursive: true, force: true, maxRetries: 5 })
.catch(() => {});
}
+78 -11
View File
@@ -24,8 +24,8 @@ import express, {
import type {
PluginMiddleware,
PluginRouterOptions,
} from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
} from "@termix-ssh/plugin-sdk/backend";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
import { pluginLogger } from "../utils/logger.js";
import { AuthManager } from "../utils/auth-manager.js";
import { runAsActor } from "./actor.js";
@@ -162,17 +162,81 @@ function compilePublicPath(path: string): RegExp {
return new RegExp(prefix ? `^${escaped}(?:/.*)?$` : `^${escaped}$`);
}
interface PublicPath {
declared: string;
pattern: RegExp;
}
interface RouterLayer {
route?: {
path?: unknown;
_handlesMethod?: (method: string) => boolean;
};
match?: (path: string) => boolean;
}
function segments(path: string): string[] {
return normalizePublicPath(path).split("/").slice(1);
}
/**
* Matches the request path against the declared public paths.
*
* Full-path match only. A prefix match would turn one declared route into a
* hole covering everything below it, which is not what declaring a single
* public path should mean.
* Whether a declared public path names this registered route: literal
* segments must be equal and a ":param" must sit where the route has one, so
* "/webhook/:token" never covers a sibling "/webhook/config".
*/
function isPublicRequest(req: Request, publicPaths: RegExp[]): boolean {
function declaredCoversRoute(declared: string, routePath: string): boolean {
const prefix = declared.endsWith("/*");
const want = segments(prefix ? declared.slice(0, -2) : declared);
const have = segments(routePath);
if (prefix ? have.length < want.length : have.length !== want.length) {
return false;
}
return want.every((segment, index) =>
segment.startsWith(":")
? have[index].startsWith(":")
: segment.toLowerCase() === have[index].toLowerCase(),
);
}
/**
* Whether the request is for a declared public path.
*
* Full-path match only, and only for the route that will actually answer:
* the first route in the plugin's router that matches the path and method
* has to be the one the declaration names. So a public "/webhook/:token"
* registered for POST does not open GET, and does not open a literal sibling
* route registered next to it. A trailing "/*" may also be served by
* router.use(), which has no route to compare against.
*/
function isPublicRequest(
req: Request,
publicPaths: PublicPath[],
inner: Router,
): boolean {
if (publicPaths.length === 0) return false;
const path = normalizePublicPath(req.path || "/");
return publicPaths.some((pattern) => pattern.test(path));
const candidates = publicPaths.filter(({ pattern }) => pattern.test(path));
if (candidates.length === 0) return false;
const stack = (inner as unknown as { stack?: RouterLayer[] }).stack ?? [];
for (const layer of stack) {
if (!layer.match?.(req.path || "/")) continue;
if (!layer.route) {
if (candidates.some(({ declared }) => declared.endsWith("/*"))) {
return true;
}
continue;
}
if (!layer.route._handlesMethod?.(req.method)) continue;
const routePath = layer.route.path;
return (
typeof routePath === "string" &&
candidates.some(({ declared }) =>
declaredCoversRoute(declared, routePath),
)
);
}
return false;
}
export interface CreatePluginRouterArgs {
@@ -196,7 +260,10 @@ export function createPluginRouter({
const declaredPublic = (options?.public ?? []).map((path) =>
normalizePublicPath(path),
);
const publicPaths = declaredPublic.map((path) => compilePublicPath(path));
const publicPaths = declaredPublic.map((declared) => ({
declared,
pattern: compilePublicPath(declared),
}));
publicRoutes.set(pluginId, declaredPublic);
if (declaredPublic.length > 0) {
@@ -250,7 +317,7 @@ export function createPluginRouter({
const authenticate = AuthManager.getInstance().createAuthMiddleware();
outer.use((req: Request, res: Response, next: NextFunction) => {
if (isPublicRequest(req, publicPaths)) {
if (isPublicRequest(req, publicPaths, inner)) {
const ip = req.ip || req.socket?.remoteAddress || "unknown";
if (overPublicRateLimit(pluginId, ip)) {
res.status(429).json({ error: "Too many requests" });
+58 -18
View File
@@ -22,6 +22,7 @@ import { setSshAuthTypeOwnerSource } from "../hosts/connect/auth-provider-regist
import { setHostProtocolSource } from "../hosts/protocol-auth/registry.js";
import { setSecretResolverOwnerSource } from "../hosts/connect/secret-resolver-registry.js";
import { recordConflict } from "./conflicts.js";
import { startablePluginIds } from "./boot-state.js";
import { setPluginImpersonationCheck } from "../utils/auth-manager.js";
import { setKeybindingActionSource } from "../database/routes/keybinding-validation.js";
@@ -94,9 +95,9 @@ export function getPluginRuntime(): { loader: PluginLoader } {
}
/**
* A plugin that tripped the error budget is off until an admin retries it, so
* it has to look off everywhere: no routes, greyed permissions, and a failed
* row that survives a restart.
* A plugin that tripped the error budget is off until an admin retries it or
* the server restarts, so it has to look off everywhere: no routes and greyed
* permissions.
*/
async function handlePluginFailed(plugin: LoadedPlugin): Promise<void> {
unregisterPluginHttp(plugin.id);
@@ -126,11 +127,21 @@ async function handlePluginFailed(plugin: LoadedPlugin): Promise<void> {
* `state` is only ever set on insert. Once the row exists whatever the user
* chose wins, because re-enabling a plugin they disabled on every restart
* would be a bug rather than a default.
*
* A fresh install (no users, no plugin rows) is different: only the bundled
* plugins marked recommended start enabled, and the first admin picks the
* rest in onboarding. Consent plugins wait for that choice too, even when
* the picker checks them by default.
*/
async function seedPlugins(loaded: LoadedPlugin[]): Promise<void> {
async function seedPlugins(
loaded: LoadedPlugin[],
freshInstall: boolean,
): Promise<boolean> {
const { createCurrentPluginRepository } =
await import("../database/repositories/factory.js");
const repository = createCurrentPluginRepository();
const fresh = freshInstall && (await repository.listAll()).length === 0;
const { initialPluginState } = await import("./bundled-index.js");
for (const plugin of loaded) {
const manifestJson = JSON.stringify(plugin.manifest);
@@ -155,7 +166,7 @@ async function seedPlugins(loaded: LoadedPlugin[]): Promise<void> {
version: plugin.manifest.version,
tier: plugin.source === "bundled" ? "bundled" : "community",
source: plugin.source,
state: plugin.source === "bundled" ? "enabled" : "disabled",
state: initialPluginState(plugin.id, plugin.source, fresh),
manifestJson,
});
@@ -163,6 +174,8 @@ async function seedPlugins(loaded: LoadedPlugin[]): Promise<void> {
operation: "plugin_seed",
});
}
return fresh;
}
/**
@@ -212,23 +225,28 @@ async function syncCapabilityGrants(loaded: LoadedPlugin[]): Promise<void> {
* Loads every plugin on disk and activates the ones marked enabled, in
* dependency order. Called once from the backend start-up sequence.
*/
export async function initializePlugins(): Promise<LoadedPlugin[]> {
export async function initializePlugins(
options: { freshInstall?: boolean } = {},
): Promise<LoadedPlugin[]> {
const { loader: pluginLoader } = getPluginRuntime();
const { applyStoredPluginChoices } = await import("./manage.js");
await applyStoredPluginChoices(pluginLoader);
const loaded = await pluginLoader.loadAll();
if (loaded.length === 0) return [];
await seedPlugins(loaded);
const fresh = await seedPlugins(loaded, options.freshInstall === true);
await syncCapabilityGrants(loaded);
if (loaded.some((plugin) => plugin.source === "bundled")) {
const { offerPluginSetup } = await import("./onboarding.js");
await offerPluginSetup(fresh);
}
const { createCurrentPluginRepository } =
await import("../database/repositories/factory.js");
const records = await createCurrentPluginRepository().listAll();
const enabled = new Set(
records
.filter((record) => record.state === "enabled")
.map((record) => record.id),
);
const enabled = startablePluginIds(records);
const candidates = loaded
.filter((plugin) => enabled.has(plugin.id))
@@ -257,14 +275,28 @@ async function persistRuntimeState(loaded: LoadedPlugin[]): Promise<void> {
const { createCurrentPluginRepository } =
await import("../database/repositories/factory.js");
const repository = createCurrentPluginRepository();
const records = new Map(
(await repository.listAll()).map((record) => [record.id, record]),
);
for (const plugin of loaded) {
if (plugin.state !== "blocked" && plugin.state !== "failed") continue;
const record = records.get(plugin.id);
try {
await repository.update(plugin.id, {
state: plugin.state,
lastError: plugin.lastError,
});
if (plugin.state === "blocked" || plugin.state === "failed") {
await repository.update(plugin.id, {
state: plugin.state,
lastError: plugin.lastError,
});
} else if (
plugin.state === "active" &&
record &&
record.state !== "enabled"
) {
await repository.update(plugin.id, {
state: "enabled",
lastError: null,
});
}
} catch {
// Reporting state must never stop the boot.
}
@@ -562,11 +594,19 @@ export async function installPluginArtifact(
file,
pluginLoader.bundledIds(),
);
await seedPlugins([plugin]);
await seedPlugins([plugin], false);
await syncCapabilityGrants([plugin]);
return plugin;
}
/** Gives freshly loaded plugins a row and their bundled grants. */
export async function registerLoadedPlugins(
loaded: LoadedPlugin[],
): Promise<void> {
await seedPlugins(loaded, false);
await syncCapabilityGrants(loaded);
}
/** Stops a plugin and forgets it, before a new version is installed. */
export async function unloadPlugin(pluginId: string): Promise<void> {
const { loader: pluginLoader } = getPluginRuntime();
+79
View File
@@ -0,0 +1,79 @@
/**
* Popularity numbers for the plugin store, copied from the registry's
* stats.json into plugin_install_counts.
*
* Two sources, and the store prefers the first:
* - "aggregate-telemetry": instances that reported the plugin enabled in the
* last 7 days through the Usage Statistics plugin. It undercounts by every
* instance that turned the report off or never installed that plugin.
* - "github-releases": downloads of the plugin's .tmxplug release assets.
* This counts downloads, not active installs: an instance that updates
* three times counts three times, and Docker images that bundle the plugin
* count once per build, not once per install.
*
* Treat both as a rough ranking, never as a user count.
*/
import { pluginLogger } from "../utils/logger.js";
import { OFFICIAL_REGISTRY_ID, fetchRegistryStats } from "./registry-index.js";
export const TELEMETRY_SOURCE = "aggregate-telemetry";
export const DOWNLOADS_SOURCE = "github-releases";
export interface InstallCount {
count: number;
source: string;
}
async function repository() {
const { createCurrentPluginInstallCountRepository } =
await import("../database/repositories/factory.js");
return createCurrentPluginInstallCountRepository();
}
/** Picks the number to show: active installs when known, downloads otherwise. */
export function preferredCount(entry: {
downloads: number;
activeInstalls: number | null;
}): InstallCount {
return entry.activeInstalls !== null && entry.activeInstalls > 0
? { count: entry.activeInstalls, source: TELEMETRY_SOURCE }
: { count: entry.downloads, source: DOWNLOADS_SOURCE };
}
/** Returns how many rows changed. Never throws; a failed sync keeps the old numbers. */
export async function syncInstallCounts(): Promise<number> {
try {
const stats = await fetchRegistryStats();
if (!stats) return 0;
const entries = [...stats.plugins].map(([pluginId, entry]) => ({
pluginId,
...preferredCount(entry),
}));
return await (await repository()).upsertMany(OFFICIAL_REGISTRY_ID, entries);
} catch (error) {
pluginLogger.warn(
`Could not sync plugin install counts: ${
error instanceof Error ? error.message : String(error)
}`,
{ operation: "plugin_registry" },
);
return 0;
}
}
export async function readInstallCounts(
registryId: string = OFFICIAL_REGISTRY_ID,
): Promise<Map<string, InstallCount>> {
try {
const rows = await (await repository()).listByRegistry(registryId);
return new Map(
rows.map((row) => [
row.pluginId,
{ count: row.count, source: row.source },
]),
);
} catch {
return new Map();
}
}
+119 -8
View File
@@ -20,7 +20,7 @@ import * as tar from "tar";
import { pluginLogger } from "../utils/logger.js";
import { isTermixCompatible, parseManifest } from "./manifest.js";
import { getLocalVersion } from "../utils/app-version.js";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
import {
getBundledPluginsDir,
getPluginBackendEntry,
@@ -29,6 +29,7 @@ import {
getUnpackedPluginsDir,
} from "./paths.js";
import { requireSignedPlugins, verifyPluginArtifact } from "./trust.js";
import { moveWithRetry } from "./fs-retry.js";
import {
createPluginContext,
createPluginHandle,
@@ -39,7 +40,7 @@ import {
import { resolveRequirements } from "./service-registry.js";
import { resolveSecretRequirements } from "./secret-registry.js";
import { recordConflict } from "./conflicts.js";
import { tablePrefix } from "@termix/plugin-sdk/db";
import { tablePrefix } from "@termix-ssh/plugin-sdk/db";
export type PluginState =
| "loaded"
@@ -90,6 +91,14 @@ export class PluginLoader {
private readonly bundledVersions = new Map<string, string>();
/** Activation order, so shutdown can run it backwards. */
private activationOrder: string[] = [];
/**
* Bundled plugins an admin uninstalled. The shipped copy is ignored as if
* it were not there, so a reinstall from the registry loads as a normal
* download and a recreated image cannot bring the old copy back.
*/
private uninstalled = new Set<string>();
/** Versions an admin pinned, which may sit below the bundled copy. */
private pinned = new Map<string, string>();
constructor(private readonly options: PluginLoaderOptions = {}) {}
@@ -111,6 +120,33 @@ export class PluginLoader {
]);
}
setUninstalled(ids: Iterable<string>): void {
this.uninstalled = new Set(ids);
for (const id of this.uninstalled) this.bundledVersions.delete(id);
}
setPinned(pins: Map<string, string>): void {
this.pinned = new Map(pins);
}
/** Version of the copy that ships in the image, loaded or not. */
bundledVersion(pluginId: string): string | undefined {
return this.bundledVersions.get(pluginId);
}
/** Loads the shipped copy of a bundled plugin, e.g. after a reinstall. */
async loadBundled(pluginId: string): Promise<LoadedPlugin> {
if (!PLUGIN_ID_PATTERN.test(pluginId)) {
throw new Error(`invalid plugin id "${pluginId}"`);
}
const plugin = await this.load(
path.join(getBundledPluginsDir(), pluginId),
"bundled",
);
this.bundledVersions.set(plugin.id, plugin.manifest.version);
return plugin;
}
/** Drops a stopped plugin from the list, so a new copy can load in its place. */
forget(pluginId: string): void {
const plugin = this.plugins.get(pluginId);
@@ -215,6 +251,7 @@ export class PluginLoader {
async loadAll(): Promise<LoadedPlugin[]> {
this.plugins.clear();
this.bundledVersions.clear();
await removeStaleStaging();
const loaded: LoadedPlugin[] = [];
// Every bundled folder name, loaded or not: a bundled plugin that fails
// to load must not leave its id free for a user plugin to take.
@@ -231,7 +268,10 @@ export class PluginLoader {
const entries = await fs.promises.readdir(root, { withFileTypes: true });
if (source === "bundled") {
for (const entry of entries) {
if (entry.isDirectory()) bundledIds.add(entry.name);
if (!entry.isDirectory() || this.uninstalled.has(entry.name)) {
continue;
}
bundledIds.add(entry.name);
}
}
@@ -244,6 +284,7 @@ export class PluginLoader {
if (entry.isDirectory() && entry.name.startsWith(".")) continue;
const dir = path.join(root, entry.name);
if (source === "bundled" && this.uninstalled.has(entry.name)) continue;
try {
if (isArtifact) {
const plugin = await this.loadArtifact(dir, bundledIds);
@@ -321,6 +362,13 @@ export class PluginLoader {
);
}
const unsafe = await unsafeArchivePath(file);
if (unsafe) {
throw new Error(
`${path.basename(file)} has a file outside the plugin folder: ${unsafe}`,
);
}
const unpackedRoot = getUnpackedPluginsDir();
const staging = path.join(
unpackedRoot,
@@ -355,6 +403,7 @@ export class PluginLoader {
raw?.version,
signedBy,
this.bundledVersions.get(id) ?? replaced?.manifest.version,
this.pinned.get(id),
);
} else if (this.plugins.has(id)) {
throw new Error(`another plugin already uses the id "${id}"`);
@@ -364,8 +413,12 @@ export class PluginLoader {
}
const target = path.join(unpackedRoot, id);
await fs.promises.rm(target, { recursive: true, force: true });
await fs.promises.rename(staging, target);
await fs.promises.rm(target, {
recursive: true,
force: true,
maxRetries: 5,
});
await moveWithRetry(staging, target);
if (replaced) this.plugins.delete(id);
try {
@@ -385,7 +438,15 @@ export class PluginLoader {
throw error;
}
} finally {
await fs.promises.rm(staging, { recursive: true, force: true });
// Never let a failed cleanup hide the error that got us here.
await fs.promises
.rm(staging, { recursive: true, force: true, maxRetries: 5 })
.catch((error) =>
pluginLogger.warn(`Could not remove ${staging}`, {
operation: "plugin_load",
error: error instanceof Error ? error.message : String(error),
}),
);
}
}
@@ -437,7 +498,11 @@ export class PluginLoader {
state.set(id, "done");
return false;
}
if (!semver.satisfies(dependency.manifest.version, range)) {
if (
!semver.satisfies(dependency.manifest.version, range, {
includePrerelease: true,
})
) {
blocked.set(
id,
`requires "${dependencyId}" ${range}, but ${dependency.manifest.version} is installed`,
@@ -547,8 +612,10 @@ export class PluginLoader {
// than leave it half-running against a schema that is not there. A
// throw here lands in the catch below, which fails this plugin only.
const { migratePlugin } = await import("./data.js");
// A release signed by a trusted key is official whether it shipped in
// the image or was downloaded, so it may adopt its 2.8 tables too.
const applied = await migratePlugin(plugin.id, plugin.dir, {
bundled: plugin.source === "bundled",
bundled: plugin.source === "bundled" || Boolean(plugin.signedBy),
});
// A new table may be where core data is waiting to move.
if (applied.length > 0) {
@@ -776,6 +843,7 @@ export function assertBundledUpdate(
version: unknown,
signedBy: string | undefined,
bundledVersion: string | undefined,
pinnedVersion?: string,
): void {
if (!signedBy) {
throw new Error(
@@ -785,6 +853,8 @@ export function assertBundledUpdate(
if (typeof version !== "string" || !semver.valid(version)) {
throw new Error(`update for "${id}" has no valid version`);
}
// An admin pinning an exact older release is the one allowed downgrade.
if (pinnedVersion && pinnedVersion === version) return;
if (bundledVersion && !semver.gt(version, bundledVersion)) {
throw new Error(
`update for "${id}" is ${version}, which is not newer than the bundled ${bundledVersion}`,
@@ -830,3 +900,44 @@ export function isRealPathInside(root: string, target: string): boolean {
return false;
}
}
/**
* The first entry that would land outside the folder it is unpacked into,
* or null. tar skips these on its own, but its stream keeps writing after
* it rejects, so they are refused before anything is unpacked.
*/
export async function unsafeArchivePath(file: string): Promise<string | null> {
let unsafe: string | null = null;
await tar.t({
file,
onReadEntry: (entry) => {
const name = entry.path.replace(/\\/g, "/");
if (
!unsafe &&
(name.startsWith("/") ||
/^[a-zA-Z]:/.test(name) ||
name.split("/").includes(".."))
) {
unsafe = entry.path;
}
},
});
return unsafe;
}
/** Clears unpack folders an install left behind when the process died. */
export async function removeStaleStaging(): Promise<void> {
const root = getUnpackedPluginsDir();
let entries: fs.Dirent[];
try {
entries = await fs.promises.readdir(root, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (!entry.isDirectory() || !entry.name.startsWith(".staging-")) continue;
await fs.promises
.rm(path.join(root, entry.name), { recursive: true, force: true })
.catch(() => {});
}
}
File diff suppressed because it is too large. Load diff
+9 -4
View File
@@ -1,7 +1,7 @@
/**
* Manifest validation, re-exported from the SDK.
*
* The contract lives in @termix/plugin-sdk/manifest so the server, the
* The contract lives in @termix-ssh/plugin-sdk/manifest so the server, the
* authoring script and plugin authors all read the same rules. This file used
* to carry its own copy of the enums and validators, which meant three places
* had to be edited together and quietly drifted when they were not.
@@ -17,10 +17,15 @@ export {
SYSTEM_ROLE_NAMES,
RESERVED_PERMISSION_PREFIXES,
qualifyPermission,
} from "@termix/plugin-sdk/manifest";
youtubeVideoId,
pluginFeatures,
pluginDocsUrl,
pluginEnvVars,
} from "@termix-ssh/plugin-sdk/manifest";
export type {
PluginManifest,
PluginEnvVar,
PluginAuthor,
PluginEngine,
PluginContributions,
@@ -36,10 +41,10 @@ export type {
HostCapabilityContribution,
ActionContributionKind,
ParsedManifest,
} from "@termix/plugin-sdk/manifest";
} from "@termix-ssh/plugin-sdk/manifest";
export type {
Capability,
CapabilityInfo,
CapabilityRisk,
} from "@termix/plugin-sdk/capabilities";
} from "@termix-ssh/plugin-sdk/capabilities";
+5 -4
View File
@@ -18,12 +18,12 @@ import path from "node:path";
import { sql } from "drizzle-orm";
import { pluginLogger } from "../utils/logger.js";
import type { DatabaseDialect } from "../database/db/dialect.js";
import { LEGACY_TABLE_OWNERS } from "@termix/plugin-sdk/db";
import { LEGACY_TABLE_OWNERS } from "@termix-ssh/plugin-sdk/db";
import {
collectOwnedIndexes,
findUnownedTableWrites,
splitStatements,
} from "@termix/plugin-sdk/ddl";
} from "@termix-ssh/plugin-sdk/ddl";
// The splitter is shared with createTestDb in the SDK, so tests apply a
// migration exactly the way this runner does.
@@ -112,8 +112,9 @@ export function readMigrations(
*
* A plugin owns the p_<id>_ namespace and nothing else. Enforced here as well
* as in the CLI, because a plugin installed from a tarball never ran the CLI.
* A legacy core table is allowed only for the bundled plugin that adopts it:
* a user plugin with the same id gets no exemption.
* A legacy core table is allowed only for the official plugin that adopts
* it, bundled or signed by a trusted key: an unsigned plugin with the same id
* gets no exemption.
*/
export function assertOwnedTables(
pluginId: string,
+1 -1
View File
@@ -4,7 +4,7 @@
* alert reaches nobody.
*/
import type { PluginNotifyHub } from "@termix/plugin-sdk/backend";
import type { PluginNotifyHub } from "@termix-ssh/plugin-sdk/backend";
import { hasCapability } from "./permissions.js";
interface HubRegistration {
+242
View File
@@ -0,0 +1,242 @@
/**
* The onboarding plugin picker: what a fresh install's first admin sees and
* how their choices are applied in one go.
*/
import { pluginLogger } from "../utils/logger.js";
import { getPluginRuntime } from "./index.js";
import { onboardingDefaults } from "./bundled-index.js";
import {
isLinkedDesktop,
serialized,
setPluginStateUnlocked,
uninstallPluginUnlocked,
} from "./manage.js";
import {
applyOrder,
resolvePluginChoices,
type ChoiceAdjustment,
type ChoicePlugin,
type PluginChoice,
} from "../../types/plugin-onboarding.js";
/**
* Set until an admin confirms the picker: "fresh" on a new install, "upgrade"
* on one that existed before the picker (2.9 and earlier).
*/
export const PLUGIN_SETUP_PENDING_KEY = "onboarding_plugin_setup_pending";
/** Set once the picker has been offered, so it is offered only once. */
export const PLUGIN_SETUP_OFFERED_KEY = "onboarding_plugin_setup_offered";
export type PluginSetupReason = "fresh" | "upgrade";
export interface OnboardingPluginInfo {
id: string;
name: string;
description: string;
icon: string | null;
category: string;
version: string;
source: "bundled" | "user";
state: string;
dependencies: string[];
recommended: boolean;
consent: boolean;
}
export interface OnboardingPluginList {
pending: boolean;
/** Why the picker is pending. On an upgrade it starts from what is on. */
reason: PluginSetupReason | null;
managedByLinkedServer: boolean;
plugins: OnboardingPluginInfo[];
}
export interface OnboardingApplyResult {
resolved: Record<string, PluginChoice>;
adjustments: ChoiceAdjustment[];
enabled: string[];
disabled: string[];
removed: string[];
failed: { id: string; error: string; code?: string }[];
}
async function repos() {
return import("../database/repositories/factory.js");
}
export async function pluginSetupReason(): Promise<PluginSetupReason | null> {
const { createCurrentSettingsRepository } = await repos();
const value = await createCurrentSettingsRepository().get(
PLUGIN_SETUP_PENDING_KEY,
);
if (value === "fresh" || value === "1") return "fresh";
if (value === "upgrade") return "upgrade";
return null;
}
export async function isPluginSetupPending(): Promise<boolean> {
return (await pluginSetupReason()) !== null;
}
async function clearPluginSetupPending(): Promise<void> {
const { createCurrentSettingsRepository } = await repos();
const settings = createCurrentSettingsRepository();
await settings.delete(PLUGIN_SETUP_PENDING_KEY);
await settings.set(PLUGIN_SETUP_OFFERED_KEY, "1");
}
/**
* Called once at boot. Every install is offered the picker once: a fresh
* one on its first admin's first run, an upgraded one (which never saw it)
* the next time an admin signs in.
*/
export async function offerPluginSetup(fresh: boolean): Promise<void> {
const { createCurrentSettingsRepository } = await repos();
const settings = createCurrentSettingsRepository();
if (await settings.get(PLUGIN_SETUP_OFFERED_KEY)) return;
await settings.set(PLUGIN_SETUP_PENDING_KEY, fresh ? "fresh" : "upgrade");
await settings.set(PLUGIN_SETUP_OFFERED_KEY, "1");
}
function choicePlugins(): ChoicePlugin[] {
return getPluginRuntime()
.loader.list()
.map((plugin) => ({
id: plugin.id,
dependencies: Object.keys(plugin.manifest.dependencies ?? {}),
}));
}
export async function describeOnboardingPlugins(): Promise<OnboardingPluginList> {
const { createCurrentPluginRepository } = await repos();
const states = new Map(
(await createCurrentPluginRepository().listAll()).map((record) => [
record.id,
record.state,
]),
);
const plugins = getPluginRuntime()
.loader.list()
.map((plugin): OnboardingPluginInfo => {
const defaults =
plugin.source === "bundled"
? onboardingDefaults(plugin.id)
: { recommended: false, consent: false };
return {
id: plugin.id,
name: plugin.manifest.name,
description: plugin.manifest.description,
icon: plugin.manifest.icon ?? null,
category: plugin.manifest.category,
version: plugin.manifest.version,
source: plugin.source,
state: states.get(plugin.id) ?? "disabled",
dependencies: Object.keys(plugin.manifest.dependencies ?? {}),
...defaults,
};
})
.sort((a, b) => a.name.localeCompare(b.name));
const reason = await pluginSetupReason();
return {
pending: reason !== null,
reason,
managedByLinkedServer: await isLinkedDesktop(),
plugins,
};
}
/**
* Applies the picker in one serialized pass: enable (dependencies first),
* then disable and remove (dependents first). One plugin failing does not
* stop the rest. Ids that are not installed are ignored.
*/
export function applyOnboardingChoices(
choices: Record<string, PluginChoice>,
options: { dryRun?: boolean } = {},
): Promise<OnboardingApplyResult> {
return serialized(async () => {
const plugins = choicePlugins();
const installed = new Set(plugins.map((plugin) => plugin.id));
const wanted: Record<string, PluginChoice> = {};
for (const [id, choice] of Object.entries(choices)) {
if (installed.has(id)) wanted[id] = choice;
}
const { choices: resolved, adjustments } = resolvePluginChoices(
plugins,
wanted,
);
const result: OnboardingApplyResult = {
resolved,
adjustments,
enabled: [],
disabled: [],
removed: [],
failed: [],
};
const { createCurrentPluginRepository } = await repos();
const states = new Map(
(await createCurrentPluginRepository().listAll()).map((record) => [
record.id,
record.state,
]),
);
const order = applyOrder(plugins, resolved);
const enable = order.enable.filter((id) => states.get(id) !== "enabled");
const disable = order.disable.filter((id) => states.get(id) === "enabled");
if (options.dryRun) {
return {
...result,
enabled: enable,
disabled: disable,
removed: order.remove,
};
}
const attempt = async (
id: string,
run: () => Promise<unknown>,
done: string[],
) => {
try {
const outcome = (await run()) as { state?: string } | undefined;
if (outcome?.state === "failed" || outcome?.state === "blocked") {
result.failed.push({ id, error: outcome.state });
return;
}
done.push(id);
} catch (error) {
result.failed.push({
id,
error: error instanceof Error ? error.message : String(error),
code: (error as { code?: string })?.code,
});
}
};
for (const id of enable) {
await attempt(id, () => setPluginStateUnlocked(id, true), result.enabled);
}
for (const id of disable) {
await attempt(
id,
() => setPluginStateUnlocked(id, false),
result.disabled,
);
}
for (const id of order.remove) {
await attempt(id, () => uninstallPluginUnlocked(id), result.removed);
}
await clearPluginSetupPending();
pluginLogger.info(
`Onboarding plugin choices applied: ${result.enabled.length} enabled, ${result.disabled.length} disabled, ${result.removed.length} removed, ${result.failed.length} failed`,
{ operation: "plugin_onboarding" },
);
return result;
});
}
+10 -19
View File
@@ -1,10 +1,9 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
DEFAULT_BACKEND_ENTRY,
type PluginManifest,
} from "@termix/plugin-sdk/manifest";
} from "@termix-ssh/plugin-sdk/manifest";
/**
* Where user-installed plugins live. Resolved per call rather than captured at
@@ -43,29 +42,21 @@ export function getUnpackedPluginsDir(): string {
* plugin is part of the install and must not be deletable or shadowable by
* whatever happens to be in the data directory.
*
* Resolved from this module's location rather than cwd, because the build
* emits to dist/backend/backend/plugins/ and the bundled plugins sit beside
* dist/backend.
* Resolved from this module's location rather than cwd. The build emits to
* dist/backend/backend/plugins/ with the bundled plugins beside dist/backend,
* and a run from source reads the same dist/plugins.
*/
export function getBundledPluginsDir(): string {
const override = process.env.TERMIX_BUNDLED_PLUGINS_DIR;
if (override) return override;
const here = path.dirname(fileURLToPath(import.meta.url));
const candidates = [
// Built server: dist/backend/backend/plugins -> dist/plugins
// Source tree: src/backend/plugins -> plugins
// Both are three levels up, which is why there is one entry, not two.
path.resolve(here, "../../../plugins"),
// Built server invoked from a nested layout: .../dist/backend/backend/plugins
path.resolve(here, "../../../../plugins"),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) return candidate;
}
return candidates[0];
// Built: dist/backend/backend/plugins -> dist/plugins
// Source (vitest, tsx): src/backend/plugins -> dist/plugins
const fromSource = path.basename(path.resolve(here, "../..")) === "src";
return fromSource
? path.resolve(here, "../../../dist/plugins")
: path.resolve(here, "../../../plugins");
}
/** The backend entry, from the manifest's own `backend` field. */
+1 -1
View File
@@ -16,7 +16,7 @@
*/
import { createCurrentPluginPermissionGrantRepository } from "../database/repositories/factory.js";
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
import { PluginCapabilityError } from "@termix-ssh/plugin-sdk/backend";
import { getActor } from "./actor.js";
const cache = new Map<string, Set<string>>();
+456
View File
@@ -0,0 +1,456 @@
/**
* The official plugin registry: fetching its index and downloading a release.
*
* The index is a plain JSON file in Termix-SSH/Termix-Registry. Nothing in it
* is trusted on its own: every download is checked against the sha256 and
* Ed25519 signature listed for it, and the signature must come from a key
* pinned in trust.ts. A tampered index can at most point at a file that then
* fails the check.
*/
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import semver from "semver";
import {
pluginDocsUrl,
pluginFeatures,
SUPPORTED_PLUGIN_API_VERSION,
youtubeVideoId,
} from "@termix-ssh/plugin-sdk/manifest";
import { safeOutboundFetch } from "../utils/safe-outbound-fetch.js";
import { pluginLogger } from "../utils/logger.js";
import { verifyPluginArtifact } from "./trust.js";
import { readArtifactManifest, sameCapabilities } from "./artifact-manifest.js";
import { getPluginsDir } from "./paths.js";
export const OFFICIAL_REGISTRY_ID = "official";
export const OFFICIAL_REGISTRY_URL =
"https://raw.githubusercontent.com/Termix-SSH/Termix-Registry/main/official/index.json";
const CACHE_MS = 15 * 60_000;
const MAX_INDEX_BYTES = 5 * 1024 * 1024;
const MAX_ARTIFACT_BYTES = 200 * 1024 * 1024;
const MAX_REDIRECTS = 5;
const MAX_NOTES_LENGTH = 20_000;
const ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
export interface RegistryVersion {
version: string;
api: string;
url: string;
sha256: string;
signature: string;
size: number;
capabilities: string[];
releaseNotesUrl?: string;
/** This version's CHANGELOG.md section, as Markdown. */
notes?: string;
publishedAt?: string;
/** Listed under the plugin's prereleases: a beta. */
prerelease?: boolean;
}
export type PluginChannel = "stable" | "beta";
export function parsePluginChannel(value: unknown): PluginChannel | null {
return value === "stable" || value === "beta" ? value : null;
}
export interface RegistryPlugin {
id: string;
name: string;
description: string;
author: string;
category: string;
repository?: string;
icon?: string;
/** Only ever a YouTube video id, never a link. */
videoId?: string;
features: string[];
/** https docs link from the index. */
docs?: string;
/** Stable releases, newest first. */
versions: RegistryVersion[];
/**
* Betas newer than the newest stable release, newest first. A separate
* list so cores from before channels never offer one.
*/
prereleases: RegistryVersion[];
}
export interface RegistryIndex {
registry: string;
name: string;
updatedAt?: string;
plugins: RegistryPlugin[];
}
export function getRegistryUrl(): string {
return process.env.TERMIX_PLUGIN_REGISTRY_URL || OFFICIAL_REGISTRY_URL;
}
function asString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
function parseVersion(raw: unknown): RegistryVersion | null {
if (!raw || typeof raw !== "object") return null;
const entry = raw as Record<string, unknown>;
const version = asString(entry.version);
const url = asString(entry.url);
const sha256 = asString(entry.sha256);
const signature = asString(entry.signature);
if (!version || !semver.valid(version) || !url || !sha256 || !signature) {
return null;
}
if (!/^[a-f0-9]{64}$/i.test(sha256)) return null;
try {
if (new URL(url).protocol !== "https:") return null;
} catch {
return null;
}
return {
version,
api: asString(entry.api) ?? "",
url,
sha256: sha256.toLowerCase(),
signature,
size: typeof entry.size === "number" ? entry.size : 0,
capabilities: Array.isArray(entry.capabilities)
? entry.capabilities.filter((c): c is string => typeof c === "string")
: [],
releaseNotesUrl: asString(entry.releaseNotesUrl),
notes: asString(entry.notes)?.slice(0, MAX_NOTES_LENGTH),
publishedAt: asString(entry.publishedAt),
};
}
/** Validates a fetched index. Malformed entries are dropped, not fatal. */
export function parseRegistryIndex(raw: unknown): RegistryIndex {
if (!raw || typeof raw !== "object") {
throw new Error("registry index is not an object");
}
const index = raw as Record<string, unknown>;
if (!Array.isArray(index.plugins)) {
throw new Error("registry index has no plugins list");
}
const plugins: RegistryPlugin[] = [];
for (const item of index.plugins) {
if (!item || typeof item !== "object") continue;
const entry = item as Record<string, unknown>;
const id = asString(entry.id);
if (!id || !ID_PATTERN.test(id)) continue;
const parseList = (list: unknown) =>
(Array.isArray(list) ? list : [])
.map(parseVersion)
.filter((v): v is RegistryVersion => v !== null)
.sort((a, b) => semver.rcompare(a.version, b.version));
const versions = parseList(entry.versions).filter(
(v) => !semver.prerelease(v.version),
);
const prereleases = parseList(entry.prereleases)
.filter((v) => semver.prerelease(v.version))
.map((v) => ({ ...v, prerelease: true }));
if (versions.length === 0 && prereleases.length === 0) continue;
plugins.push({
id,
name: asString(entry.name) ?? id,
description: asString(entry.description) ?? "",
author: asString(entry.author) ?? "",
category: asString(entry.category) ?? "",
repository: asString(entry.repository),
icon: asString(entry.icon),
videoId: youtubeVideoId(entry.video) ?? undefined,
features: pluginFeatures(entry.features),
docs: pluginDocsUrl(entry.docs) ?? undefined,
versions,
prereleases,
});
}
return {
registry: asString(index.registry) ?? OFFICIAL_REGISTRY_ID,
name: asString(index.name) ?? "",
updatedAt: asString(index.updatedAt),
plugins,
};
}
/** Whether this build can run a release built against `api`. */
export function isApiCompatible(api: string): boolean {
const major = semver.coerce(api)?.major;
return major !== undefined && String(major) === SUPPORTED_PLUGIN_API_VERSION;
}
/** Follows redirects by hand so each hop still gets the outbound checks. */
async function fetchFollowing(url: string): Promise<Response> {
let current = url;
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
const response = await safeOutboundFetch(current, {
method: "GET",
redirect: "manual",
headers: { "User-Agent": "Termix" },
});
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get("location");
if (!location) throw new Error(`redirect without a location: ${current}`);
current = new URL(location, current).toString();
continue;
}
if (!response.ok) {
throw new Error(`${current} answered ${response.status}`);
}
return response;
}
throw new Error(`too many redirects fetching ${url}`);
}
async function readLimited(response: Response, limit: number): Promise<Buffer> {
const declared = Number(response.headers.get("content-length") ?? 0);
if (declared > limit) throw new Error("download is larger than allowed");
if (!response.body) return Buffer.alloc(0);
const chunks: Buffer[] = [];
let total = 0;
const reader = response.body.getReader();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > limit) {
await reader.cancel().catch(() => {});
throw new Error("download is larger than allowed");
}
chunks.push(Buffer.from(value));
}
return Buffer.concat(chunks);
}
let cache: { index: RegistryIndex; at: number; hash: string } | null = null;
let inflight: Promise<RegistryIndex> | null = null;
export interface RegistryStatus {
url: string;
lastCheckedAt: string | null;
error: string | null;
}
let lastError: string | null = null;
export function getRegistryStatus(): RegistryStatus {
return {
url: getRegistryUrl(),
lastCheckedAt: cache ? new Date(cache.at).toISOString() : null,
error: lastError,
};
}
export async function fetchRegistryIndex(
options: { force?: boolean } = {},
): Promise<RegistryIndex> {
if (!options.force && cache && Date.now() - cache.at < CACHE_MS) {
return cache.index;
}
if (inflight) return inflight;
inflight = (async () => {
try {
const response = await fetchFollowing(getRegistryUrl());
const body = await readLimited(response, MAX_INDEX_BYTES);
const index = parseRegistryIndex(JSON.parse(body.toString("utf8")));
cache = {
index,
at: Date.now(),
hash: crypto.createHash("sha256").update(body).digest("hex"),
};
lastError = null;
return index;
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
// A stale index beats none when the registry is briefly unreachable.
if (cache) {
pluginLogger.warn(`Plugin registry check failed: ${lastError}`, {
operation: "plugin_registry",
});
return cache.index;
}
throw error;
} finally {
inflight = null;
}
})();
return inflight;
}
export interface RegistryStats {
plugins: Map<string, { downloads: number; activeInstalls: number | null }>;
}
/** stats.json sits next to index.json and is rebuilt daily by the registry. */
export function getStatsUrl(): string | null {
if (process.env.TERMIX_PLUGIN_STATS_URL) {
return process.env.TERMIX_PLUGIN_STATS_URL;
}
const url = getRegistryUrl();
return /\/index\.json$/.test(url)
? url.replace(/\/index\.json$/, "/stats.json")
: null;
}
function asCount(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) && value >= 0
? Math.floor(value)
: null;
}
export function parseRegistryStats(raw: unknown): RegistryStats {
const plugins = new Map<
string,
{ downloads: number; activeInstalls: number | null }
>();
const entries = (raw as { plugins?: unknown } | null)?.plugins;
if (!entries || typeof entries !== "object" || Array.isArray(entries)) {
return { plugins };
}
for (const [id, value] of Object.entries(entries)) {
if (!ID_PATTERN.test(id) || !value || typeof value !== "object") continue;
const entry = value as Record<string, unknown>;
plugins.set(id, {
downloads: asCount(entry.downloads) ?? 0,
activeInstalls: asCount(entry.activeInstalls),
});
}
return { plugins };
}
export async function fetchRegistryStats(): Promise<RegistryStats | null> {
const url = getStatsUrl();
if (!url) return null;
const response = await fetchFollowing(url);
const body = await readLimited(response, MAX_INDEX_BYTES);
return parseRegistryStats(JSON.parse(body.toString("utf8")));
}
/** Test seam. */
export function resetRegistryCache(): void {
cache = null;
inflight = null;
lastError = null;
}
/** Every release a channel may install, newest first. */
export function channelReleases(
plugin: RegistryPlugin,
channel: PluginChannel,
): RegistryVersion[] {
if (channel === "stable") return plugin.versions;
return [...plugin.prereleases, ...plugin.versions].sort((a, b) =>
semver.rcompare(a.version, b.version),
);
}
/** The newest release on a channel this build can run. */
export function latestOnChannel(
plugin: RegistryPlugin,
channel: PluginChannel,
): RegistryVersion | null {
return (
channelReleases(plugin, channel).find((entry) =>
isApiCompatible(entry.api),
) ?? null
);
}
export function findRelease(
index: RegistryIndex,
pluginId: string,
version?: string,
channel: PluginChannel = "stable",
): { plugin: RegistryPlugin; release: RegistryVersion } | null {
const plugin = index.plugins.find((entry) => entry.id === pluginId);
if (!plugin) return null;
const release = version
? channelReleases(plugin, "beta").find((entry) => entry.version === version)
: latestOnChannel(plugin, channel);
if (!release) return null;
return { plugin, release };
}
/**
* The signed file is the truth and the index only describes it, so the
* consent the admin gave (built from the index) must match what the packed
* manifest asks for.
*/
export function assertArtifactMatches(
pluginId: string,
release: RegistryVersion,
manifest: Record<string, unknown>,
): void {
const where = `${pluginId} ${release.version}`;
if (manifest.id !== pluginId) {
throw new Error(`${where}: the archive is for "${String(manifest.id)}"`);
}
if (manifest.version !== release.version) {
throw new Error(
`${where}: the archive is version ${String(manifest.version)}`,
);
}
const declared = Array.isArray(manifest.capabilities)
? manifest.capabilities.filter((c): c is string => typeof c === "string")
: [];
if (!sameCapabilities(declared, release.capabilities)) {
throw new Error(
`${where}: the archive asks for different capabilities than the registry lists`,
);
}
}
/**
* Downloads a release, verifies it, and leaves it in a staging folder the
* loader never scans. The caller moves it into place.
*/
export async function downloadRelease(
pluginId: string,
release: RegistryVersion,
): Promise<{ file: string; signatureFile: string }> {
if (!isApiCompatible(release.api)) {
throw new Error(
`${pluginId} ${release.version} needs plugin API ${release.api}, this build runs ${SUPPORTED_PLUGIN_API_VERSION}`,
);
}
const limit =
release.size > 0 ? release.size + 1024 * 1024 : MAX_ARTIFACT_BYTES;
const response = await fetchFollowing(release.url);
const buffer = await readLimited(
response,
Math.min(limit, MAX_ARTIFACT_BYTES),
);
const verified = verifyPluginArtifact(
buffer,
release.sha256,
release.signature,
);
if (verified.ok === false) {
throw new Error(`${pluginId} ${release.version}: ${verified.reason}`);
}
assertArtifactMatches(pluginId, release, readArtifactManifest(buffer));
const staging = getDownloadsDir();
await fs.promises.mkdir(staging, { recursive: true });
const base = path.join(
staging,
`${pluginId}-${release.version}-${crypto.randomBytes(4).toString("hex")}.tmxplug`,
);
await fs.promises.writeFile(base, buffer);
await fs.promises.writeFile(`${base}.sig`, release.signature.trim());
return { file: base, signatureFile: `${base}.sig` };
}
/** A dot folder inside the plugins dir, which the loader's scan skips. */
export function getDownloadsDir(): string {
return path.join(getPluginsDir(), ".downloads");
}
+1 -1
View File
@@ -3,7 +3,7 @@
* bag, so deactivate clears every one of them.
*/
import type { PluginSchedule } from "@termix/plugin-sdk/backend";
import type { PluginSchedule } from "@termix-ssh/plugin-sdk/backend";
import type { DisposableBag } from "./disposables.js";
type Log = (message: string, error: unknown) => void;
+14 -3
View File
@@ -229,7 +229,12 @@ export function getServiceImplementation<T extends object>(
): T | undefined {
const registration = lookup(service, name);
if (!registration) return undefined;
if (!semver.satisfies(registration.version, versionRange)) return undefined;
if (
!semver.satisfies(registration.version, versionRange, {
includePrerelease: true,
})
)
return undefined;
return registration.implementation as T;
}
@@ -267,7 +272,9 @@ export function resolveRequirements(
const providers = [...(services.get(requirement.service)?.values() ?? [])];
const registration =
providers.find((candidate) =>
semver.satisfies(candidate.version, requirement.versionRange),
semver.satisfies(candidate.version, requirement.versionRange, {
includePrerelease: true,
}),
) ?? providers[0];
if (!registration) {
@@ -280,7 +287,11 @@ export function resolveRequirements(
continue;
}
if (!semver.satisfies(registration.version, requirement.versionRange)) {
if (
!semver.satisfies(registration.version, requirement.versionRange, {
includePrerelease: true,
})
) {
recordUnsatisfied(
requirement,
`requires service "${requirement.service}" ${requirement.versionRange}, but ${registration.pluginId} provides ${registration.version}`,
+2 -2
View File
@@ -14,12 +14,12 @@
import type {
PluginManifest,
PluginSettingsField,
} from "@termix/plugin-sdk/manifest";
} from "@termix-ssh/plugin-sdk/manifest";
import {
coerceSettingValue,
isRedactedSecret,
validateSettingValue,
} from "@termix/plugin-sdk/settings";
} from "@termix-ssh/plugin-sdk/settings";
import {
createCurrentHostRepository,
createCurrentPluginSettingsRepository,
+1 -1
View File
@@ -14,7 +14,7 @@
import type {
SyncEntityRegistration,
SyncRow,
} from "@termix/plugin-sdk/backend";
} from "@termix-ssh/plugin-sdk/backend";
/** Core-only extras a plugin registration cannot set. */
export interface CoreSyncEntityOptions {
+2 -2
View File
@@ -6,11 +6,11 @@
* core keeps one import site.
*/
export { buildTable } from "@termix/plugin-sdk/table-builder";
export { buildTable } from "@termix-ssh/plugin-sdk/table-builder";
export {
createTableSql,
adoptTableSql,
dropTableSql,
addColumnSql,
keyedColumns,
} from "@termix/plugin-sdk/ddl";
} from "@termix-ssh/plugin-sdk/ddl";
+7 -1
View File
@@ -20,7 +20,13 @@ export interface TrustedPluginKey {
*
* Add a key with `termix-plugin keygen` (see the Termix-Registry README).
*/
export const TRUSTED_PLUGIN_KEYS: readonly TrustedPluginKey[] = [];
export const TRUSTED_PLUGIN_KEYS: readonly TrustedPluginKey[] = [
{
id: "ec40e29bcc4986b2",
publicKey: "rF6ouWTnuLgnZi7yemr+yhZmMxvNMvgro1Mf05IkSGo=",
addedIn: "26.10.0",
},
];
export type ArtifactVerification =
{ ok: true; keyId: string } | { ok: false; reason: string };
+17 -10
View File
@@ -7,9 +7,9 @@
*/
import { sql } from "drizzle-orm";
import { columnName, createTableSql } from "@termix/plugin-sdk/ddl";
import { prefixedTableName } from "@termix/plugin-sdk/db";
import type { PluginTableDefinition } from "@termix/plugin-sdk/db";
import { columnName, createTableSql } from "@termix-ssh/plugin-sdk/ddl";
import { prefixedTableName } from "@termix-ssh/plugin-sdk/db";
import type { PluginTableDefinition } from "@termix-ssh/plugin-sdk/db";
import {
runStatement,
selectRows,
@@ -29,6 +29,8 @@ export interface UserOwnedTable {
hostColumns: string[];
/** Columns that travel, as SQL names. The primary key and secrets do not. */
columns: string[];
/** The owner column is the primary key: one row per user. */
userIsKey: boolean;
definition: PluginTableDefinition;
}
@@ -50,14 +52,17 @@ export function listUserOwnedTables(): UserOwnedTable[] {
hostColumns: entries
.filter(([, column]) => column.type === "refHost")
.map(([property, column]) => columnName(property, column)),
// The owner column always travels, even as the key; import rewrites it.
columns: entries
.filter(
([, column]) =>
column.type !== "id" &&
!column.primaryKey &&
column.type !== "encryptedText",
column === user[1] ||
(column.type !== "id" &&
!column.primaryKey &&
column.type !== "encryptedText"),
)
.map(([property, column]) => columnName(property, column)),
userIsKey: !!user[1].primaryKey,
definition,
});
}
@@ -239,13 +244,15 @@ export async function importUserPluginRows(
summary.skipped++;
continue;
}
const matchOn = owned.userIsKey ? [owned.userColumn] : columns;
const existing = await selectRows(
sql`SELECT 1 FROM ${sql.identifier(owned.table)} WHERE ${sql.join(
columns.map((column, index) =>
values[index] === null
matchOn.map((column) => {
const value = values[columns.indexOf(column)];
return value === null
? sql`${sql.identifier(column)} IS NULL`
: sql`${sql.identifier(column)} = ${values[index]}`,
),
: sql`${sql.identifier(column)} = ${value}`;
}),
sql` AND `,
)} LIMIT 1`,
);
+14 -7
View File
@@ -25,10 +25,13 @@ import { WebSocketServer, type WebSocket } from "ws";
import type {
PluginWebSocketHandler,
PluginWebSocketOptions,
} from "@termix/plugin-sdk/backend";
} from "@termix-ssh/plugin-sdk/backend";
import { pluginLogger } from "../utils/logger.js";
import { recordConflict } from "./conflicts.js";
import { extractWebSocketToken } from "../utils/ws-auth.js";
import {
extractWebSocketToken,
isCookieOriginAllowed,
} from "../utils/ws-auth.js";
import { runAsActor } from "./actor.js";
import { isPluginInstalled } from "./http.js";
@@ -110,7 +113,9 @@ async function verifyToken(request: IncomingMessage): Promise<string | null> {
if (!token) return null;
try {
const { AuthManager } = await import("../utils/auth-manager.js");
const payload = await AuthManager.getInstance().verifyJWTToken(token);
const payload = await AuthManager.getInstance().verifyJWTToken(token, {
allowSocketTicket: true,
});
if (!payload || payload.pendingTOTP) return null;
return payload.userId ?? null;
} catch {
@@ -163,6 +168,12 @@ async function handlePluginUpgrade(
return true;
}
// A page on another site gets the browser's cookies attached for free.
// Core already ignores them for auth; plugin code must not see them either.
if (request.headers.cookie && !isCookieOriginAllowed(request)) {
delete request.headers.cookie;
}
let userId = "";
if (!route.options.public) {
const resolved = await verifyToken(request);
@@ -362,10 +373,6 @@ function disposeRoute(routeKey: string, pluginId: string, path: string): void {
});
}
export function getRegisteredWsRoutes(): string[] {
return [...routes.keys()];
}
/** The socket paths a plugin serves without core's login check. */
export function getPluginPublicWsRoutes(pluginId: string): string[] {
return [...routes.values()]