feat(plugins): install an older version of a plugin (#2035)

Adds a version picker to the plugin admin panel, on desktop and mobile: every
published version with its own server-computed compatibility verdict, so the
client never re-derives semver range logic.

A deliberate non-latest pick sets an update hold on the row, which keeps the
update banner and "Update all" from immediately nagging the rollback away. The
hold releases when an install lands back on the newest compatible version by any
path, or when the admin resumes updates explicitly. An unresolvable registry
never sets one: refusing to pin on missing information beats silently muting
future updates.
This commit is contained in:
jubnl
2026-08-25 17:30:27 +02:00
committed by GitHub
parent b5c8182410
commit 4ba407aead
37 changed files with 1383 additions and 42 deletions
+13
View File
@@ -4123,6 +4123,19 @@ function runMigrations(db: Database.Database): void {
}
}
},
// A deliberate non-latest plugin install sets `update_hold`: the row leaves the
// update banner and "Update all" until the admin resumes updates, or until an
// install lands back on the newest compatible version. Only an EXPLICIT version
// pick ever sets it — dependency resolution pins versions too, but never
// deliberately. Appended LAST — the array is index-addressed against schema_version.
() => {
try {
db.exec('ALTER TABLE plugins ADD COLUMN update_hold INTEGER NOT NULL DEFAULT 0;');
} catch (err) {
console.warn('[migrations] Non-fatal migration step failed:', err);
}
},
];
if (currentVersion < migrations.length) {
+24 -4
View File
@@ -11,7 +11,7 @@ import { CurrentUser } from '../auth/current-user.decorator';
import { getClientIp } from '../audit/client-ip';
import { pluginsEnabled } from './kill-switch';
import { devLinkEnabled } from './dev-link';
import { PluginActivateDto, PluginConfigDto, PluginEgressHostsDto, PluginInstallDto, PluginLinkDto, PluginRetrustDto, PluginUninstallDto } from './plugins.dto';
import { PluginActivateDto, PluginConfigDto, PluginEgressHostsDto, PluginInstallDto, PluginLinkDto, PluginRetrustDto, PluginUninstallDto, PluginUpdateDto } from './plugins.dto';
import { ManagedForbidden, isManagedBlocked, MANAGED_FORBIDDEN_ERROR } from '../common/managed';
import { RuntimeEnvService } from '../app-config/runtime-env.service';
@@ -81,8 +81,12 @@ export class PluginsController {
// withDependencies (used by the "resolve missing dependency" admin flow) pulls
// the target + its transitive plugin deps, resolving each to its latest
// compatible version and reporting addons the admin still has to enable.
// Dependency resolution pins versions internally but never DELIBERATELY, so
// only the plain path below recomputes the update hold.
if (body.withDependencies) return await this.registry.installWithDependencies(body.id, body.constraint);
return await this.registry.install(body.id, { version: body.version, constraint: body.constraint });
const res = await this.registry.install(body.id, { version: body.version, constraint: body.constraint });
await this.registry.recomputeUpdateHold(res.id, res.version, !!body.version);
return res;
} catch (e) {
throw registryFailure(e, 'install failed');
}
@@ -221,15 +225,31 @@ export class PluginsController {
@Post(':id/update')
@HttpCode(200)
async update(@Param('id') id: string) {
async update(@Param('id') id: string, @Body() body?: PluginUpdateDto) {
if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503);
try {
return await this.runtime.update(id);
// An explicit version is the rollback path: install exactly what the admin picked
// (the TREK-compat gate still refuses in selectVersion). Absent, the runtime
// resolves the newest compatible version itself.
const res = await this.runtime.update(id, { version: body?.version });
// A deliberate non-latest pick holds future updates; landing on the newest
// (any path) releases a stale hold. Only after success — a failed update
// changed nothing and must not touch the flag.
await this.registry.recomputeUpdateHold(id, res.version, !!body?.version);
return res;
} catch (e) {
throw registryFailure(e, 'update failed');
}
}
/** Release a per-plugin update hold (set by a deliberate non-latest install). */
@Post(':id/resume-updates')
@HttpCode(200)
resumeUpdates(@Param('id') id: string) {
if (!this.plugins.resumeUpdates(id)) throw new HttpException({ error: `plugin ${id} not found` }, 404);
return { updateHold: false };
}
/**
* Re-trust a plugin whose author signing key ROTATED, and update it in the same call.
*
+8
View File
@@ -41,6 +41,14 @@ export class PluginRetrustDto extends createZodDto(
z.looseObject({ version: z.string().optional(), publicKey: z.string().optional() }),
) {}
/**
* `version` pins the exact version to install — the rollback path. Omitted, the
* runtime resolves the newest TREK-compatible version itself (the classic update).
*/
export class PluginUpdateDto extends createZodDto(
z.looseObject({ version: z.string().optional() }),
) {}
/**
* `hosts` is deliberately unknown, not `string[]`.
*
+15 -1
View File
@@ -43,6 +43,7 @@ interface PluginRawRow {
update_block_code: string | null;
update_block_detail: string | null;
update_block_version: string | null;
update_hold: number;
}
export interface PluginListItem {
@@ -87,6 +88,9 @@ export interface PluginListItem {
/** Why an update was refused, if one was. `version` is the registry version that was
* refused, so a caller can treat the block as stale once a newer one is on offer. */
updateBlock: { code: string; detail: string | null; version: string | null } | null;
/** A deliberate non-latest install paused updates: excluded from the banner/Update all
* until the admin resumes, or an install lands back on the newest compatible version. */
updateHold: boolean;
}
@Injectable()
@@ -114,7 +118,7 @@ export class PluginsService {
.prepare(
`SELECT id, name, description, type, icon, version, status, enabled, last_error, reviewed_at, source_repo,
permissions, capabilities, dependencies, operator_egress, trek_range,
author_pubkey, update_block_code, update_block_detail, update_block_version
author_pubkey, update_block_code, update_block_detail, update_block_version, update_hold
FROM plugins
ORDER BY sort_order, name`,
)
@@ -143,6 +147,7 @@ export class PluginsService {
update_block_code,
update_block_detail,
update_block_version,
update_hold,
...rest
} = r as PluginRawRow & { operator_egress?: number };
return {
@@ -159,11 +164,20 @@ export class PluginsService {
updateBlock: update_block_code
? { code: update_block_code, detail: update_block_detail, version: update_block_version }
: null,
updateHold: update_hold === 1,
};
});
return { enabled: pluginsEnabled(), devLink: devLinkEnabled(), plugins };
}
/**
* Release a per-plugin update hold (set by a deliberate non-latest install).
* Returns whether the plugin row existed — the controller answers 404 otherwise.
*/
resumeUpdates(id: string): boolean {
return this.db.prepare('UPDATE plugins SET update_hold = 0 WHERE id = ?').run(id).changes > 0;
}
/**
* Merge instance-scope settings into the plugin's config, encrypting fields
* declared secret (unless the value is the unchanged mask sentinel). Only keys
@@ -304,6 +304,17 @@ export class PluginRegistryService {
screenshotUrl: entry.screenshotUrl ?? (latest ? rawFileUrl(entry.repo, latest.commitSha, 'docs/screenshot.png') : null),
signed: !!entry.authorPublicKey && !!latest?.signature,
authorPublicKey: entry.authorPublicKey ?? null,
// The version picker's data: every published version with its OWN server-computed
// compat verdict. The client has no semver and must never re-derive range logic —
// a picker that disagreed with the install gate would offer a version that 400s.
versions: entry.versions.map((v) => ({
version: v.version,
publishedAt: v.publishedAt ?? null,
size: v.size ?? null,
signed: !!entry.authorPublicKey && !!v.signature,
trek: trekRequirementOrNull(v),
compatible: hostCompatible(v, normalizedHost()),
})),
...this.hostCompat(entry),
manifest,
};
@@ -508,6 +519,30 @@ export class PluginRegistryService {
return { installed, requiredAddons: [...requiredAddons] };
}
/**
* Recompute the per-plugin update hold after a successful install/update.
*
* The hold exists so a DELIBERATE rollback isn't immediately nagged away by the
* update banner. It is set only when `explicit` (the admin picked this exact
* version) AND a newer TREK-compatible version exists; every other outcome writes
* 0, so landing back on the newest compatible version — by any path — releases a
* stale hold. An unresolvable registry never sets a hold: refusing to pin on
* missing information beats silently muting future updates.
*/
async recomputeUpdateHold(id: string, installedVersion: string, explicit: boolean): Promise<boolean> {
let hold = false;
if (explicit) {
try {
const newest = await this.resolveVersion(id);
hold = semver.lt(installedVersion, newest.version);
} catch {
hold = false;
}
}
this.db.prepare('UPDATE plugins SET update_hold = ? WHERE id = ?').run(hold ? 1 : 0, id);
return hold;
}
/**
* Sideload step 1: extract + validate an uploaded archive into a staging dir,
* WITHOUT touching the live plugin. Returns the manifest id/version + the staged
@@ -710,17 +745,23 @@ export function assertHostCompatible(range: string | null, id: string): void {
}
/**
* How a version's TREK requirement reads in an error/UI string. Each bound is optional —
* an entry may declare only a ceiling, or (once `trek` is absent too) nothing at all — so
* this composes whatever bounds exist rather than interpolating a missing one as "null".
* A version's declared TREK requirement as a range string, or null when the entry
* carries no bounds at all. Each bound is optional — an entry may declare only a
* ceiling, or (once `trek` is absent too) nothing — so this composes whatever
* bounds exist rather than interpolating a missing one as "null".
*/
function trekRequirement(v: RegistryVersion): string {
function trekRequirementOrNull(v: RegistryVersion): string | null {
if (v.trek) return v.trek;
const bounds = [
v.minTrekVersion ? `>=${v.minTrekVersion}` : null,
v.maxTrekVersion ? `<=${v.maxTrekVersion}` : null,
].filter(Boolean);
return bounds.length ? bounds.join(' ') : 'any version';
return bounds.length ? bounds.join(' ') : null;
}
/** How a version's TREK requirement reads in an error/UI string. */
function trekRequirement(v: RegistryVersion): string {
return trekRequirementOrNull(v) ?? 'any version';
}
/**