fix: finish 2.9.0 plugin moves and get every check green

This commit is contained in:
LukeGus committed 2026-09-24 23:35:18 -05:00
1 parent 8c1e02af89
commit 6489b62ece
335 files changed
+29878 -17749

No files matched your search

@@ -1160,13 +1160,6 @@ function formatSSHHostOutput(
: [],
pin: !!host.pin,
authType: host.authType,
enableTerminal: !!host.enableTerminal,
enableTunnel: !!host.enableTunnel,
tunnelConnections: host.tunnelConnections
? JSON.parse(host.tunnelConnections as string)
: [],
enableFileManager: host.enableFileManager !== false,
defaultPath: host.defaultPath,
createdAt: host.createdAt,
updatedAt: host.updatedAt,
};
@@ -1,303 +0,0 @@
import { getErrorMessage } from "../../utils/error-message.js";
import type { Request, RequestHandler, Response, Router } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { sshLogger } from "../../utils/logger.js";
import { createCurrentHostRepository } from "../repositories/factory.js";
type HostAutostartRoutesDeps = {
authenticateJWT: RequestHandler;
requireViewPermission: RequestHandler;
requireEditPermission: RequestHandler;
requireDataAccess: RequestHandler;
};
export function registerHostAutostartRoutes(
router: Router,
{
authenticateJWT,
requireViewPermission,
requireEditPermission,
requireDataAccess,
}: HostAutostartRoutesDeps,
): void {
/**
* @openapi
* /host/autostart/enable:
* post:
* summary: Enable autostart for SSH configuration
* description: Enables autostart for a specific SSH configuration.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sshConfigId:
* type: number
* responses:
* 200:
* description: AutoStart enabled successfully.
* 400:
* description: Valid sshConfigId is required.
* 404:
* description: SSH configuration not found.
* 500:
* description: Internal server error.
*/
router.post(
"/autostart/enable",
authenticateJWT,
requireEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { sshConfigId } = req.body;
if (!sshConfigId || typeof sshConfigId !== "number") {
sshLogger.warn(
"Missing or invalid sshConfigId in autostart enable request",
{
operation: "autostart_enable",
userId,
sshConfigId,
},
);
return res.status(400).json({ error: "Valid sshConfigId is required" });
}
try {
const userDataKey = DataCrypto.getUserDataKey(userId);
if (!userDataKey) {
sshLogger.warn(
"User attempted to enable autostart without unlocked data",
{
operation: "autostart_enable_failed",
userId,
sshConfigId,
reason: "data_locked",
},
);
return res.status(400).json({
error: "Failed to enable autostart. Ensure user data is unlocked.",
});
}
const hostRepository = createCurrentHostRepository();
const config = await hostRepository.findByIdForUser(
userId,
sshConfigId,
);
if (!config) {
sshLogger.warn("SSH config not found for autostart enable", {
operation: "autostart_enable_failed",
userId,
sshConfigId,
reason: "config_not_found",
});
return res.status(404).json({
error: "SSH configuration not found",
});
}
const decryptedConfig = DataCrypto.decryptRecord(
"ssh_data",
config,
userId,
userDataKey,
);
let updatedTunnelConnections = config.tunnelConnections;
if (config.tunnelConnections) {
try {
const tunnelConnections = JSON.parse(config.tunnelConnections);
const endpointHosts = await hostRepository.listByUserId(userId);
const resolvedConnections = await Promise.all(
tunnelConnections.map(async (tunnel: Record<string, unknown>) => {
if (
tunnel.autoStart &&
tunnel.endpointHost &&
!tunnel.endpointPassword &&
!tunnel.endpointKey
) {
const endpointHost = endpointHosts.find(
(h) =>
h.name === tunnel.endpointHost ||
`${h.username}@${h.ip}` === tunnel.endpointHost,
);
if (endpointHost) {
const decryptedEndpoint = DataCrypto.decryptRecord(
"ssh_data",
endpointHost,
userId,
userDataKey,
);
return {
...tunnel,
endpointPassword: decryptedEndpoint.password || null,
endpointKey: decryptedEndpoint.key || null,
endpointKeyPassword:
decryptedEndpoint.keyPassword || null,
endpointAuthType: endpointHost.authType,
};
}
}
return tunnel;
}),
);
updatedTunnelConnections = JSON.stringify(resolvedConnections);
} catch (error) {
sshLogger.warn("Failed to update tunnel connections", {
operation: "tunnel_connections_update_failed",
error: getErrorMessage(error),
});
}
}
await hostRepository.updateForUser(userId, sshConfigId, {
autostartPassword: decryptedConfig.password || null,
autostartKey: decryptedConfig.key || null,
autostartKeyPassword: decryptedConfig.keyPassword || null,
tunnelConnections: updatedTunnelConnections,
});
res.json({
message: "AutoStart enabled successfully",
sshConfigId,
});
} catch (error) {
sshLogger.error("Error enabling autostart", error, {
operation: "autostart_enable_error",
userId,
sshConfigId,
});
res.status(500).json({ error: "Internal server error" });
}
},
);
/**
* @openapi
* /host/autostart/disable:
* delete:
* summary: Disable autostart for SSH configuration
* description: Disables autostart for a specific SSH configuration.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sshConfigId:
* type: number
* responses:
* 200:
* description: AutoStart disabled successfully.
* 400:
* description: Valid sshConfigId is required.
* 500:
* description: Internal server error.
*/
router.delete(
"/autostart/disable",
authenticateJWT,
requireEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { sshConfigId } = req.body;
if (!sshConfigId || typeof sshConfigId !== "number") {
sshLogger.warn(
"Missing or invalid sshConfigId in autostart disable request",
{
operation: "autostart_disable",
userId,
sshConfigId,
},
);
return res.status(400).json({ error: "Valid sshConfigId is required" });
}
try {
await createCurrentHostRepository().updateForUser(userId, sshConfigId, {
autostartPassword: null,
autostartKey: null,
autostartKeyPassword: null,
});
res.json({
message: "AutoStart disabled successfully",
sshConfigId,
});
} catch (error) {
sshLogger.error("Error disabling autostart", error, {
operation: "autostart_disable_error",
userId,
sshConfigId,
});
res.status(500).json({ error: "Internal server error" });
}
},
);
/**
* @openapi
* /host/autostart/status:
* get:
* summary: Get autostart status
* description: Retrieves the autostart status for the user's SSH configurations.
* tags:
* - SSH
* responses:
* 200:
* description: A list of autostart configurations.
* 500:
* description: Internal server error.
*/
router.get(
"/autostart/status",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const autostartConfigs = (
await createCurrentHostRepository().listByUserId(userId)
).filter((config) => config.autostartPassword || config.autostartKey);
const statusList = autostartConfigs.map((config) => ({
sshConfigId: config.id,
host: config.ip,
port: config.port,
username: config.username,
authType: config.authType,
}));
res.json({
autostart_configs: statusList,
total_count: statusList.length,
});
} catch (error) {
sshLogger.error("Error getting autostart status", error, {
operation: "autostart_status_error",
userId,
});
res.status(500).json({ error: "Internal server error" });
}
},
);
}
+46 -135
View File
@@ -10,10 +10,16 @@ import {
createCurrentCredentialRepository,
createCurrentHostRepository,
createCurrentHostResolutionRepository,
createCurrentPluginSettingsRepository,
} from "../repositories/factory.js";
import { validateParentHostId } from "./host-parent-validation.js";
import { applyPluginHostImportSettings } from "./host-plugin-settings.js";
import {
listSshAuthProviders,
listSshAuthTypeOwners,
} from "../../hosts/connect/auth-provider-registry.js";
import {
applyPluginHostImportSettings,
setHostPluginEnabled,
} from "./host-plugin-settings.js";
import {
isNonEmptyString,
isValidPort,
@@ -182,6 +188,15 @@ export function registerHostBulkRoutes(
* parentHostId:
* type: integer
* nullable: true
* statusCheckEnabled:
* type: boolean
* pin:
* type: boolean
* pluginEnable:
* type: object
* description: Plugin id to on or off, for each plugin that declares a host enable switch.
* additionalProperties:
* type: boolean
* responses:
* 200:
* description: Bulk update completed.
@@ -278,16 +293,6 @@ export function registerHostBulkRoutes(
simpleUpdates.folder = null;
}
}
if (typeof updates.enableTerminal === "boolean")
simpleUpdates.enableTerminal = updates.enableTerminal;
if (typeof updates.enableTunnel === "boolean")
simpleUpdates.enableTunnel = updates.enableTunnel;
if (typeof updates.enableFileManager === "boolean")
simpleUpdates.enableFileManager = updates.enableFileManager;
if (typeof updates.enableTmuxMonitor === "boolean")
simpleUpdates.enableTmuxMonitor = updates.enableTmuxMonitor;
if (typeof updates.enableTerminalToolbar === "boolean")
simpleUpdates.enableTerminalToolbar = updates.enableTerminalToolbar;
if (typeof updates.statusCheckEnabled === "boolean")
simpleUpdates.statusCheckEnabled = updates.statusCheckEnabled;
@@ -299,91 +304,15 @@ export function registerHostBulkRoutes(
);
}
// Proxmox enable/disable now lives in the proxmox plugin's own
// host-scope settings, not a hosts column. Disabling is a plain flag
// flip; enabling also defaults the Proxmox credential to the one
// already stored on the host, so discovery works without picking one
// by hand. Existing proxmoxConfig values are preserved.
if (typeof updates.enableProxmox === "boolean") {
const pluginSettingsRepository =
createCurrentPluginSettingsRepository();
for (const host of ownedHosts) {
try {
const scopeId = String(host.id);
await pluginSettingsRepository.set(
"proxmox",
"host",
scopeId,
"enableProxmox",
JSON.stringify(updates.enableProxmox),
);
if (updates.enableProxmox) {
const existingRow = await pluginSettingsRepository.get(
"proxmox",
"host",
scopeId,
"proxmoxConfig",
);
const existing = existingRow?.value
? JSON.parse(existingRow.value)
: {};
const merged = {
defaultCredentialId:
existing.defaultCredentialId ?? host.credentialId ?? null,
windowsPatterns: existing.windowsPatterns ?? "win, windows",
dockerPatterns: existing.dockerPatterns ?? "docker",
preferredPrefixes:
existing.preferredPrefixes ?? "10., 192.168.",
autoSyncEnabled: existing.autoSyncEnabled ?? false,
syncIntervalMinutes: existing.syncIntervalMinutes ?? 15,
markMissingGuests: existing.markMissingGuests ?? true,
};
await pluginSettingsRepository.set(
"proxmox",
"host",
scopeId,
"proxmoxConfig",
JSON.stringify(merged),
);
}
} catch {
errors.push(
`Failed to ${updates.enableProxmox ? "enable" : "disable"} Proxmox for host ${host.id}`,
);
}
}
}
// Web endpoint enable/disable lives in the plugin's own host-scope
// settings. Disabling clears webUiConfig, matching the old column
// write it replaces.
if (typeof updates.enableWebUi === "boolean") {
const pluginSettingsRepository =
createCurrentPluginSettingsRepository();
for (const host of ownedHosts) {
try {
const scopeId = String(host.id);
await pluginSettingsRepository.set(
"web-endpoint",
"host",
scopeId,
"enableWebUi",
JSON.stringify(updates.enableWebUi),
);
if (!updates.enableWebUi) {
await pluginSettingsRepository.set(
"web-endpoint",
"host",
scopeId,
"webUiConfig",
JSON.stringify(null),
);
}
} catch {
errors.push(
`Failed to ${updates.enableWebUi ? "enable" : "disable"} web endpoints for host ${host.id}`,
);
// Each plugin's own host switch, by plugin id: the field its manifest
// names in contributes.settings.host.enableKey.
if (updates.pluginEnable && typeof updates.pluginEnable === "object") {
for (const [pluginId, enabled] of Object.entries(
updates.pluginEnable as Record<string, unknown>,
)) {
if (typeof enabled !== "boolean") continue;
if (!(await setHostPluginEnabled(pluginId, ownedIds, enabled))) {
errors.push(`Plugin ${pluginId} has no host switch`);
}
}
}
@@ -605,6 +534,7 @@ export function registerHostBulkRoutes(
}
}
const knownAuthTypes = listKnownAuthTypes();
for (const { host: hostData, index: i, exportId } of orderedHosts) {
try {
const effectiveConnectionType = hostData.connectionType || "ssh";
@@ -644,20 +574,11 @@ export function registerHostBulkRoutes(
if (
effectiveConnectionType === "ssh" &&
hostData.authType &&
![
"password",
"key",
"credential",
"none",
"opkssh",
"stepca",
"tailscale",
"vault",
].includes(hostData.authType)
!knownAuthTypes.has(hostData.authType)
) {
results.failed++;
results.errors.push(
`Host ${i + 1}: Invalid authType. Must be 'password', 'key', 'credential', 'none', 'opkssh', 'stepca', 'tailscale', or 'vault'`,
`Host ${i + 1}: Invalid authType. Must be one of ${[...knownAuthTypes].join(", ")}`,
);
continue;
}
@@ -744,22 +665,7 @@ export function registerHostBulkRoutes(
port: hostData.port,
username,
pin: hostData.pin || false,
enableTerminal: hostData.enableTerminal !== false,
enableTunnel: hostData.enableTunnel !== false,
enableFileManager: hostData.enableFileManager !== false,
enableTmuxMonitor: hostData.enableTmuxMonitor || false,
enableTerminalToolbar: hostData.enableTerminalToolbar !== false,
enableCommandHistory: hostData.enableCommandHistory !== false,
showTerminalInSidebar: hostData.showTerminalInSidebar ? 1 : 0,
showFileManagerInSidebar: hostData.showFileManagerInSidebar ? 1 : 0,
showTunnelInSidebar: hostData.showTunnelInSidebar ? 1 : 0,
showDockerInSidebar: hostData.showDockerInSidebar ? 1 : 0,
showServerStatsInSidebar: hostData.showServerStatsInSidebar ? 1 : 0,
defaultPath: hostData.defaultPath || "/",
sudoPassword: hostData.sudoPassword || null,
tunnelConnections: hostData.tunnelConnections
? JSON.stringify(hostData.tunnelConnections)
: "[]",
jumpHosts: jumpHosts ? JSON.stringify(jumpHosts) : null,
quickActions: hostData.quickActions
? JSON.stringify(hostData.quickActions)
@@ -1004,19 +910,7 @@ export function registerHostBulkRoutes(
keyType: null,
credentialId: null,
pin: false,
enableTerminal: true,
enableTunnel: true,
enableFileManager: true,
enableTmuxMonitor: false,
enableTerminalToolbar: true,
showTerminalInSidebar: 0,
showFileManagerInSidebar: 0,
showTunnelInSidebar: 0,
showDockerInSidebar: 0,
showServerStatsInSidebar: 0,
defaultPath: "/",
sudoPassword: null,
tunnelConnections: "[]",
jumpHosts: hostData.jumpHosts
? JSON.stringify(hostData.jumpHosts)
: null,
@@ -1098,3 +992,20 @@ function importedStatusCheck(raw: Record<string, unknown>): {
: null,
};
}
const BUILTIN_SSH_AUTH_TYPES = [
"password",
"key",
"credential",
"agent",
"none",
];
/** Core's own types plus every type a plugin registers or declares. */
function listKnownAuthTypes(): Set<string> {
return new Set([
...BUILTIN_SSH_AUTH_TYPES,
...listSshAuthProviders().map((provider) => provider.type),
...listSshAuthTypeOwners().map((owner) => owner.type),
]);
}
@@ -9,7 +9,6 @@ export function applyHostEnrollmentDefaults(
connectionType: "ssh",
port: 22,
authType: "none",
enableTerminal: true,
enableSsh: true,
...hostData,
};
@@ -1,171 +0,0 @@
import type { Request, Response, Router } from "express";
import { SystemCrypto } from "../../utils/system-crypto.js";
import { sshLogger } from "../../utils/logger.js";
import { createCurrentHostResolutionRepository } from "../repositories/factory.js";
export function registerHostInternalRoutes(router: Router): void {
/**
* @openapi
* /host/db/host/internal:
* get:
* summary: Get internal SSH host data
* description: Returns internal SSH host data for autostart tunnels. Requires internal auth token.
* tags:
* - SSH
* responses:
* 200:
* description: A list of autostart hosts.
* 403:
* description: Forbidden.
* 500:
* description: Failed to fetch autostart SSH data.
*/
router.get("/db/host/internal", async (req: Request, res: Response) => {
try {
const internalToken = req.headers["x-internal-auth-token"];
const systemCrypto = SystemCrypto.getInstance();
const expectedToken = await systemCrypto.getInternalAuthToken();
if (internalToken !== expectedToken) {
sshLogger.warn(
"Unauthorized attempt to access internal SSH host endpoint",
{
source: req.ip,
userAgent: req.headers["user-agent"],
providedToken: internalToken ? "present" : "missing",
},
);
return res.status(403).json({ error: "Forbidden" });
}
} catch (error) {
sshLogger.error("Failed to validate internal auth token", error);
return res.status(500).json({ error: "Internal server error" });
}
try {
const autostartHosts =
await createCurrentHostResolutionRepository().listHostsWithTunnelConnections();
const result = autostartHosts
.map((host) => {
const tunnelConnections = host.tunnelConnections
? JSON.parse(host.tunnelConnections)
: [];
const hasAutoStartTunnels = tunnelConnections.some(
(tunnel: Record<string, unknown>) => tunnel.autoStart,
);
if (!hasAutoStartTunnels) {
return null;
}
return {
id: host.id,
userId: host.userId,
name: host.name || `autostart-${host.id}`,
ip: host.ip,
port: host.port,
username: host.username,
authType: host.authType,
keyType: host.keyType,
credentialId: host.credentialId,
enableTunnel: true,
tunnelConnections: tunnelConnections.filter(
(tunnel: Record<string, unknown>) => tunnel.autoStart,
),
pin: !!host.pin,
enableTerminal: !!host.enableTerminal,
enableFileManager: host.enableFileManager !== false,
showTerminalInSidebar: !!host.showTerminalInSidebar,
showFileManagerInSidebar: !!host.showFileManagerInSidebar,
showTunnelInSidebar: !!host.showTunnelInSidebar,
showDockerInSidebar: !!host.showDockerInSidebar,
showServerStatsInSidebar: !!host.showServerStatsInSidebar,
tags: ["autostart"],
};
})
.filter(Boolean);
res.json(result);
} catch (err) {
sshLogger.error("Failed to fetch autostart SSH data", err);
res.status(500).json({ error: "Failed to fetch autostart SSH data" });
}
});
/**
* @openapi
* /host/db/host/internal/all:
* get:
* summary: Get all internal SSH host data
* description: Returns all internal SSH host data. Requires internal auth token.
* tags:
* - SSH
* responses:
* 200:
* description: A list of all hosts.
* 401:
* description: Invalid or missing internal authentication token.
* 500:
* description: Failed to fetch all hosts.
*/
router.get("/db/host/internal/all", async (req: Request, res: Response) => {
try {
const internalToken = req.headers["x-internal-auth-token"];
if (!internalToken) {
return res
.status(401)
.json({ error: "Internal authentication token required" });
}
const systemCrypto = SystemCrypto.getInstance();
const expectedToken = await systemCrypto.getInternalAuthToken();
if (internalToken !== expectedToken) {
return res
.status(401)
.json({ error: "Invalid internal authentication token" });
}
const allHosts =
await createCurrentHostResolutionRepository().listAllHosts();
const result = allHosts.map((host) => {
const tunnelConnections = host.tunnelConnections
? JSON.parse(host.tunnelConnections)
: [];
return {
id: host.id,
userId: host.userId,
name: host.name || `${host.username}@${host.ip}`,
ip: host.ip,
port: host.port,
username: host.username,
authType: host.authType,
keyType: host.keyType,
credentialId: host.credentialId,
enableTunnel: !!host.enableTunnel,
tunnelConnections: tunnelConnections,
pin: !!host.pin,
enableTerminal: !!host.enableTerminal,
enableFileManager: host.enableFileManager !== false,
showTerminalInSidebar: !!host.showTerminalInSidebar,
showFileManagerInSidebar: !!host.showFileManagerInSidebar,
showTunnelInSidebar: !!host.showTunnelInSidebar,
showDockerInSidebar: !!host.showDockerInSidebar,
showServerStatsInSidebar: !!host.showServerStatsInSidebar,
defaultPath: host.defaultPath,
createdAt: host.createdAt,
updatedAt: host.updatedAt,
};
});
res.json(result);
} catch (err) {
sshLogger.error("Failed to fetch all hosts for internal use", err);
res.status(500).json({ error: "Failed to fetch all hosts" });
}
});
}
@@ -170,30 +170,11 @@ export type NormalizedImportedHost = Record<string, unknown> & {
credentialId?: number;
credentialAlias?: string;
pin?: unknown;
enableTerminal?: unknown;
enableTunnel?: unknown;
enableFileManager?: unknown;
enableWebUi?: unknown;
enableProxmox?: unknown;
enableTmuxMonitor?: unknown;
enableTerminalToolbar?: unknown;
enableCommandHistory?: unknown;
showTerminalInSidebar?: unknown;
showFileManagerInSidebar?: unknown;
showTunnelInSidebar?: unknown;
showDockerInSidebar?: unknown;
showServerStatsInSidebar?: unknown;
defaultPath?: unknown;
sudoPassword?: unknown;
tunnelConnections?: unknown;
jumpHosts?: unknown;
quickActions?: unknown;
statusCheckEnabled?: unknown;
statusCheckInterval?: unknown;
webUiConfig?: unknown;
proxmoxConfig?: unknown;
enableProxmoxStats?: unknown;
proxmoxStatsConfig?: unknown;
terminalConfig?: unknown;
forceKeyboardInteractive?: unknown;
notes?: unknown;
@@ -333,24 +314,11 @@ const CONNECT_LEVEL_FIELDS = new Set([
"shareSshAuth",
"authOverrides",
"connectionType",
"enableTerminal",
"enableTunnel",
"enableFileManager",
"enableTmuxMonitor",
"enableTerminalToolbar",
"showTerminalInSidebar",
"showFileManagerInSidebar",
"showTunnelInSidebar",
"showDockerInSidebar",
"showServerStatsInSidebar",
"statusCheckEnabled",
"statusCheckInterval",
"enableSsh",
"sshPort",
"rdpAuthType",
"defaultPath",
"scpLegacy",
"tunnelConnections",
"jumpHosts",
"createdAt",
"updatedAt",
@@ -428,25 +396,12 @@ export function transformHostResponse(
: [],
pin: !!host.pin,
shareSshAuth: !!host.shareSshAuth,
enableTerminal: !!host.enableTerminal,
enableTunnel: !!host.enableTunnel,
enableFileManager: host.enableFileManager !== false,
enableTmuxMonitor: !!host.enableTmuxMonitor,
enableTerminalToolbar: host.enableTerminalToolbar !== false,
showTerminalInSidebar: !!host.showTerminalInSidebar,
showFileManagerInSidebar: !!host.showFileManagerInSidebar,
showTunnelInSidebar: !!host.showTunnelInSidebar,
showDockerInSidebar: !!host.showDockerInSidebar,
showServerStatsInSidebar: !!host.showServerStatsInSidebar,
enableSsh: !!host.enableSsh,
sshPort: host.sshPort ?? host.port ?? 22,
rdpUser: host.rdpUser || undefined,
rdpDomain: host.rdpDomain || undefined,
vncUser: host.vncUser || undefined,
telnetUser: host.telnetUser || undefined,
tunnelConnections: host.tunnelConnections
? JSON.parse(host.tunnelConnections as string)
: [],
jumpHosts: host.jumpHosts ? JSON.parse(host.jumpHosts as string) : [],
quickActions: host.quickActions
? JSON.parse(host.quickActions as string)
@@ -13,13 +13,34 @@
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import { createCurrentPluginSettingsRepository } from "../repositories/factory.js";
import type { PluginSettingsRecord } from "../repositories/plugin-settings-repository.js";
import { declaredFields, resolveFieldValue } from "../../plugins/settings.js";
import {
declaredFields,
resolveFieldValue,
setSetting,
} from "../../plugins/settings.js";
import { getPluginRuntime } from "../../plugins/index.js";
import { consume } from "../../plugins/registry.js";
import { sshLogger } from "../../utils/logger.js";
export type HostPluginSettings = Record<string, Record<string, unknown>>;
/**
* Marks a host changed after one of its plugin settings did, so remote sync,
* which pulls by updated_at, carries the new value across.
*/
export async function touchHost(hostId: number): Promise<void> {
try {
const { sql } = await import("drizzle-orm");
const { runStatement } =
await import("../../utils/crypto-migration/raw-rows.js");
await runStatement(
sql`UPDATE ssh_data SET updated_at = ${new Date().toISOString()} WHERE id = ${hostId}`,
);
} catch {
// Only a sync hint; the setting itself is already saved.
}
}
/** Enabled plugins that declare host-scope settings, with their manifests. */
export function hostSettingsPlugins(): PluginManifest[] {
try {
@@ -105,6 +126,71 @@ export async function loadHostPluginSettings(
return result;
}
const SHARE_LEVELS = ["connect", "view", "edit", "manage"];
/**
* Drops the fields a shared host's recipient may not read: a field declares
* the lowest share level that sees it with shareRead. The owner sees all.
*/
export function visibleHostPluginSettings(
values: HostPluginSettings,
host: Record<string, unknown>,
): HostPluginSettings {
if (!host.isShared) return values;
const level = SHARE_LEVELS.indexOf(
typeof host.permissionLevel === "string" ? host.permissionLevel : "connect",
);
const result: HostPluginSettings = {};
for (const manifest of hostSettingsPlugins()) {
const own = values[manifest.id];
if (!own) continue;
const copy = { ...own };
for (const field of declaredFields(manifest, "host")) {
if (
field.shareRead &&
SHARE_LEVELS.indexOf(field.shareRead) > Math.max(level, 0)
) {
delete copy[field.key];
}
}
result[manifest.id] = copy;
}
return result;
}
/**
* Fields a plugin still puts on the host payload in their pre-2.9 shape, for
* clients outside Termix (Termix-Mobile) that have not moved to
* pluginSettings yet. Registered as ctx.registry.provide(
* "<id>.hostPayloadLegacy", fn); the function gets the plugin's own host
* values and the host, and never overwrites a field core already set.
*/
export type PluginHostPayloadLegacy = (
values: Record<string, unknown>,
host: Record<string, unknown>,
) => Record<string, unknown> | null;
function applyLegacyFields(
host: Record<string, unknown>,
values: HostPluginSettings,
): void {
for (const manifest of hostSettingsPlugins()) {
const own = values[manifest.id];
if (!own) continue;
const legacy = consume<PluginHostPayloadLegacy>(
`${manifest.id}.hostPayloadLegacy`,
);
if (!legacy) continue;
try {
for (const [key, value] of Object.entries(legacy(own, host) ?? {})) {
if (!(key in host)) host[key] = value;
}
} catch {
// A plugin's compat shape must never break a host read.
}
}
}
/** Attaches the map to already-transformed host objects, in place. */
export function attachHostPluginSettings(
hosts: Record<string, unknown>[],
@@ -114,7 +200,9 @@ export function attachHostPluginSettings(
for (const host of hosts) {
const hostId = Number(host.id);
const values = settings.get(hostId);
if (values) host.pluginSettings = values;
if (!values) continue;
host.pluginSettings = visibleHostPluginSettings(values, host);
applyLegacyFields(host, host.pluginSettings as HostPluginSettings);
}
}
@@ -127,7 +215,13 @@ export async function withHostPluginSettings(
const settings = await loadHostPluginSettings([hostId]);
const values = settings.get(hostId);
return values ? { ...host, pluginSettings: values } : host;
if (!values) return host;
const result: Record<string, unknown> = {
...host,
pluginSettings: visibleHostPluginSettings(values, host),
};
applyLegacyFields(result, result.pluginSettings as HostPluginSettings);
return result;
}
/**
@@ -175,13 +269,33 @@ export async function applyPluginHostImportSettings(
hostId: number,
raw: Record<string, unknown>,
): Promise<void> {
const exported =
raw.pluginSettings && typeof raw.pluginSettings === "object"
? (raw.pluginSettings as HostPluginSettings)
: {};
for (const manifest of hostSettingsPlugins()) {
const normalizer = consume<PluginHostImportNormalizer>(
`${manifest.id}.hostImportNormalizer`,
);
if (!normalizer) continue;
try {
const values = normalizer(raw);
// What an export carried for this plugin, validated like any other
// write. Secrets never leave the server, so there are none to restore.
const carried = exported[manifest.id];
if (carried && typeof carried === "object") {
for (const field of declaredFields(manifest, "host")) {
if (field.type === "secret" || !(field.key in carried)) continue;
await setSetting(
manifest,
"host",
hostId,
field.key,
carried[field.key],
);
}
}
const normalizer = consume<PluginHostImportNormalizer>(
`${manifest.id}.hostImportNormalizer`,
);
const values = normalizer?.(raw);
if (values) await writeHostPluginSettings(manifest.id, hostId, values);
} catch (error) {
sshLogger.warn("Plugin host import normalizer failed", {
@@ -193,3 +307,22 @@ export async function applyPluginHostImportSettings(
}
}
}
/**
* Turns one plugin's host switch on or off for a set of hosts. Returns false
* when the plugin is not running or declares no enableKey.
*/
export async function setHostPluginEnabled(
pluginId: string,
hostIds: number[],
enabled: boolean,
): Promise<boolean> {
const manifest = hostSettingsPlugins().find((m) => m.id === pluginId);
const enableKey = manifest?.contributes?.settings?.host?.enableKey;
if (!manifest || !enableKey) return false;
for (const hostId of hostIds) {
await setSetting(manifest, "host", hostId, enableKey, enabled);
await touchHost(hostId);
}
return true;
}
-217
View File
@@ -41,14 +41,11 @@ import {
import {
attachHostPluginSettings,
loadHostPluginSettings,
writeHostPluginSettings,
withHostPluginSettings,
} from "./host-plugin-settings.js";
import { validateParentHostId } from "./host-parent-validation.js";
import { registerHostAuthCompatRoutes } from "./host-compat-routes.js";
import { registerHostFolderRoutes } from "./host-folder-routes.js";
import { registerHostAutostartRoutes } from "./host-autostart-routes.js";
import { registerHostInternalRoutes } from "./host-internal-routes.js";
import { registerHostNetworkRoutes } from "./host-network-routes.js";
import { registerHostBulkRoutes } from "./host-bulk-routes.js";
import { registerHostStatusRoutes } from "./host-status-routes.js";
@@ -104,8 +101,6 @@ const permissionManager = PermissionManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware();
registerHostInternalRoutes(router);
registerHostStatusRoutes(router, {
authenticateJWT,
requireAdmin: authManager.createAdminMiddleware(),
@@ -188,28 +183,10 @@ router.post(
keyType,
sudoPassword,
pin,
enableTerminal,
enableCommandHistory,
enableTunnel,
enableFileManager,
scpLegacy,
enableProxmox,
enableTmuxMonitor,
enableTerminalToolbar,
showTerminalInSidebar,
showFileManagerInSidebar,
showTunnelInSidebar,
showDockerInSidebar,
showServerStatsInSidebar,
defaultPath,
tunnelConnections,
jumpHosts,
quickActions,
statusCheckEnabled,
statusCheckInterval,
proxmoxConfig,
enableProxmoxStats,
proxmoxStatsConfig,
terminalConfig,
forceKeyboardInteractive,
domain,
@@ -306,26 +283,10 @@ router.post(
credentialId: credentialId || null,
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
pin: pin ? 1 : 0,
enableTerminal: enableTerminal ? 1 : 0,
enableCommandHistory: enableCommandHistory ? 1 : 0,
enableTunnel: enableTunnel ? 1 : 0,
tunnelConnections: Array.isArray(tunnelConnections)
? JSON.stringify(tunnelConnections)
: null,
jumpHosts: Array.isArray(jumpHosts) ? JSON.stringify(jumpHosts) : null,
quickActions: Array.isArray(quickActions)
? JSON.stringify(quickActions)
: null,
enableFileManager: enableFileManager ? 1 : 0,
scpLegacy: scpLegacy ? 1 : 0,
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
enableTerminalToolbar: enableTerminalToolbar === false ? 0 : 1,
showTerminalInSidebar: showTerminalInSidebar ? 1 : 0,
showFileManagerInSidebar: showFileManagerInSidebar ? 1 : 0,
showTunnelInSidebar: showTunnelInSidebar ? 1 : 0,
showDockerInSidebar: showDockerInSidebar ? 1 : 0,
showServerStatsInSidebar: showServerStatsInSidebar ? 1 : 0,
defaultPath: defaultPath || null,
statusCheckEnabled: statusCheckEnabled === false ? 0 : 1,
statusCheckInterval: normalizeStatusInterval(statusCheckInterval),
terminalConfig: terminalConfig
@@ -451,36 +412,6 @@ router.post(
const createdHost = result;
if (
enableProxmox !== undefined ||
proxmoxConfig !== undefined ||
enableProxmoxStats !== undefined ||
proxmoxStatsConfig !== undefined
) {
try {
await writeHostPluginSettings("proxmox", createdHost.id as number, {
enableProxmox: !!enableProxmox,
proxmoxConfig: proxmoxConfig
? typeof proxmoxConfig === "string"
? safeParseJson(proxmoxConfig)
: proxmoxConfig
: null,
enableProxmoxStats: !!enableProxmoxStats,
proxmoxStatsConfig: proxmoxStatsConfig
? typeof proxmoxStatsConfig === "string"
? safeParseJson(proxmoxStatsConfig)
: proxmoxStatsConfig
: null,
});
} catch (proxmoxSettingsError) {
sshLogger.warn("Failed to save Proxmox host settings", {
operation: "host_create_proxmox_settings",
hostId: createdHost.id,
error: getErrorMessage(proxmoxSettingsError),
});
}
}
// Standing folder shares apply to the newcomer.
try {
await applyFolderAccessRules(
@@ -582,12 +513,6 @@ router.post(
* - type: array
* items:
* type: string
* enableTerminal:
* type: boolean
* enableFileManager:
* type: boolean
* enableTunnel:
* type: boolean
* responses:
* 200:
* description: Host enrolled successfully.
@@ -735,19 +660,6 @@ router.post(
key: resolvedKey,
keyPassword: resolvedKeyPassword,
keyType: resolvedKeyType,
enableTerminal: true,
enableTunnel: false,
enableFileManager: true,
enableWebUi: false,
enableTmuxMonitor: false,
enableTerminalToolbar: true,
showTerminalInSidebar: true,
showFileManagerInSidebar: false,
showTunnelInSidebar: false,
showDockerInSidebar: false,
showServerStatsInSidebar: false,
defaultPath: "/",
tunnelConnections: [],
jumpHosts: [],
quickActions: [],
statusCheckEnabled: true,
@@ -860,28 +772,10 @@ router.put(
keyType,
sudoPassword,
pin,
enableTerminal,
enableCommandHistory,
enableTunnel,
enableFileManager,
scpLegacy,
enableProxmox,
enableTmuxMonitor,
enableTerminalToolbar,
showTerminalInSidebar,
showFileManagerInSidebar,
showTunnelInSidebar,
showDockerInSidebar,
showServerStatsInSidebar,
defaultPath,
tunnelConnections,
jumpHosts,
quickActions,
statusCheckEnabled,
statusCheckInterval,
proxmoxConfig,
enableProxmoxStats,
proxmoxStatsConfig,
terminalConfig,
forceKeyboardInteractive,
domain,
@@ -979,26 +873,10 @@ router.put(
credentialId: credentialId || null,
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
pin: pin ? 1 : 0,
enableTerminal: enableTerminal ? 1 : 0,
enableCommandHistory: enableCommandHistory ? 1 : 0,
enableTunnel: enableTunnel ? 1 : 0,
tunnelConnections: Array.isArray(tunnelConnections)
? JSON.stringify(tunnelConnections)
: null,
jumpHosts: Array.isArray(jumpHosts) ? JSON.stringify(jumpHosts) : null,
quickActions: Array.isArray(quickActions)
? JSON.stringify(quickActions)
: null,
enableFileManager: enableFileManager ? 1 : 0,
scpLegacy: scpLegacy ? 1 : 0,
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
enableTerminalToolbar: enableTerminalToolbar === false ? 0 : 1,
showTerminalInSidebar: showTerminalInSidebar ? 1 : 0,
showFileManagerInSidebar: showFileManagerInSidebar ? 1 : 0,
showTunnelInSidebar: showTunnelInSidebar ? 1 : 0,
showDockerInSidebar: showDockerInSidebar ? 1 : 0,
showServerStatsInSidebar: showServerStatsInSidebar ? 1 : 0,
defaultPath: defaultPath || null,
statusCheckEnabled: statusCheckEnabled === false ? 0 : 1,
statusCheckInterval: normalizeStatusInterval(statusCheckInterval),
terminalConfig: terminalConfig
@@ -1300,36 +1178,6 @@ router.put(
sshDataObj,
);
if (
enableProxmox !== undefined ||
proxmoxConfig !== undefined ||
enableProxmoxStats !== undefined ||
proxmoxStatsConfig !== undefined
) {
try {
await writeHostPluginSettings("proxmox", Number(hostId), {
enableProxmox: !!enableProxmox,
proxmoxConfig: proxmoxConfig
? typeof proxmoxConfig === "string"
? safeParseJson(proxmoxConfig)
: proxmoxConfig
: null,
enableProxmoxStats: !!enableProxmoxStats,
proxmoxStatsConfig: proxmoxStatsConfig
? typeof proxmoxStatsConfig === "string"
? safeParseJson(proxmoxStatsConfig)
: proxmoxStatsConfig
: null,
});
} catch (proxmoxSettingsError) {
sshLogger.warn("Failed to save Proxmox host settings", {
operation: "host_update_proxmox_settings",
hostId: parseInt(hostId),
error: getErrorMessage(proxmoxSettingsError),
});
}
}
// A host that moved into a folder inherits that folder's standing shares.
try {
await applyFolderAccessRules(
@@ -1987,16 +1835,6 @@ router.get(
const hostPluginSettings = (
await loadHostPluginSettings([Number(hostId)])
).get(Number(hostId));
const proxmoxSettings = hostPluginSettings?.proxmox as
| {
enableProxmox?: boolean;
proxmoxConfig?: unknown;
enableProxmoxStats?: boolean;
proxmoxStatsConfig?: unknown;
}
| undefined;
const webEndpointSettings = hostPluginSettings?.["web-endpoint"] as
{ enableWebUi?: boolean; webUiConfig?: unknown } | undefined;
const exportedConnectionType =
(resolvedHost.connectionType as string) || "ssh";
@@ -2047,35 +1885,13 @@ router.get(
credentialId: resolvedHost.credentialId || null,
overrideCredentialUsername:
!!resolvedHost.overrideCredentialUsername,
enableTerminal: !!resolvedHost.enableTerminal,
enableCommandHistory: resolvedHost.enableCommandHistory !== false,
enableTunnel: !!resolvedHost.enableTunnel,
enableFileManager: resolvedHost.enableFileManager !== false,
scpLegacy: !!resolvedHost.scpLegacy,
enableWebUi: !!webEndpointSettings?.enableWebUi,
enableProxmox: !!proxmoxSettings?.enableProxmox,
enableProxmoxStats: !!proxmoxSettings?.enableProxmoxStats,
enableTmuxMonitor: !!resolvedHost.enableTmuxMonitor,
enableTerminalToolbar: resolvedHost.enableTerminalToolbar !== false,
showTerminalInSidebar: !!resolvedHost.showTerminalInSidebar,
showFileManagerInSidebar: !!resolvedHost.showFileManagerInSidebar,
showTunnelInSidebar: !!resolvedHost.showTunnelInSidebar,
showDockerInSidebar: !!resolvedHost.showDockerInSidebar,
showServerStatsInSidebar: !!resolvedHost.showServerStatsInSidebar,
defaultPath: resolvedHost.defaultPath,
sudoPassword: resolvedHost.sudoPassword || null,
tunnelConnections: resolvedHost.tunnelConnections
? JSON.parse(resolvedHost.tunnelConnections as string)
: [],
jumpHosts: resolvedHost.jumpHosts
? JSON.parse(resolvedHost.jumpHosts as string)
: null,
quickActions: resolvedHost.quickActions
? JSON.parse(resolvedHost.quickActions as string)
: null,
webUiConfig: webEndpointSettings?.webUiConfig ?? { endpoints: [] },
proxmoxConfig: proxmoxSettings?.proxmoxConfig ?? null,
proxmoxStatsConfig: proxmoxSettings?.proxmoxStatsConfig ?? null,
terminalConfig: resolvedHost.terminalConfig
? JSON.parse(resolvedHost.terminalConfig as string)
: null,
@@ -2162,10 +1978,6 @@ router.get(
? host
: (await resolveHostCredentials(host, userId)) || host;
const hostPluginSettings = pluginSettingsByHost.get(host.id as number);
const proxmoxSettings = hostPluginSettings?.proxmox as
{ enableProxmox?: boolean; proxmoxConfig?: unknown } | undefined;
const webEndpointSettings = hostPluginSettings?.["web-endpoint"] as
{ enableWebUi?: boolean; webUiConfig?: unknown } | undefined;
const exportedConnectionType =
(resolvedHost.connectionType as string) || "ssh";
@@ -2204,37 +2016,15 @@ router.get(
credentialId: resolvedHost.credentialId || null,
overrideCredentialUsername:
!!resolvedHost.overrideCredentialUsername,
enableTerminal: !!resolvedHost.enableTerminal,
enableCommandHistory: resolvedHost.enableCommandHistory !== false,
enableTunnel: !!resolvedHost.enableTunnel,
enableFileManager: resolvedHost.enableFileManager !== false,
enableWebUi: !!webEndpointSettings?.enableWebUi,
enableProxmox: !!proxmoxSettings?.enableProxmox,
enableTmuxMonitor: !!resolvedHost.enableTmuxMonitor,
enableTerminalToolbar:
resolvedHost.enableTerminalToolbar !== false,
showTerminalInSidebar: !!resolvedHost.showTerminalInSidebar,
showFileManagerInSidebar: !!resolvedHost.showFileManagerInSidebar,
showTunnelInSidebar: !!resolvedHost.showTunnelInSidebar,
showDockerInSidebar: !!resolvedHost.showDockerInSidebar,
showServerStatsInSidebar: !!resolvedHost.showServerStatsInSidebar,
defaultPath: resolvedHost.defaultPath,
sudoPassword: shareMode
? null
: resolvedHost.sudoPassword || null,
tunnelConnections: resolvedHost.tunnelConnections
? JSON.parse(resolvedHost.tunnelConnections as string)
: [],
jumpHosts: resolvedHost.jumpHosts
? JSON.parse(resolvedHost.jumpHosts as string)
: null,
quickActions: resolvedHost.quickActions
? JSON.parse(resolvedHost.quickActions as string)
: null,
webUiConfig: webEndpointSettings?.webUiConfig ?? {
endpoints: [],
},
proxmoxConfig: proxmoxSettings?.proxmoxConfig ?? null,
terminalConfig: resolvedHost.terminalConfig
? JSON.parse(resolvedHost.terminalConfig as string)
: null,
@@ -2646,13 +2436,6 @@ registerHostBulkRoutes(
requireDataAccess,
);
registerHostAutostartRoutes(router, {
authenticateJWT,
requireViewPermission: permissionManager.requirePermission("hosts.view"),
requireEditPermission: permissionManager.requirePermission("hosts.edit"),
requireDataAccess,
});
registerHostAuthCompatRoutes(router);
registerHostNetworkRoutes(router, {
@@ -3,8 +3,7 @@ import type { AuthenticatedRequest } from "../../../types/index.js";
import { createCurrentNotificationChannelRepository } from "../repositories/factory.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { databaseLogger } from "../../utils/logger.js";
import { sendWebhook, sendNtfy } from "../../utils/notification-sender.js";
import { sendDiscord } from "../../utils/discord-sender.js";
import { deliverNotification } from "../../utils/notification-sender.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
@@ -224,43 +223,15 @@ router.post(
);
if (!row) return res.status(404).json({ error: "Channel not found" });
const testPayload = {
hostName: "Test Host",
hostId: 0,
triggerType: "test",
message: "This is a test notification from Termix",
severity: "info" as const,
timestamp: new Date().toISOString(),
ruleId: 0,
ruleName: "Test",
};
try {
let config: Record<string, unknown>;
try {
config = JSON.parse(row.config) as Record<string, unknown>;
} catch {
return res
.status(400)
.json({ success: false, error: "Invalid channel config" });
}
if (row.type === "webhook") {
await sendWebhook(
config as unknown as Parameters<typeof sendWebhook>[0],
testPayload,
);
} else if (row.type === "ntfy") {
await sendNtfy(
config as unknown as Parameters<typeof sendNtfy>[0],
testPayload,
);
} else if (row.type === "discord") {
await sendDiscord(
config as unknown as Parameters<typeof sendDiscord>[0],
testPayload,
);
}
// The same path an automation's notify step takes, so a test proves
// the channel works for real, private-network opt-in included.
await deliverNotification(row, {
title: "Termix",
body: "This is a test notification from Termix",
severity: "info",
context: { sourceName: "Test", triggerType: "test" },
});
res.json({ success: true });
} catch (err) {
res.json({
@@ -78,4 +78,32 @@ export function mountPluginApi(app: express.Express): void {
app.use("/plugin-api", router);
}
/**
* Serves the old URLs plugins declare in contributes.http.legacyPaths, each
* under "/<plugin id>/", through that plugin's router with the prefix
* removed. Mounted after every core route, so core wins a clash.
*/
export function mountPluginLegacyPaths(
app: express.Express,
listActive: () => Array<{ id: string; legacyPaths: string[] }>,
): void {
app.use((req: Request, res: Response, next) => {
for (const plugin of listActive()) {
const matched = plugin.legacyPaths.some(
(path) => req.path === path || req.path.startsWith(`${path}/`),
);
if (!matched) continue;
const pluginRouter = getPluginRouter(plugin.id);
if (!pluginRouter) {
res.status(404).json({ error: "Plugin not installed or not running" });
return;
}
req.url = req.url.slice(plugin.id.length + 1) || "/";
pluginRouter(req, res, next);
return;
}
next();
});
}
export default router;
+105 -48
View File
@@ -14,11 +14,13 @@ import {
} from "../repositories/factory.js";
import { getPluginRuntime } from "../../plugins/index.js";
import { describePluginFrontend } from "../../plugins/assets.js";
import { getSetting } from "../../plugins/settings.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import {
findField,
getAllSettings,
setSetting,
validateSettingsSave,
type PluginSettingsScope,
} from "../../plugins/settings.js";
import {
@@ -231,57 +233,59 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
);
}
const plugins = records.map((record) => {
const loaded = live.get(record.id);
const plugins = await Promise.all(
records.map(async (record) => {
const loaded = live.get(record.id);
let contributes: unknown = null;
let capabilities: string[] = [];
let icon: string | undefined;
let dependencies: Record<string, string> = {};
let optionalDependencies: Record<string, string> = {};
try {
const manifest = JSON.parse(record.manifestJson) as {
contributes?: unknown;
capabilities?: unknown;
icon?: unknown;
dependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
let contributes: unknown = null;
let capabilities: string[] = [];
let icon: string | undefined;
let dependencies: Record<string, string> = {};
let optionalDependencies: Record<string, string> = {};
try {
const manifest = JSON.parse(record.manifestJson) as {
contributes?: unknown;
capabilities?: unknown;
icon?: unknown;
dependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
};
contributes = await withHostFieldDefaults(manifest as PluginManifest);
capabilities = Array.isArray(manifest?.capabilities)
? (manifest.capabilities as string[])
: [];
icon = typeof manifest?.icon === "string" ? manifest.icon : undefined;
dependencies = manifest?.dependencies ?? {};
optionalDependencies = manifest?.optionalDependencies ?? {};
} catch {
contributes = null;
}
const summary = {
id: record.id,
name: record.name,
version: record.version,
enabled: record.state === "enabled",
state: loaded?.state ?? record.state,
contributes,
icon,
dependencies,
optionalDependencies,
...describePluginFrontend(loaded),
};
contributes = manifest?.contributes ?? null;
capabilities = Array.isArray(manifest?.capabilities)
? (manifest.capabilities as string[])
: [];
icon = typeof manifest?.icon === "string" ? manifest.icon : undefined;
dependencies = manifest?.dependencies ?? {};
optionalDependencies = manifest?.optionalDependencies ?? {};
} catch {
contributes = null;
}
const summary = {
id: record.id,
name: record.name,
version: record.version,
enabled: record.state === "enabled",
state: loaded?.state ?? record.state,
contributes,
icon,
dependencies,
optionalDependencies,
...describePluginFrontend(loaded),
};
if (!canManage) return summary;
if (!canManage) return summary;
return {
...summary,
tier: record.tier,
source: record.source,
capabilities,
grantedCapabilities: grantsByPlugin.get(record.id) ?? [],
lastError: loaded?.lastError ?? record.lastError ?? null,
};
});
return {
...summary,
tier: record.tier,
source: record.source,
capabilities,
grantedCapabilities: grantsByPlugin.get(record.id) ?? [],
lastError: loaded?.lastError ?? record.lastError ?? null,
};
}),
);
res.json(plugins);
} catch (error) {
@@ -762,10 +766,11 @@ async function applySettings(
scope: PluginSettingsScope,
scopeId: string | null,
body: Record<string, unknown>,
options: { isHostOwner?: boolean } = {},
): Promise<Record<string, string>> {
const errors: Record<string, string> = {};
for (const [key, value] of Object.entries(body)) {
for (const [key] of Object.entries(body)) {
const field = findField(manifest, scope, key);
if (!field) {
errors[key] = "Not a settings field this plugin declares";
@@ -775,7 +780,22 @@ async function applySettings(
errors[key] = "You do not have permission to change this setting";
continue;
}
if (scope === "host" && field.ownerOnly && !options.isHostOwner) {
errors[key] = "Only the host's owner can change this setting";
}
}
if (Object.keys(errors).length > 0) return errors;
// The plugin's own checks see the whole save before anything is written.
const rejected = await validateSettingsSave(
manifest.id,
scope,
scopeId,
body,
);
if (Object.keys(rejected).length > 0) return rejected;
for (const [key, value] of Object.entries(body)) {
const error = await setSetting(manifest, scope, scopeId, key, value);
if (error) errors[key] = error;
}
@@ -1208,11 +1228,14 @@ router.put(
"host",
String(hostId),
req.body,
{ isHostOwner: access.isOwner === true },
);
if (Object.keys(errors).length > 0) {
res.status(400).json({ error: "Some settings were rejected", errors });
return;
}
const { touchHost } = await import("./host-plugin-settings.js");
await touchHost(hostId);
const values = await getAllSettings(manifest, "host", hostId, {
redactSecrets: true,
@@ -1230,3 +1253,37 @@ router.put(
);
export default router;
/**
* A host field with defaultFrom starts new hosts at an admin setting's value,
* so the host editor gets that value as the field's default.
*/
async function withHostFieldDefaults(
manifest: PluginManifest,
): Promise<unknown> {
const contributes = manifest?.contributes;
const host = contributes?.settings?.host;
if (!host?.fields.some((field) => field.defaultFrom)) {
return contributes ?? null;
}
const fields: PluginSettingsField[] = await Promise.all(
host.fields.map(async (field) => {
if (!field.defaultFrom) return field;
try {
const value = await getSetting(
manifest,
"admin",
null,
field.defaultFrom,
);
return value === undefined ? field : { ...field, default: value };
} catch {
return field;
}
}),
);
return {
...contributes,
settings: { ...contributes.settings, host: { ...host, fields } },
};
}
@@ -0,0 +1,84 @@
/**
* Host plugin settings over remote sync. A host row travels with a
* pluginSettings map of its non-secret values; a plugin whose value refers to
* one of its own rows by local id translates it with a registered
* ctx.registry.provide("<id>.hostSettingsSync", { exportValue, importValue }).
*/
import { consume } from "../../plugins/registry.js";
import {
declaredFields,
getAllSettings,
setSetting,
} from "../../plugins/settings.js";
import { databaseLogger } from "../../utils/logger.js";
import {
hostSettingsPlugins,
type HostPluginSettings,
} from "./host-plugin-settings.js";
export interface PluginHostSettingsSync {
exportValue?: (key: string, value: unknown) => unknown | Promise<unknown>;
importValue?: (key: string, value: unknown) => unknown | Promise<unknown>;
}
/** What a host carries to the other side of a sync pair. */
export async function exportHostPluginSettings(
hostId: number,
): Promise<HostPluginSettings> {
const result: HostPluginSettings = {};
for (const manifest of hostSettingsPlugins()) {
const fields = declaredFields(manifest, "host").filter(
(field) => field.type !== "secret",
);
if (fields.length === 0) continue;
const values = await getAllSettings(manifest, "host", hostId, {
redactSecrets: true,
});
const hook = consume<PluginHostSettingsSync>(
`${manifest.id}.hostSettingsSync`,
);
const own: Record<string, unknown> = {};
for (const field of fields) {
const value = values[field.key];
own[field.key] = hook?.exportValue
? await hook.exportValue(field.key, value)
: value;
}
result[manifest.id] = own;
}
return result;
}
/** Writes what a synced host carried, translated back to local ids. */
export async function importHostPluginSettings(
hostId: number,
carried: unknown,
): Promise<void> {
if (!carried || typeof carried !== "object") return;
const byPlugin = carried as HostPluginSettings;
for (const manifest of hostSettingsPlugins()) {
const own = byPlugin[manifest.id];
if (!own || typeof own !== "object") continue;
const hook = consume<PluginHostSettingsSync>(
`${manifest.id}.hostSettingsSync`,
);
for (const field of declaredFields(manifest, "host")) {
if (field.type === "secret" || !(field.key in own)) continue;
try {
const value = hook?.importValue
? await hook.importValue(field.key, own[field.key])
: own[field.key];
await setSetting(manifest, "host", hostId, field.key, value);
} catch (error) {
databaseLogger.warn("Could not apply a synced host plugin setting", {
operation: "sync_host_plugin_settings",
pluginId: manifest.id,
hostId,
key: field.key,
error: error instanceof Error ? error.message : String(error),
});
}
}
}
}
+15
View File
@@ -32,6 +32,10 @@ import {
updateReturning,
} from "../repositories/returning.js";
import { validateParentHostId } from "./host-parent-validation.js";
import {
exportHostPluginSettings,
importHostPluginSettings,
} from "./sync-host-plugin-settings.js";
// Primed at import so every consumer of this module - the routes below, the
// reference resolvers, the Electron entity-types endpoint - sees the same
@@ -302,6 +306,11 @@ router.get(
(referenceType, id) =>
findReferenceSyncId(context, referenceType, id, userId),
);
if (entityType === "hosts") {
result.pluginSettings = await exportHostPluginSettings(
row.id as number,
);
}
return singleton
? { ...result, syncId: `${entityType}:singleton` }
: result;
@@ -461,6 +470,8 @@ router.post(
);
if (parentError) return res.status(400).json({ error: parentError });
}
const carriedPluginSettings = resolvedPayload.pluginSettings;
delete resolvedPayload.pluginSettings;
const writePayload = stripWritePayload(entityType, resolvedPayload);
const encryptedPayload = encryptIfNeeded(
entityType,
@@ -495,6 +506,10 @@ router.post(
resultRow = insertedRows[0] as Record<string, unknown>;
}
if (entityType === "hosts" && typeof resultRow?.id === "number") {
await importHostPluginSettings(resultRow.id, carriedPluginSettings);
}
await DatabaseSaveTrigger.forceSave("sync_upsert");
res.json({
@@ -35,8 +35,6 @@ export type HostDefaults = {
theme?: string;
cursorStyle?: string;
cursorBlink?: boolean;
enableSessionLogging?: boolean;
enableCommandHistory?: boolean;
autoTmux?: boolean;
};