feat: convert serial console into the serial plugin

This commit is contained in:
LukeGus committed 2026-09-23 23:06:37 -05:00
1 parent 708eedb861
commit 791d8656a1
121 files changed
+2216 -1425

No files matched your search

+87 -5
View File
@@ -1031,6 +1031,7 @@ not do.
"frontend": "dist/frontend.js", // default
"locales": "locales", // default
"platforms": ["linux", "win32", "darwin"], // optional
"nativeDependencies": ["serialport"], // optional, see "Native dependencies"
}
```
@@ -1104,6 +1105,7 @@ deciding, not the mechanism.
| `users:write` | high | Create and change user accounts |
| `auth:provide` | high | Add a login method, second factor or SSH auth type |
| `system:tls` | high | Request and replace the server certificate |
| `device:serial` | high | Open a physical serial or USB device on the Termix server |
| `hosts:write` | medium | Create and change hosts |
| `credentials:use` | medium | Connect using a host's stored credentials, without seeing them |
| `network:outbound` | medium | Make outbound requests |
@@ -1156,6 +1158,7 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
| `ctx.sync.registerEntity` | none | **A3** |
| `ctx.http.router` / `ctx.ws.route` / `.upgrade` | `network:serve` | **A4** |
| `ctx.rbac.has` / `.hasFor` / `.require` | own permissions only | **A5** |
| `ctx.capabilities.has` / `.require` | the capability itself | **B11** |
| `ctx.hosts.*` | `hosts:read` / `hosts:write` | **B4**, extended **B5** |
| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | **A8** |
| `ctx.settings.*` | `settings:read-core` (readCore only) | **A6** |
@@ -1165,6 +1168,20 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
| `ctx.audit.record` | none, the actor is the runtime's | **B9** |
| `ctx.fetch` | `network:outbound` | B |
**B11** added `ctx.capabilities.has(capability)` / `.require(capability)`, a
generic check for a capability no other ctx member wraps. Unlike every other
row above, the capability it checks is not fixed at the call site: the
plugin names it. That is right for exactly one kind of caller - privileged
code core cannot mediate through its own primitives, because it is not core's
to mediate. The serial plugin bundling `serialport` and opening an OS device
file is the first: there is no `ctx.serial.open()` for core to gate, because
the whole point is that the plugin talks to the hardware directly. `has`
answers without an audit line, for deciding whether to offer something;
`require` throws `PluginCapabilityError` and audits like a guarded member. A
plugin reaching for this first checks whether its need is actually one of the
rows above - `device:serial` is not a general "trust me" capability, it is
specifically for code a dedicated ctx member cannot cover.
`ctx.ssh` was pulled forward from B so plugin transports go through core's
connect pipeline instead of importing ssh2 helpers from core:
@@ -1429,6 +1446,70 @@ it.
The lists live in `packages/plugin-sdk/cli/lib/externals.mjs`.
### Native dependencies
A package with a compiled `.node` binding (`serialport`, `better-sqlite3`,
`node-pty`) cannot be bundled the way an ordinary npm dependency is. esbuild
would inline its JS, but the binding is loaded through a path computed
relative to the original package's own directory, which breaks once that JS
moves into `dist/backend.js` somewhere else entirely. It also cannot go on
the host-provided list above: that list is for packages every plugin shares
one instance of, and a serial port binding is one plugin's business, not
core's.
**B11** (the serial plugin, the first bundled plugin with a native
dependency) resolved this by declaring the package in the manifest's
`nativeDependencies` array (`["serialport"]`) as well as a real dependency in
the plugin's own `package.json`. `termix-plugin build` adds every entry to
esbuild's `external` list for that plugin alone, so the compiled bundle keeps
a plain `import { SerialPort } from "serialport"` instead of inlining it, and
`termix-plugin validate` checks the name is actually declared in the
plugin's own `dependencies`.
At runtime that import is a bare specifier Node resolves by walking up from
wherever `dist/backend.js` is loaded, through `dist/plugins/serial/`,
`dist/plugins/`, `dist/`, to the repo (or image) root. **The three
deployments this repo builds all end up with the package there because of
how npm workspaces already install it**, not because of anything specific to
native code:
- **Dev and CI.** `npm install` at the repo root installs every workspace's
own dependencies, including a plugin's, and hoists them into the root
`node_modules` unless a version conflict forces one to stay nested. Nothing
about `serialport` is special here; it lands exactly where any of this
plugin's other dependencies would.
- **Docker.** The image's `npm ci` runs once at the repo root over the whole
workspace and produces one `node_modules`, which is copied into the final
image wholesale (`docker/Dockerfile`'s `production-deps` stage). Its native
binding compiles against the image's own `node:26-slim` toolchain
(`python3 make g++`, already installed for `better-sqlite3`), once per
target architecture under `docker buildx`, so linux x64 and arm64 each get
a binary actually built for that machine rather than a cross-compiled one.
- **Electron.** `electron-builder.json`'s `asarUnpack` already unpacks all of
`node_modules` (not a curated subset), and `npm run electron:rebuild` runs
`electron-rebuild` over the same hoisted `node_modules`, rebuilding
`@serialport/bindings-cpp` against Electron's ABI by name, exactly as it
already did for `better-sqlite3` and `node-pty`. Nothing in that script
changed: a package's native binding gets rebuilt because the script names
it, not because of which `package.json` declared the package as a
dependency.
None of this required a new packaging mechanism, which is the honest reason
it was chosen over shipping the plugin its own `node_modules`: this repo's
build already treats the whole workspace as one `node_modules`, so a
native dependency declared in a plugin's `package.json` rides along for
free. A **community plugin installed from a tarball has none of this**: there
is no repo-root `npm install` to hoist its dependency into, no shared
`node_modules` its bundle can walk up to. Shipping a native dependency in a
plugin nobody's build system already vendors is an open problem this step
does not solve - see `packages/plugin-sdk/FINISH-LIST.md`. A community plugin
author who hits this today has three honest options, worst to best: ask the
person installing it to `npm install` the native package into the server's
own `node_modules` by hand (fragile, easy to get wrong on upgrade); avoid a
native dependency and reach for a WASM build of the same functionality if one
exists; or wait for prebuild-per-platform packaging support, which is not
built yet.
---
## Converting a feature into a plugin
@@ -1797,11 +1878,12 @@ Known specifics:
an `optionalDependency` (added in A2; it previously declared nothing, and
the loader had no reason to order the two).
- Core feature servers that are not plugins yet still own ports: dashboard
30006, tmux 30010, serial 30011 and homepage 30012. Each keeps its nginx
block until its own Phase B step. No plugin owns a port any more (A4).
**B6** moved file-manager off port 30004 onto `/plugin-api/file-manager/`,
and **B7** moved tunnels off port 30003 onto `/plugin-api/tunnels/` and
`/plugin-ws/tunnels/c2s/stream`.
30006 and homepage 30012. Each keeps its nginx block until its own Phase B
step. No plugin owns a port any more (A4). **B6** moved file-manager off
port 30004 onto `/plugin-api/file-manager/`, **B7** moved tunnels off port
30003 onto `/plugin-api/tunnels/` and `/plugin-ws/tunnels/c2s/stream`,
**B10** moved tmux monitoring off port 30010, and **B11** moved serial off
port 30011 onto `/plugin-ws/serial/console`.
- **B9** moved the terminal, its session manager, the local terminal and the
command history panel into ssh-terminal, which imports nothing from core.
The file manager's terminal window renders the `terminal.view` component
+24
View File
@@ -247,6 +247,28 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
`app.registerAction` opening the singleton tab) but is a shared gap across
four plugins, not specific to this step. Owner: D1, or whoever revisits the
toolbar's quick-link row.
- **B11 (serial):** `plugins/serial/src/frontend/Serial.tsx` uses a small
hardcoded light/dark xterm palette and a generic monospace font stack
instead of the app's real terminal theme system
(`resolveTermixThemeColors`, `DEFAULT_TERMINAL_CONFIG`, `TERMINAL_FONTS`,
font loading), which is still core-only (`src/ui/features/terminal/`,
`src/ui/lib/terminal-themes.ts`) and not yet exposed through the SDK. The
serial console therefore does not match a user's chosen terminal theme or
font. Promoting that system into `@termix/plugin-sdk/ui` is really
ssh-terminal's own Phase B step's call to make (it is the plugin that
currently owns the terminal component itself, per the "Known specifics"
note above), so this line just points at the gap rather than building a
premature shared surface for one caller. Owner: ssh-terminal's own Phase B
step, or D1.
- **B11 (serial):** shipping a plugin's own native dependency (`serialport`)
works for this repo's three bundled deployments only because they all
install the whole npm workspace at once and copy/rebuild one shared
`node_modules` - see "Native dependencies" in ARCHITECTURE.md for the full
reasoning. A community plugin installed later from a tarball has no such
shared `node_modules` to land in, so it cannot ship a native dependency the
same way. No fix attempted here; the honest workarounds are listed in that
section. Owner: whoever designs the 3.0.0 install flow (prebuild-per-platform
packaging, or a documented "avoid native code" rule for community plugins).
## Manual checks after 2.9.0
@@ -254,3 +276,5 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
prompt), TOTP keyboard-interactive, a jump host, Warpgate, split view,
snippets sent to a terminal, reconnect after a network drop and after a
page reload (session reattach), and the local terminal in the desktop app.
- Serial: the Electron backend path against a real device path/COM port, and
the Web Serial browser picker in Chrome or Edge.
+6 -1
View File
@@ -52,7 +52,12 @@ export async function build({ cwd }) {
target: "node22",
sourcemap: true,
logLevel: "warning",
external: BACKEND_EXTERNALS,
// A native dependency's compiled .node binary is resolved by the
// package's own relative paths, which break once esbuild inlines its JS
// elsewhere. Declaring it in nativeDependencies keeps it a real
// node_modules import instead, resolved at runtime like a host-provided
// package.
external: [...BACKEND_EXTERNALS, ...(manifest.nativeDependencies ?? [])],
plugins: [legacyCoreImports({ pluginId, platform: "node" })],
});
@@ -37,6 +37,7 @@ export async function validate({ cwd }) {
}
problems.push(...validateMigrations(cwd, raw.id ?? path.basename(cwd)));
problems.push(...validateNativeDependencies(cwd, raw));
if (problems.length > 0) {
for (const problem of problems) console.error(` ${problem}`);
@@ -46,6 +47,33 @@ export async function validate({ cwd }) {
console.log(`ok ${raw.id ?? path.basename(cwd)}`);
}
/**
* A native dependency has to actually be a real dependency of the plugin, or
* the build's external declaration points at nothing node_modules can
* resolve at runtime.
*/
function validateNativeDependencies(cwd, raw) {
const problems = [];
const nativeDependencies = raw.nativeDependencies ?? [];
if (nativeDependencies.length === 0) return problems;
const pkgPath = path.join(cwd, "package.json");
const pkg = fs.existsSync(pkgPath)
? JSON.parse(fs.readFileSync(pkgPath, "utf8"))
: {};
const deps = { ...pkg.dependencies };
for (const name of nativeDependencies) {
if (!(name in deps)) {
problems.push(
`nativeDependencies names "${name}", which is not in this plugin's own package.json dependencies`,
);
}
}
return problems;
}
const DIALECTS = ["sqlite", "postgres", "mysql"];
const CREATE_OR_ALTER =
@@ -103,6 +103,7 @@
"users:write",
"auth:provide",
"system:tls",
"device:serial",
"hosts:write",
"credentials:use",
"network:outbound",
@@ -742,6 +743,15 @@
"type": "string",
"enum": ["linux", "win32", "darwin"]
}
},
"nativeDependencies": {
"type": "array",
"minItems": 1,
"description": "Bare npm package names this plugin depends on that carry a native (.node) binding. The build never bundles them: they stay a real dependency in the plugin's own package.json and are resolved from node_modules at runtime, the same as a host-provided package, so the compiled native binary is never copied into the JS bundle.",
"items": {
"type": "string",
"minLength": 1
}
}
},
"$defs": {
+17
View File
@@ -1111,6 +1111,21 @@ export interface PluginAudit {
}) => Promise<void>;
}
/**
* A generic capability check, for a privileged action that has no dedicated
* ctx method to wrap it: a plugin bundling its own native dependency to touch
* hardware (a serial port, a USB device) is the first caller. Core cannot
* mediate that access the way it mediates ctx.ssh or ctx.db, so the
* capability here is a declared, reviewable, audited statement of intent
* rather than a technical gate, matching credentials:read and process:spawn.
*/
export interface PluginCapabilities {
/** Whether this plugin currently holds `capability`. Not audited: for deciding whether to offer something, not for gating an action. */
has: (capability: string) => Promise<boolean>;
/** Throws PluginCapabilityError if this plugin does not hold `capability`. Audited like every guarded ctx method. */
require: (capability: string) => Promise<void>;
}
export interface PluginContext {
readonly pluginId: string;
readonly manifest: PluginManifest;
@@ -1127,6 +1142,8 @@ export interface PluginContext {
readonly rbac: PluginRbac;
readonly settings: PluginSettings;
readonly disposables: PluginDisposables;
/** A generic capability check, for privileged code no ctx method wraps. */
readonly capabilities: PluginCapabilities;
/** Hosts the actor can see, and host-sharing operations. Needs hosts:read / hosts:write. */
readonly hosts: PluginHosts;
/** SSH through core's connect pipeline. Needs ssh:connect and credentials:use. */
+1
View File
@@ -45,6 +45,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
entry("users:write", "high"),
entry("auth:provide", "high"),
entry("system:tls", "high"),
entry("device:serial", "high"),
entry("hosts:write", "medium"),
entry("credentials:use", "medium"),
+6
View File
@@ -232,6 +232,12 @@ export interface TabOptions {
singleton?: boolean;
/** A live session: asks before closing and can be refreshed. */
session?: boolean;
/**
* Whether a session tab offers "Share session". Default true. A plugin
* whose ref does not implement session sharing (a local device, not a
* remote connection another user could join) sets this false.
*/
shareable?: boolean;
/** Restorable without a host. */
hostless?: boolean;
/** Decides whether a saved tab for this host may be restored. */
+26
View File
@@ -298,6 +298,15 @@ export interface PluginManifest {
/** Locales directory, relative to the plugin root. */
locales?: string;
platforms?: string[];
/**
* Bare npm package names carrying a native (.node) binding. The CLI build
* never bundles them: they stay a real dependency in the plugin's own
* package.json and are resolved from node_modules at runtime instead,
* because esbuild bundling a native addon breaks the relative path it uses
* to locate its compiled binary. See ARCHITECTURE.md under "native
* dependencies".
*/
nativeDependencies?: string[];
}
export const DEFAULT_BACKEND_ENTRY = "dist/backend.js";
@@ -331,6 +340,7 @@ const ALLOWED_TOP_LEVEL = new Set([
"frontend",
"locales",
"platforms",
"nativeDependencies",
]);
const ALLOWED_CONTRIBUTES = new Set([
@@ -441,6 +451,7 @@ export function validateManifest(manifest: unknown): string[] {
validateCategory(m.category, errors);
validateCapabilities(m.capabilities, errors);
validatePlatforms(m.platforms, errors);
validateNativeDependencies(m.nativeDependencies, errors);
validateDependencyMap(m.dependencies, "dependencies", errors);
validateDependencyMap(m.optionalDependencies, "optionalDependencies", errors);
validateProvides(m.provides, errors);
@@ -535,6 +546,21 @@ function validatePlatforms(platforms: unknown, errors: string[]): void {
});
}
function validateNativeDependencies(value: unknown, errors: string[]): void {
if (value === undefined) return;
if (!Array.isArray(value) || value.length === 0) {
errors.push(
'Field "nativeDependencies" must be a non-empty array when present',
);
return;
}
value.forEach((entry, index) => {
if (typeof entry !== "string" || entry.length === 0) {
errors.push(`nativeDependencies[${index}] must be a non-empty string`);
}
});
}
function validateDependencyMap(
value: unknown,
field: string,
+14
View File
@@ -406,6 +406,13 @@ export function createFakeContext(
}) as PluginMiddleware,
},
// Nothing here is capability-checked, per the module doc; createMockCtx
// gates this against options.capabilities.
capabilities: {
has: async () => true,
require: async () => {},
},
settings: {
get: async (key) =>
settings.get(settingsKey("admin", undefined, key)) as never,
@@ -936,6 +943,13 @@ export function createMockCtx(
return ctx.desktop.openIsolatedWindow(request);
},
},
capabilities: {
has: async (capability) => granted.has(capability),
require: async (capability) => {
require(capability);
},
},
};
return { ...base, ctx: gatedCtx, checked };