* feat: let the login page send the token back to the mobile app

* feat: add host-scripts entry to the plugin sdk

* feat: let plugins say how they show in the mobile app

* feat: frame host for running one plugin view outside the shell

* fix: build the SDK host modules in production mode

* chore: note the SDK host build fix in the changelog

* feat: list plugin engine ranges for the mobile app

* feat: host color and icon columns that sync

* chore: update translations and plugin pins

* fix: test

* fix: show source address when rejecting proxy auth

* fix: deleting your own account removes all of its data

* fix(deps): update body-parser to 2.4.0 (#1736)

* fix: body-parser 보안 패치 반영

* chore: 자동 검사를 막던 기존 JSON 서식 정리

---------

Co-authored-by: renovys <180682734+renovys@users.noreply.github.com>

* fix(sdk): decode command output as GBK when it is not valid UTF-8 (#1735)

* fix: honor shared credentials on shared hosts (#1730)

* fix: honor shared credentials on shared hosts and in personal SSH auth

* fix: snapshot shared SSH auth from a credential shared with the host owner

* fix: size DataView list columns by the table's own width (#1713)

* fix: size DataView list columns by the table's own width

The list grid was built from every column, so columns hidden below md/lg
left empty tracks and the visible cells landed under the wrong headers.
Each width now gets a template of only its visible columns (CSS variables
+ container queries on the table, so a narrow panel counts, not the window),
the header uses the same gap as the rows, and rows use a minimum height so
wrapped cells no longer overlap.

* test: wait for the title bar offset effect before asserting

The bar shows once getTitleBarVisible resolves, but --termix-titlebar-height
is set in a later passive effect, so reading it right after findByText raced
(3-5 of 40 runs under load, on main too).

* chore: rerun CI against dev-26.10.6

* fix: unlock passphrase keys once per connect burst (#1737)

* feat: give ssh transports the host origin and sync id (#1717)

* fix: wake other clients after a sync push (Plugin-Sync#1)

* feat: look up local row ids by sync id for the sync plugin (Plugin-Sync#4)

* feat: rail items can show a status dot (Plugin-Sync#5)

* fix: add the desktop shared copy banner text (#1709)

* feat: sidebar preference for alternating rows (#1712)

* fix: striped rows text

* feat: shell can open a saved host's editor for plugins (Plugin-SSH-Terminal#36)

* fix: show the logo in the macos menu bar (#1728)

* fix: number shortcuts skip the home tab (#1724)

* fix: show plugins that could not be downloaded as failed (#1714)

* fix: pass the host id as text when opening its editor

* fix: retry a plugin bundle that was not ready yet (#1708)

* fix: let commonjs plugins fall back to core packages on the desktop

* fix: let non docker installs switch nginx to https (#1711)

* feat: remove a folder share from its hosts too (#1719)

* chore: changelog for merged pull requests

* fix: accept the release .sig when installing a plugin from a file

* feat: folder logins for shared hosts, read only plugin sync, full desktop builds

* fix: test

* fix: stop dev runner restart loops and narrow plugin watchers

* chore: update translations and plugin pins

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

---------

Co-authored-by: renovys <renovy@gmail.com>
Co-authored-by: renovys <180682734+renovys@users.noreply.github.com>
Co-authored-by: Sebastian Cao <cycsmail@gmail.com>
Co-authored-by: Jonathan Rehuel Lewerissa <jonathan.rehuel16@gmail.com>
Co-authored-by: Rhiz3K <33246262+Rhiz3K@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-29 17:59:55 -05:00
1 parent 73c15f7c58
commit f90621a54e
168 files changed
+35063 -339

No files matched your search

+64 -9
View File
@@ -73,7 +73,7 @@ import {
pluginFeatures,
type PluginManifest,
} from "./manifest.js";
import { requireSignedPlugins } from "./trust.js";
import { requireSignedPlugins, verifyAnyTrusted } from "./trust.js";
import {
parseChangelog,
parseReleaseNotes,
@@ -1048,7 +1048,15 @@ export function restoreMissingPlugins(): Promise<LoadedPlugin[]> {
await readUninstalled(),
);
const restored: LoadedPlugin[] = [];
for (const id of ids) {
// Shown on the Plugins tab, so a plugin that could not be fetched is not
// listed as working. It stays on and is tried again on the next start.
const markMissing = (id: string, message: string) =>
recordState(
id,
"failed",
`Not in this image and could not be downloaded: ${message}`,
).catch(() => {});
for (const [index, id] of ids.entries()) {
try {
const record = await repository.findById(id);
const { source, release } = await requireRelease(
@@ -1075,11 +1083,14 @@ export function restoreMissingPlugins(): Promise<LoadedPlugin[]> {
pluginLogger.warn(`Could not restore ${id}: ${message}`, {
operation: "plugin_restore_missing",
});
await markMissing(id, message);
// The registry is down or offline, so the rest would fail the same way.
if (
error instanceof PluginManageError &&
error.code === "REGISTRY_UNREACHABLE"
) {
for (const rest of ids.slice(index + 1))
await markMissing(rest, message);
break;
}
}
@@ -1514,6 +1525,8 @@ interface StagedUpload {
file: string;
manifest: PluginManifest;
expiresAt: number;
/** Set when the upload came with a .sig an official key made. */
official: boolean;
}
const uploads = new Map<string, StagedUpload>();
@@ -1537,6 +1550,29 @@ export interface UploadPreview {
capabilities: string[];
/** The version of an earlier upload this one replaces. */
replaces: string | null;
/** The .sig sent with it was made by the official Termix key. */
official: boolean;
}
/**
* Checks a .sig sent with an upload. An official signature makes the file
* the same as a registry download, so it may stand in for one that could not
* be fetched (an offline install). Anything else is refused.
*/
function checkUploadSignature(
buffer: Buffer,
signature: string,
): { official: boolean } {
const sha256 = crypto.createHash("sha256").update(buffer).digest("hex");
const result = verifyAnyTrusted(buffer, sha256, signature);
if (result.ok === false) {
throw new PluginManageError(
`The signature does not match this file: ${result.reason}`,
400,
"INVALID_SIGNATURE",
);
}
return { official: result.tier === "official" };
}
async function assertUploadsAllowed(): Promise<void> {
@@ -1593,9 +1629,17 @@ function validateUpload(buffer: Buffer): PluginManifest {
return manifest;
}
/** Only another upload of the same id can be replaced from a file. */
async function assertReplaceable(pluginId: string): Promise<string | null> {
/**
* Only another upload of the same id can be replaced from a file. An
* officially signed file may also fill in a plugin whose code is missing, like
* one that was on but could not be downloaded.
*/
async function assertReplaceable(
pluginId: string,
official = false,
): Promise<string | null> {
const { loader } = getPluginRuntime();
if (official && !loader.get(pluginId)) return null;
if (loader.bundledIds().has(pluginId)) {
throw new PluginManageError(
`${pluginId} ships with Termix and can only be replaced by a signed release`,
@@ -1620,21 +1664,30 @@ async function assertReplaceable(pluginId: string): Promise<string | null> {
* Checks an uploaded .tmxplug and holds it until the admin agrees to what
* it asks for. Nothing is loaded yet.
*/
export async function stageUpload(buffer: Buffer): Promise<UploadPreview> {
export async function stageUpload(
buffer: Buffer,
signature: string | null = null,
): Promise<UploadPreview> {
await assertUploadsAllowed();
await clearUploads(Date.now());
const manifest = validateUpload(buffer);
const replaces = await assertReplaceable(manifest.id);
const { official } = signature
? checkUploadSignature(buffer, signature)
: { official: false };
const replaces = await assertReplaceable(manifest.id, official);
const token = crypto.randomBytes(16).toString("hex");
const dir = getDownloadsDir();
await fs.promises.mkdir(dir, { recursive: true });
const file = path.join(dir, `upload-${token}.tmxplug`);
await fs.promises.writeFile(file, buffer);
// Next to the file, where the loader looks for it.
if (signature) await fs.promises.writeFile(`${file}.sig`, signature);
uploads.set(token, {
file,
manifest,
expiresAt: Date.now() + UPLOAD_TTL_MS,
official,
});
return {
@@ -1647,6 +1700,7 @@ export async function stageUpload(buffer: Buffer): Promise<UploadPreview> {
features: pluginFeatures(manifest.features),
capabilities: [...manifest.capabilities],
replaces,
official,
};
}
@@ -1676,7 +1730,7 @@ export function installUpload(
);
}
assertConsent(options.capabilities, manifest.capabilities);
await assertReplaceable(pluginId);
await assertReplaceable(pluginId, upload.official);
const { createCurrentPluginRepository } = await repos();
const repository = createCurrentPluginRepository();
@@ -1689,6 +1743,7 @@ export function installUpload(
plugin = await swapIn(pluginId, { upload: upload.file });
} catch (error) {
await fs.promises.rm(upload.file, { force: true });
await fs.promises.rm(`${upload.file}.sig`, { force: true });
const { loader } = getPluginRuntime();
if (before && wasEnabled && loader.get(pluginId)) {
await startAndRecord(pluginId);
@@ -1701,7 +1756,7 @@ export function installUpload(
}
await repository.update(pluginId, {
tier: UNVERIFIED_TIER,
tier: upload.official ? "official" : UNVERIFIED_TIER,
registryId: null,
pinnedVersion: null,
autoUpdate: false,
@@ -1725,7 +1780,7 @@ export function installUpload(
}
const state = await startAndRecord(pluginId);
pluginLogger.warn(
`Installed unverified plugin ${pluginId}@${manifest.version} from a file`,
`Installed ${upload.official ? "officially signed" : "unverified"} plugin ${pluginId}@${manifest.version} from a file`,
{ operation: "plugin_install" },
);
return { id: pluginId, version: manifest.version, state };