build hook plugins

This commit is contained in:
KingKDot
2026-06-22 12:54:12 -05:00
parent 6180e7f1a2
commit 7aaf115e2f
20 changed files with 1857 additions and 81 deletions
+2
View File
@@ -303,6 +303,7 @@ const startBuildProcess = (buildId: string, buildConfig: any) =>
generateBuildMutex,
sanitizeOutputName,
fileShareRoot: FILE_SHARE_ROOT,
runBuildHookForAll: (hook, payload) => pluginRuntime.runBuildHookForAll(hook, payload),
});
const pendingHttpDownloads = new Map<string, PendingHttpDownload>();
@@ -496,6 +497,7 @@ async function startServer() {
startBuildProcess,
sanitizeMutex,
allowedPlatforms: ALLOWED_PLATFORMS,
listPluginManifests,
},
deploy: {
DEPLOY_ROOT,
+46 -1
View File
@@ -335,7 +335,7 @@ export type PluginManifest = {
id: string;
name: string;
apiVersion?: number;
runtime?: "native" | "wasm" | string;
runtime?: "native" | "wasm" | "server" | string;
version?: string;
description?: string;
binary?: string;
@@ -352,9 +352,54 @@ export type PluginManifest = {
label?: string;
icon?: string;
};
build?: PluginBuildIntegration;
hasServer?: boolean;
};
export type PluginBuildIntegration = {
enabledByDefault?: boolean;
label?: string;
description?: string;
settings?: PluginBuildSetting[];
actions?: PluginBuildAction[];
requires?: PluginBuildRequirement[];
};
export type PluginBuildSetting = {
key: string;
label?: string;
type?: "string" | "number" | "boolean" | "select" | "textarea" | string;
default?: unknown;
placeholder?: string;
description?: string;
required?: boolean;
min?: number;
max?: number;
options?: Array<{ value: string; label?: string } | string>;
};
export type PluginBuildAction = {
id: string;
label: string;
icon?: string;
description?: string;
setBuild?: Record<string, unknown>;
setSettings?: Record<string, unknown>;
requires?: PluginBuildRequirement[];
};
export type PluginBuildRequirement = {
field?: string;
pluginSetting?: string;
equals?: unknown;
notEquals?: unknown;
truthy?: boolean;
falsy?: boolean;
includes?: unknown;
platforms?: string[];
message?: string;
};
export type PluginFileNeed = {
bucket: "home" | "desktop" | "documents" | "downloads" | "temp" | "appData" | "pluginData" | "fullDisk" | string;
access: Array<"read" | "write" | "list" | "delete" | "mkdir" | string>;
+580 -4
View File
@@ -122,8 +122,110 @@ type BuildProcessConfig = {
iosBundleId?: string;
fetchPublicIP?: boolean;
uploadToFileShare?: boolean;
buildPlugins?: Record<string, { enabled: boolean; settings: Record<string, unknown> }>;
};
type BuildHookRunner = (
hook: string,
payload: unknown,
) => Promise<Array<{ pluginId: string; result: unknown }>>;
type BuildHookMessage = {
text: string;
level?: "debug" | "info" | "warn" | "error" | "success";
};
function cloneForHook<T>(value: T): T {
return JSON.parse(JSON.stringify(value ?? null));
}
function isRecord(value: unknown): value is Record<string, any> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function normalizeHookMessages(result: unknown): BuildHookMessage[] {
if (!isRecord(result)) return [];
const rawMessages = Array.isArray(result.messages)
? result.messages
: typeof result.message === "string"
? [result.message]
: [];
const messages: BuildHookMessage[] = [];
for (const raw of rawMessages) {
if (typeof raw === "string" && raw.trim()) {
messages.push({ text: raw.trim(), level: "info" });
} else if (isRecord(raw) && typeof raw.text === "string" && raw.text.trim()) {
const level = typeof raw.level === "string" ? raw.level : "info";
messages.push({
text: raw.text.trim(),
level: ["debug", "info", "warn", "error", "success"].includes(level) ? level as BuildHookMessage["level"] : "info",
});
}
}
return messages;
}
async function runBuildHooks(
hookRunner: BuildHookRunner | undefined,
hook: string,
payload: unknown,
sendToStream: (data: any) => void,
): Promise<Array<{ pluginId: string; result: unknown }>> {
if (!hookRunner) return [];
const results = await hookRunner(hook, cloneForHook(payload));
for (const item of results) {
for (const message of normalizeHookMessages(item.result)) {
sendToStream({
type: "output",
text: `[plugin:${item.pluginId}] ${message.text}\n`,
level: message.level || "info",
});
}
}
return results;
}
function mergeBuildConfigPatch(config: BuildProcessConfig, result: unknown): BuildProcessConfig {
if (!isRecord(result)) return config;
const patch = isRecord(result.config) ? result.config : isRecord(result.configPatch) ? result.configPatch : undefined;
if (!patch) return config;
return { ...config, ...patch };
}
function buildTransformHookPayload(args: {
buildId: string;
stage: string;
platform: string;
os: string;
arch: string;
targetKey: string;
outDir: string;
clientDir: string;
filename: string;
filePath: string;
size?: number;
config: BuildProcessConfig;
extra?: Record<string, unknown>;
}) {
return {
buildId: args.buildId,
stage: args.stage,
platform: args.platform,
os: args.os,
arch: args.arch,
targetKey: args.targetKey,
outDir: args.outDir,
clientDir: args.clientDir,
file: {
filename: args.filename,
path: args.filePath,
platform: args.platform,
size: args.size ?? (fs.existsSync(args.filePath) ? fs.statSync(args.filePath).size : 0),
},
config: args.config,
...(args.extra || {}),
};
}
function stripUpxHeaders(filePath: string): boolean {
try {
@@ -154,6 +256,7 @@ type BuildProcessDeps = {
generateBuildMutex: (length?: number) => string;
sanitizeOutputName: (name: string) => string;
fileShareRoot?: string;
runBuildHookForAll?: BuildHookRunner;
};
function guessMimeTypeForUpload(filename: string): string {
@@ -384,6 +487,15 @@ export async function startBuildProcess(
}, BUILD_STREAM_HEARTBEAT_MS);
try {
for (const item of await runBuildHooks(
deps.runBuildHookForAll,
"prepare",
{ buildId, config },
sendToStream,
)) {
config = mergeBuildConfigPatch(config, item.result);
}
const serverConfig = getConfig();
const buildAgentToken = (serverConfig.auth.agentToken || "").trim();
@@ -809,10 +921,10 @@ func runBoundFiles() {
const namePrefix = config.outputName || "agent";
const winExt = config.outputExtension || ".exe";
const buildSlug = buildId.substring(0, 8);
const friendlyOutputName = deps.sanitizeOutputName(
let friendlyOutputName = deps.sanitizeOutputName(
platform.includes("windows") ? `${namePrefix}-${platform}${winExt}` : `${namePrefix}-${platform}`,
);
const outputName = deps.sanitizeOutputName(
let outputName = deps.sanitizeOutputName(
platform.includes("windows") ? `${namePrefix}-${platform}-${buildSlug}${winExt}` : `${namePrefix}-${platform}-${buildSlug}`,
);
@@ -1084,7 +1196,7 @@ func runBoundFiles() {
}
// Base tags: always present regardless of build pass
const baseTags: string[] = [];
let baseTags: string[] = [];
if (config.noPrinting) baseTags.push("noprint");
if (config.disableKeylogger) baseTags.push("nokeylogger");
if (config.enableWebrtc) baseTags.push("overlord_webrtc");
@@ -1095,7 +1207,7 @@ func runBoundFiles() {
if (config.fetchPublicIP) baseTags.push("fetch_public_ip");
// Windows persistence tags (omitted in shellcode mode — handled by two-pass below)
const winPersistTags: string[] = [];
let winPersistTags: string[] = [];
if (config.enablePersistence && os === "windows" && !isShellcodeMode) {
const methods = config.persistenceMethods?.length ? config.persistenceMethods : ["startup"];
if (methods.includes("startup")) winPersistTags.push("persist_startup");
@@ -1104,6 +1216,73 @@ func runBoundFiles() {
if (methods.includes("wmi")) winPersistTags.push("persist_wmi");
}
let skipTarget = false;
const targetHookPayload = {
buildId,
platform,
os,
arch: actualArch,
effectiveOs,
targetKey,
outputName,
friendlyOutputName,
outputPath: path.join(outDir, outputName),
outDir,
clientDir,
env,
ldflags,
tags: [...baseTags, ...winPersistTags],
baseTags,
persistenceTags: winPersistTags,
garbleFlags,
config,
};
for (const item of await runBuildHooks(deps.runBuildHookForAll, "target", targetHookPayload, sendToStream)) {
if (!isRecord(item.result)) continue;
if (item.result.skip === true) {
skipTarget = true;
}
if (isRecord(item.result.env)) {
for (const [key, value] of Object.entries(item.result.env)) {
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) && value !== null && value !== undefined) {
env[key] = String(value);
}
}
}
if (typeof item.result.ldflags === "string") {
ldflags = item.result.ldflags;
}
if (typeof item.result.ldflagsAppend === "string" && item.result.ldflagsAppend.trim()) {
ldflags = ldflags ? `${ldflags} ${item.result.ldflagsAppend.trim()}` : item.result.ldflagsAppend.trim();
}
if (Array.isArray(item.result.removeTags)) {
const remove = new Set(item.result.removeTags.filter((tag: unknown) => typeof tag === "string"));
baseTags = baseTags.filter((tag) => !remove.has(tag));
winPersistTags = winPersistTags.filter((tag) => !remove.has(tag));
}
if (Array.isArray(item.result.tags)) {
baseTags = item.result.tags.filter((tag: unknown) => typeof tag === "string" && tag.trim()).map((tag: string) => tag.trim());
winPersistTags = [];
}
if (Array.isArray(item.result.addTags)) {
for (const tag of item.result.addTags) {
if (typeof tag === "string" && tag.trim() && !baseTags.includes(tag.trim())) {
baseTags.push(tag.trim());
}
}
}
if (typeof item.result.outputName === "string" && item.result.outputName.trim()) {
outputName = deps.sanitizeOutputName(item.result.outputName.trim());
}
if (typeof item.result.friendlyOutputName === "string" && item.result.friendlyOutputName.trim()) {
friendlyOutputName = deps.sanitizeOutputName(item.result.friendlyOutputName.trim());
}
}
if (skipTarget) {
sendToStream({ type: "output", text: `Skipping ${platform}; requested by server plugin hook.\n`, level: "warn" });
continue;
}
const runBuild = async (tags: string[], outputPath: string) => {
const buildArgs: string[] = [];
if (tags.length > 0) buildArgs.push("-tags", tags.join(" "));
@@ -1159,12 +1338,51 @@ func runBoundFiles() {
const filePath = `${outDir}/${outputName}`;
let finalSize = Bun.file(filePath).size;
await runBuildHooks(
deps.runBuildHookForAll,
"post_build",
buildTransformHookPayload({
buildId,
stage: "post_build",
platform,
os,
arch: actualArch,
targetKey,
outDir,
clientDir,
filename: outputName,
filePath,
size: finalSize,
config,
}),
sendToStream,
);
// For script outputs: go build writes a PE binary; UPX must run first (it needs PE format),
// then after compression we wrap it in a script with an embedded base64 payload.
const isBatchWrapper = os === "windows" && (winExt === ".bat" || winExt === ".cmd");
const isPowerShellWrapper = os === "windows" && winExt === ".ps1";
if (upxBin) {
await runBuildHooks(
deps.runBuildHookForAll,
"before_upx",
buildTransformHookPayload({
buildId,
stage: "before_upx",
platform,
os,
arch: actualArch,
targetKey,
outDir,
clientDir,
filename: outputName,
filePath,
size: finalSize,
config,
extra: { upxBin },
}),
sendToStream,
);
sendToStream({ type: "output", text: `Compressing ${outputName} with UPX...\n`, level: "info" });
const originalSize = finalSize;
try {
@@ -1190,9 +1408,49 @@ func runBoundFiles() {
} catch (upxErr: any) {
sendToStream({ type: "output", text: `WARNING: UPX failed: ${upxErr.message || upxErr}\n`, level: "warn" });
}
await runBuildHooks(
deps.runBuildHookForAll,
"after_upx",
buildTransformHookPayload({
buildId,
stage: "after_upx",
platform,
os,
arch: actualArch,
targetKey,
outDir,
clientDir,
filename: outputName,
filePath,
size: finalSize,
config,
extra: { upxBin, originalSize },
}),
sendToStream,
);
}
if (isBatchWrapper || isPowerShellWrapper) {
await runBuildHooks(
deps.runBuildHookForAll,
"before_script_wrapper",
buildTransformHookPayload({
buildId,
stage: "before_script_wrapper",
platform,
os,
arch: actualArch,
targetKey,
outDir,
clientDir,
filename: outputName,
filePath,
size: finalSize,
config,
extra: { extension: winExt },
}),
sendToStream,
);
sendToStream({ type: "output", text: `Wrapping PE binary as ${winExt} script...\n`, level: "info" });
try {
const exeBytes = fs.readFileSync(filePath);
@@ -1252,10 +1510,49 @@ func runBoundFiles() {
} catch (wrapErr: any) {
sendToStream({ type: "output", text: `WARNING: Failed to generate ${winExt} wrapper: ${wrapErr.message || wrapErr}. Output is a raw PE binary with ${winExt} extension.\n`, level: "warn" });
}
await runBuildHooks(
deps.runBuildHookForAll,
"after_script_wrapper",
buildTransformHookPayload({
buildId,
stage: "after_script_wrapper",
platform,
os,
arch: actualArch,
targetKey,
outDir,
clientDir,
filename: outputName,
filePath,
size: finalSize,
config,
extra: { extension: winExt },
}),
sendToStream,
);
}
// ── IPA packaging for iOS targets ──────────────────────────────────────
if (os === "ios") {
await runBuildHooks(
deps.runBuildHookForAll,
"before_ipa",
buildTransformHookPayload({
buildId,
stage: "before_ipa",
platform,
os,
arch: actualArch,
targetKey,
outDir,
clientDir,
filename: outputName,
filePath,
size: finalSize,
config,
}),
sendToStream,
);
sendToStream({ type: "output", text: `Packaging ${outputName} as IPA...\n`, level: "info" });
try {
const appName = config.outputName || "Agent";
@@ -1355,6 +1652,27 @@ func runBoundFiles() {
// Clean up temp dirs
fs.rmSync(ipaWorkDir, { recursive: true, force: true });
await runBuildHooks(
deps.runBuildHookForAll,
"after_ipa",
buildTransformHookPayload({
buildId,
stage: "after_ipa",
platform,
os,
arch: actualArch,
targetKey,
outDir,
clientDir,
filename: ipaOutputName,
filePath: ipaPath,
size: finalSize,
config,
extra: { inputFilename: outputName },
}),
sendToStream,
);
// Push IPA file entry instead of raw binary
(build.files as any[]).push({
name: ipaOutputName.replace(`-${buildSlug}`, ""),
@@ -1387,6 +1705,26 @@ func runBoundFiles() {
const scOutputName = deps.sanitizeOutputName(outputName.replace(/\.[^.]+$/, ".bin"));
const binPath = path.join(outDir, scOutputName);
const donutArch = (actualArch === "386" ? "386" : "amd64") as "386" | "amd64";
await runBuildHooks(
deps.runBuildHookForAll,
"before_donut",
buildTransformHookPayload({
buildId,
stage: "before_donut",
platform,
os,
arch: actualArch,
targetKey,
outDir,
clientDir,
filename: finalOutputName,
filePath,
size: finalOutputSize,
config,
extra: { outputFilename: scOutputName, outputPath: binPath, donutArch },
}),
sendToStream,
);
const ok = await runDonut(filePath, binPath, donutArch, sendToStream);
if (!ok) throw new Error(`Donut shellcode conversion failed for ${platform}`);
try { fs.unlinkSync(filePath); } catch {}
@@ -1394,6 +1732,26 @@ func runBoundFiles() {
finalOutputSize = Bun.file(binPath).size;
shellcodeBinPath = binPath;
shellcodeArch = donutArch;
await runBuildHooks(
deps.runBuildHookForAll,
"after_donut",
buildTransformHookPayload({
buildId,
stage: "after_donut",
platform,
os,
arch: actualArch,
targetKey,
outDir,
clientDir,
filename: finalOutputName,
filePath: binPath,
size: finalOutputSize,
config,
extra: { inputFilename: outputName, donutArch },
}),
sendToStream,
);
sendToStream({ type: "output", text: `Shellcode ready: ${finalOutputSize} bytes → ${finalOutputName}\n`, level: "success" });
}
@@ -1403,6 +1761,26 @@ func runBoundFiles() {
sendToStream({ type: "output", text: `\nWrapping ELF with Linux shellcode stub...\n`, level: "info" });
const scOutputName = deps.sanitizeOutputName(outputName + ".bin");
const binPath = path.join(outDir, scOutputName);
await runBuildHooks(
deps.runBuildHookForAll,
"before_linux_shellcode",
buildTransformHookPayload({
buildId,
stage: "before_linux_shellcode",
platform,
os,
arch: actualArch,
targetKey,
outDir,
clientDir,
filename: finalOutputName,
filePath,
size: finalOutputSize,
config,
extra: { outputFilename: scOutputName, outputPath: binPath },
}),
sendToStream,
);
const ok = buildLinuxShellcode(filePath, binPath, sendToStream);
if (!ok) throw new Error(`Linux shellcode wrap failed for ${platform}`);
try { fs.unlinkSync(filePath); } catch {}
@@ -1410,6 +1788,26 @@ func runBoundFiles() {
finalOutputSize = Bun.file(binPath).size;
shellcodeBinPath = binPath;
shellcodeArch = "amd64";
await runBuildHooks(
deps.runBuildHookForAll,
"after_linux_shellcode",
buildTransformHookPayload({
buildId,
stage: "after_linux_shellcode",
platform,
os,
arch: actualArch,
targetKey,
outDir,
clientDir,
filename: finalOutputName,
filePath: binPath,
size: finalOutputSize,
config,
extra: { inputFilename: outputName },
}),
sendToStream,
);
sendToStream({ type: "output", text: `Shellcode ready: ${finalOutputSize} bytes → ${finalOutputName}\n`, level: "success" });
}
@@ -1419,27 +1817,172 @@ func runBoundFiles() {
sendToStream({ type: "output", text: `\nEncoding shellcode with SGN (${iters} iteration${iters === 1 ? "" : "s"})...\n`, level: "info" });
const sgnOutputName = deps.sanitizeOutputName(finalOutputName + ".sgn");
const sgnOutputPath = path.join(outDir, sgnOutputName);
await runBuildHooks(
deps.runBuildHookForAll,
"before_sgn",
buildTransformHookPayload({
buildId,
stage: "before_sgn",
platform,
os,
arch: actualArch,
targetKey,
outDir,
clientDir,
filename: finalOutputName,
filePath: shellcodeBinPath,
size: finalOutputSize,
config,
extra: { outputFilename: sgnOutputName, outputPath: sgnOutputPath, shellcodeArch, iterations: iters },
}),
sendToStream,
);
const ok = await runSgn(shellcodeBinPath, sgnOutputPath, shellcodeArch, iters, sendToStream);
if (!ok) throw new Error(`SGN encoding failed for ${platform}`);
try { fs.unlinkSync(shellcodeBinPath); } catch {}
finalOutputName = sgnOutputName;
finalOutputSize = Bun.file(sgnOutputPath).size;
shellcodeBinPath = sgnOutputPath;
await runBuildHooks(
deps.runBuildHookForAll,
"after_sgn",
buildTransformHookPayload({
buildId,
stage: "after_sgn",
platform,
os,
arch: actualArch,
targetKey,
outDir,
clientDir,
filename: finalOutputName,
filePath: sgnOutputPath,
size: finalOutputSize,
config,
extra: { shellcodeArch, iterations: iters },
}),
sendToStream,
);
sendToStream({ type: "output", text: `SGN-encoded shellcode: ${finalOutputSize} bytes → ${finalOutputName}\n`, level: "success" });
if (config.outputSgnTxt) {
sendToStream({ type: "status", text: `Writing ${platform} SGN output as TXT…` });
const txtOutputName = deps.sanitizeOutputName(`${finalOutputName}.txt`);
const txtOutputPath = path.join(outDir, txtOutputName);
await runBuildHooks(
deps.runBuildHookForAll,
"before_sgn_txt",
buildTransformHookPayload({
buildId,
stage: "before_sgn_txt",
platform,
os,
arch: actualArch,
targetKey,
outDir,
clientDir,
filename: finalOutputName,
filePath: sgnOutputPath,
size: finalOutputSize,
config,
extra: { outputFilename: txtOutputName, outputPath: txtOutputPath, shellcodeArch, iterations: iters },
}),
sendToStream,
);
const txtSize = writeSgnTextArtifact(sgnOutputPath, txtOutputPath, platform, shellcodeArch, iters);
try { fs.unlinkSync(sgnOutputPath); } catch {}
finalOutputName = txtOutputName;
finalOutputSize = txtSize;
shellcodeBinPath = txtOutputPath;
await runBuildHooks(
deps.runBuildHookForAll,
"after_sgn_txt",
buildTransformHookPayload({
buildId,
stage: "after_sgn_txt",
platform,
os,
arch: actualArch,
targetKey,
outDir,
clientDir,
filename: finalOutputName,
filePath: txtOutputPath,
size: finalOutputSize,
config,
extra: { shellcodeArch, iterations: iters },
}),
sendToStream,
);
sendToStream({ type: "output", text: `SGN TXT ready: ${txtSize} bytes → ${finalOutputName}\n`, level: "success" });
}
}
const extraArtifactFiles: any[] = [];
const artifactHookPayload = {
buildId,
platform,
os,
arch: actualArch,
targetKey,
outDir,
clientDir,
file: {
name: finalOutputName.replace(`-${buildSlug}`, ""),
filename: finalOutputName,
path: path.join(outDir, finalOutputName),
platform,
version: agentVersion,
size: finalOutputSize,
},
files: cloneForHook(build.files),
config,
};
for (const item of await runBuildHooks(deps.runBuildHookForAll, "artifact", artifactHookPayload, sendToStream)) {
if (!isRecord(item.result)) continue;
const replacement = isRecord(item.result.file) ? item.result.file : item.result;
if (typeof replacement.filename === "string" && replacement.filename.trim()) {
const safeFilename = deps.sanitizeOutputName(path.basename(replacement.filename.trim()));
const candidatePath = resolveContainedPath(outDir, safeFilename);
if (fs.existsSync(candidatePath)) {
finalOutputName = safeFilename;
finalOutputSize = fs.statSync(candidatePath).size;
} else {
sendToStream({
type: "output",
text: `[plugin:${item.pluginId}] WARNING: artifact hook returned missing file ${safeFilename}; keeping ${finalOutputName}\n`,
level: "warn",
});
}
}
if (typeof replacement.size === "number" && Number.isFinite(replacement.size) && replacement.size >= 0) {
finalOutputSize = Math.floor(replacement.size);
}
if (Array.isArray(item.result.files)) {
for (const f of item.result.files) {
if (!isRecord(f) || typeof f.filename !== "string" || !f.filename.trim()) continue;
const safeFilename = deps.sanitizeOutputName(path.basename(f.filename.trim()));
const candidatePath = resolveContainedPath(outDir, safeFilename);
if (!fs.existsSync(candidatePath)) {
sendToStream({
type: "output",
text: `[plugin:${item.pluginId}] WARNING: extra artifact not found: ${safeFilename}\n`,
level: "warn",
});
continue;
}
const stat = fs.statSync(candidatePath);
extraArtifactFiles.push({
name: typeof f.name === "string" && f.name.trim() ? f.name.trim() : safeFilename,
filename: safeFilename,
platform: typeof f.platform === "string" ? f.platform : platform,
version: typeof f.version === "string" ? f.version : agentVersion,
size: stat.size,
});
}
}
}
(build.files as any[]).push({
name: finalOutputName.replace(`-${buildSlug}`, ""),
filename: finalOutputName,
@@ -1447,6 +1990,7 @@ func runBoundFiles() {
version: agentVersion,
size: finalOutputSize,
});
(build.files as any[]).push(...extraArtifactFiles);
} catch (err: any) {
const errorMsg = `[ERROR] Failed to build ${platform}: ${err.message || err}\n`;
logger.error(`[build:${buildId.substring(0, 8)}] ${errorMsg.trim()}`);
@@ -1483,6 +2027,21 @@ func runBoundFiles() {
});
}
await runBuildHooks(
deps.runBuildHookForAll,
"complete",
{
buildId,
status: build.status,
files: build.files,
outputDir: outDir,
expiresAt: build.expiresAt,
userId: config.builtByUserId,
config,
},
sendToStream,
);
sendToStream({ type: "complete", success: true, files: build.files, buildId, expiresAt: build.expiresAt });
saveBuild({
@@ -1502,6 +2061,23 @@ func runBoundFiles() {
} catch (err: any) {
build.status = "failed";
logger.error(`[build:${buildId.substring(0, 8)}] Build failed:`, err);
try {
await runBuildHooks(
deps.runBuildHookForAll,
"failed",
{
buildId,
status: build.status,
error: err?.message || String(err),
files: build.files,
userId: config.builtByUserId,
config,
},
sendToStream,
);
} catch (hookErr) {
logger.warn(`[build:${buildId.substring(0, 8)}] failed hook error:`, hookErr);
}
sendToStream({ type: "error", error: err.message || String(err) });
sendToStream({ type: "complete", success: false, buildId });
@@ -11,7 +11,7 @@ type Subscriber = {
close: () => void;
};
type PendingRpc = {
type PendingCall = {
resolve: (value: unknown) => void;
reject: (err: Error) => void;
timer: ReturnType<typeof setTimeout>;
@@ -24,12 +24,14 @@ type PluginInstance = {
resolveReady: () => void;
rejectReady: (err: Error) => void;
isReady: boolean;
pending: Map<string, PendingRpc>;
pending: Map<string, PendingCall>;
pendingBuildHooks: Map<string, PendingCall>;
subscribers: Set<Subscriber>;
startedAt: number;
};
const RPC_TIMEOUT_MS = 30_000;
const BUILD_HOOK_TIMEOUT_MS = 5 * 60_000;
const SHUTDOWN_GRACE_MS = 750;
export type PluginRuntime = {
@@ -48,6 +50,8 @@ export type PluginRuntime = {
params: unknown,
caller: PluginRpcCaller,
) => Promise<unknown>;
runBuildHook: (pluginId: string, hook: string, payload: unknown) => Promise<unknown>;
runBuildHookForAll: (hook: string, payload: unknown) => Promise<Array<{ pluginId: string; result: unknown }>>;
subscribe: (
pluginId: string,
send: (sse: string) => void,
@@ -55,6 +59,7 @@ export type PluginRuntime = {
) => (() => void) | null;
isRunning: (pluginId: string) => boolean;
hasServerCode: (pluginId: string) => boolean;
runningPluginIds: () => string[];
shutdownAll: () => Promise<void>;
};
@@ -105,6 +110,7 @@ export function createPluginRuntime(opts: PluginRuntimeOptions): PluginRuntime {
rejectReady,
isReady: false,
pending: new Map(),
pendingBuildHooks: new Map(),
subscribers: new Set(),
startedAt: Date.now(),
};
@@ -161,6 +167,11 @@ export function createPluginRuntime(opts: PluginRuntimeOptions): PluginRuntime {
pending.reject(new Error("Plugin runtime stopped"));
}
inst.pending.clear();
for (const pending of inst.pendingBuildHooks.values()) {
clearTimeout(pending.timer);
pending.reject(new Error("Plugin runtime stopped"));
}
inst.pendingBuildHooks.clear();
try {
const shutdownMsg: WorkerInbound = { type: "shutdown" };
@@ -237,6 +248,50 @@ export function createPluginRuntime(opts: PluginRuntimeOptions): PluginRuntime {
return inst.isReady ? callOnReady() : inst.ready.then(callOnReady);
}
function runBuildHook(
pluginId: string,
hook: string,
payload: unknown,
): Promise<unknown> {
const inst = instances.get(pluginId);
if (!inst) {
return Promise.reject(new Error("Plugin runtime not running"));
}
const callOnReady = () =>
new Promise((resolve, reject) => {
const id = uuidv4();
const timer = setTimeout(() => {
if (inst.pendingBuildHooks.delete(id)) {
reject(new Error(`Build hook timeout: ${hook}`));
}
}, BUILD_HOOK_TIMEOUT_MS);
inst.pendingBuildHooks.set(id, { resolve, reject, timer });
const msg: WorkerInbound = { type: "build_hook", id, hook, payload };
try {
inst.worker.postMessage(msg);
} catch (err) {
clearTimeout(timer);
inst.pendingBuildHooks.delete(id);
reject(err instanceof Error ? err : new Error(String(err)));
}
});
return inst.isReady ? callOnReady() : inst.ready.then(callOnReady);
}
async function runBuildHookForAll(
hook: string,
payload: unknown,
): Promise<Array<{ pluginId: string; result: unknown }>> {
const results: Array<{ pluginId: string; result: unknown }> = [];
for (const pluginId of runningPluginIds()) {
const result = await runBuildHook(pluginId, hook, payload);
if (result !== null && result !== undefined) {
results.push({ pluginId, result });
}
}
return results;
}
function subscribe(
pluginId: string,
send: (sse: string) => void,
@@ -270,6 +325,15 @@ export function createPluginRuntime(opts: PluginRuntimeOptions): PluginRuntime {
else pending.reject(new Error(msg.error));
return;
}
if (msg.type === "build_hook_reply") {
const pending = inst.pendingBuildHooks.get(msg.id);
if (!pending) return;
clearTimeout(pending.timer);
inst.pendingBuildHooks.delete(msg.id);
if (msg.ok) pending.resolve(msg.result);
else pending.reject(new Error(msg.error));
return;
}
if (msg.type === "broadcast") {
const safeChannel = String(msg.channel || "message").replace(/[\r\n]/g, "");
const dataLine = JSON.stringify(msg.data ?? null);
@@ -293,15 +357,22 @@ export function createPluginRuntime(opts: PluginRuntimeOptions): PluginRuntime {
await Promise.all(ids.map((id) => stopPlugin(id)));
}
function runningPluginIds(): string[] {
return Array.from(instances.keys()).sort();
}
return {
startPlugin,
stopPlugin,
restartPlugin,
dispatchClientEvent,
rpc,
runBuildHook,
runBuildHookForAll,
subscribe,
isRunning,
hasServerCode,
runningPluginIds,
shutdownAll,
};
}
@@ -24,6 +24,12 @@ export type WorkerInbound =
params: unknown;
caller: PluginRpcCaller;
}
| {
type: "build_hook";
id: string;
hook: string;
payload: unknown;
}
| {
type: "shutdown";
};
@@ -33,5 +39,7 @@ export type WorkerOutbound =
| { type: "boot_error"; error: string }
| { type: "rpc_reply"; id: string; ok: true; result: unknown }
| { type: "rpc_reply"; id: string; ok: false; error: string }
| { type: "build_hook_reply"; id: string; ok: true; result: unknown }
| { type: "build_hook_reply"; id: string; ok: false; error: string }
| { type: "broadcast"; channel: string; data: unknown }
| { type: "log"; level: "debug" | "info" | "warn" | "error"; message: string };
@@ -12,6 +12,27 @@ type PluginExports = {
setup?: (ctx: PluginContext) => unknown | Promise<unknown>;
onEvent?: (ctx: PluginContext, clientId: string, event: string, payload: unknown) => unknown | Promise<unknown>;
rpc?: Record<string, (ctx: PluginContext, params: unknown, meta: { caller: { id: number; role: string } }) => unknown | Promise<unknown>>;
buildHooks?: Record<string, (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>>;
onBuildPrepare?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
onBuildTarget?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
onBuildPostBuild?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
onBuildBeforeUpx?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
onBuildAfterUpx?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
onBuildBeforeScriptWrapper?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
onBuildAfterScriptWrapper?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
onBuildBeforeIpa?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
onBuildAfterIpa?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
onBuildBeforeDonut?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
onBuildAfterDonut?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
onBuildBeforeLinuxShellcode?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
onBuildAfterLinuxShellcode?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
onBuildBeforeSgn?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
onBuildAfterSgn?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
onBuildBeforeSgnTxt?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
onBuildAfterSgnTxt?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
onBuildArtifact?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
onBuildComplete?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
onBuildFailed?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
teardown?: (ctx: PluginContext) => unknown | Promise<unknown>;
};
@@ -118,6 +139,49 @@ self.onmessage = async (e: MessageEvent<WorkerInbound>) => {
return;
}
if (msg.type === "build_hook") {
if (!plugin || !ctx) {
send({ type: "build_hook_reply", id: msg.id, ok: false, error: "Plugin not initialised" });
return;
}
const handler =
plugin.buildHooks?.[msg.hook] ||
(msg.hook === "prepare" ? plugin.onBuildPrepare : undefined) ||
(msg.hook === "target" ? plugin.onBuildTarget : undefined) ||
(msg.hook === "post_build" ? plugin.onBuildPostBuild : undefined) ||
(msg.hook === "before_upx" ? plugin.onBuildBeforeUpx : undefined) ||
(msg.hook === "after_upx" ? plugin.onBuildAfterUpx : undefined) ||
(msg.hook === "before_script_wrapper" ? plugin.onBuildBeforeScriptWrapper : undefined) ||
(msg.hook === "after_script_wrapper" ? plugin.onBuildAfterScriptWrapper : undefined) ||
(msg.hook === "before_ipa" ? plugin.onBuildBeforeIpa : undefined) ||
(msg.hook === "after_ipa" ? plugin.onBuildAfterIpa : undefined) ||
(msg.hook === "before_donut" ? plugin.onBuildBeforeDonut : undefined) ||
(msg.hook === "after_donut" ? plugin.onBuildAfterDonut : undefined) ||
(msg.hook === "before_linux_shellcode" ? plugin.onBuildBeforeLinuxShellcode : undefined) ||
(msg.hook === "after_linux_shellcode" ? plugin.onBuildAfterLinuxShellcode : undefined) ||
(msg.hook === "before_sgn" ? plugin.onBuildBeforeSgn : undefined) ||
(msg.hook === "after_sgn" ? plugin.onBuildAfterSgn : undefined) ||
(msg.hook === "before_sgn_txt" ? plugin.onBuildBeforeSgnTxt : undefined) ||
(msg.hook === "after_sgn_txt" ? plugin.onBuildAfterSgnTxt : undefined) ||
(msg.hook === "artifact" ? plugin.onBuildArtifact : undefined) ||
(msg.hook === "complete" ? plugin.onBuildComplete : undefined) ||
(msg.hook === "failed" ? plugin.onBuildFailed : undefined);
if (typeof handler !== "function") {
send({ type: "build_hook_reply", id: msg.id, ok: true, result: null });
return;
}
try {
const result = await handler(ctx, msg.payload);
send({ type: "build_hook_reply", id: msg.id, ok: true, result: result ?? null });
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
const stack = err instanceof Error ? err.stack || "" : "";
console.error(`[plugin-worker] build hook ${msg.hook} error: ${error}`, stack);
send({ type: "build_hook_reply", id: msg.id, ok: false, error });
}
return;
}
if (msg.type === "shutdown") {
if (plugin && ctx && typeof plugin.teardown === "function") {
try {
@@ -195,6 +195,7 @@ export async function ensurePluginExtracted(
const runtime = normalizePluginRuntime(extraConfig.runtime, wasmEntry !== null);
const needs = normalizePluginNeeds(extraConfig.needs);
const build = normalizePluginBuildIntegration(extraConfig.build);
const manifest: PluginManifest = {
id: safeId,
name: extraConfig.name || safeId,
@@ -212,6 +213,7 @@ export async function ensurePluginExtracted(
js: `${safeId}.js`,
},
...(extraConfig.navbar && { navbar: extraConfig.navbar }),
...(build && { build }),
hasServer: serverEntry !== null || !!serverTsEntry,
};
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2));
@@ -622,6 +624,71 @@ function normalizePluginRuntime(runtime: any, hasWasm: boolean): "native" | "was
return "native";
}
function normalizePluginBuildIntegration(raw: any): any | undefined {
if (!raw || typeof raw !== "object") return undefined;
const build: any = {};
if (typeof raw.enabledByDefault === "boolean") build.enabledByDefault = raw.enabledByDefault;
if (typeof raw.label === "string" && raw.label.trim()) build.label = raw.label.trim().slice(0, 80);
if (typeof raw.description === "string" && raw.description.trim()) build.description = raw.description.trim().slice(0, 300);
const settings: any[] = [];
if (Array.isArray(raw.settings)) {
for (const item of raw.settings.slice(0, 20)) {
if (!item || typeof item !== "object") continue;
const key = typeof item.key === "string" ? item.key.trim() : "";
if (!/^[A-Za-z0-9_.-]{1,64}$/.test(key)) continue;
const type = typeof item.type === "string" ? item.type.trim().toLowerCase() : "string";
if (!["string", "number", "boolean", "select", "textarea"].includes(type)) continue;
const setting: any = { key, type };
if (typeof item.label === "string" && item.label.trim()) setting.label = item.label.trim().slice(0, 80);
if (typeof item.placeholder === "string") setting.placeholder = item.placeholder.slice(0, 120);
if (typeof item.description === "string" && item.description.trim()) setting.description = item.description.trim().slice(0, 240);
if (typeof item.required === "boolean") setting.required = item.required;
if (typeof item.min === "number" && Number.isFinite(item.min)) setting.min = item.min;
if (typeof item.max === "number" && Number.isFinite(item.max)) setting.max = item.max;
if (item.default !== undefined) setting.default = item.default;
if (Array.isArray(item.options)) {
const options = item.options.slice(0, 50).map((opt: any) => {
if (typeof opt === "string") return opt.slice(0, 120);
if (opt && typeof opt === "object" && typeof opt.value === "string") {
return {
value: opt.value.slice(0, 120),
...(typeof opt.label === "string" && { label: opt.label.slice(0, 120) }),
};
}
return null;
}).filter(Boolean);
if (options.length > 0) setting.options = options;
}
settings.push(setting);
}
}
if (settings.length > 0) build.settings = settings;
const actions: any[] = [];
if (Array.isArray(raw.actions)) {
for (const item of raw.actions.slice(0, 12)) {
if (!item || typeof item !== "object") continue;
const id = typeof item.id === "string" ? item.id.trim() : "";
const label = typeof item.label === "string" ? item.label.trim() : "";
if (!/^[A-Za-z0-9_.-]{1,64}$/.test(id) || !label) continue;
actions.push({
id,
label: label.slice(0, 80),
...(typeof item.icon === "string" && { icon: item.icon.trim().slice(0, 80) }),
...(typeof item.description === "string" && { description: item.description.trim().slice(0, 240) }),
...(item.setBuild && typeof item.setBuild === "object" && !Array.isArray(item.setBuild) && { setBuild: item.setBuild }),
...(item.setSettings && typeof item.setSettings === "object" && !Array.isArray(item.setSettings) && { setSettings: item.setSettings }),
...(Array.isArray(item.requires) && { requires: item.requires.slice(0, 20) }),
});
}
}
if (actions.length > 0) build.actions = actions;
if (Array.isArray(raw.requires)) build.requires = raw.requires.slice(0, 20);
return Object.keys(build).length > 0 ? build : undefined;
}
export function detectPluginIdFromZip(zip: any): string | null {
const entries = zip.getEntries();
for (const entry of entries) {
@@ -21,7 +21,7 @@ import { logger } from "../../logger";
import { normalizeClientOs } from "../deploy-utils";
import { canUserBuild, recordBuildStart, recordBuildEnd } from "../../build-rate-limit";
import { getConfig } from "../../config";
import { canUploadFiles } from "../../users";
import { canUploadFiles, canUserAccessPlugin } from "../../users";
import { addBuildToBanlist, removeBuildFromBanlist } from "../build-signing";
import path from "path";
import fs from "fs";
@@ -37,8 +37,93 @@ type BuildRouteDeps = {
startBuildProcess: (buildId: string, config: any) => Promise<void>;
sanitizeMutex: (value?: string) => string | undefined;
allowedPlatforms: Set<string>;
listPluginManifests?: () => Promise<any[]>;
};
function getPathValue(source: any, dotted: string): any {
if (!source || !dotted) return undefined;
return dotted.split(".").reduce((cur, key) => (cur && typeof cur === "object" ? cur[key] : undefined), source);
}
function requirementMet(req: any, buildConfig: any, pluginSettings: any): boolean {
if (!req || typeof req !== "object") return true;
let value: any;
if (typeof req.field === "string") value = getPathValue(buildConfig, req.field);
else if (typeof req.pluginSetting === "string") value = getPathValue(pluginSettings, req.pluginSetting);
else if (Array.isArray(req.platforms)) {
const platforms = Array.isArray(buildConfig.platforms) ? buildConfig.platforms : [];
return req.platforms.some((p: any) => typeof p === "string" && platforms.includes(p));
} else {
return true;
}
if (req.truthy === true && !value) return false;
if (req.falsy === true && value) return false;
if (Object.prototype.hasOwnProperty.call(req, "equals") && value !== req.equals) return false;
if (Object.prototype.hasOwnProperty.call(req, "notEquals") && value === req.notEquals) return false;
if (Object.prototype.hasOwnProperty.call(req, "includes")) {
if (!Array.isArray(value) || !value.includes(req.includes)) return false;
}
return true;
}
function sanitizePluginBuildValue(setting: any, value: any): any {
const type = typeof setting?.type === "string" ? setting.type : "string";
if (value === undefined || value === null || value === "") {
return setting?.default !== undefined ? setting.default : undefined;
}
if (type === "boolean") return value === true || value === "true" || value === "1";
if (type === "number") {
const n = Number(value);
if (!Number.isFinite(n)) return setting?.default !== undefined ? setting.default : undefined;
const min = typeof setting.min === "number" ? setting.min : -1_000_000_000;
const max = typeof setting.max === "number" ? setting.max : 1_000_000_000;
return Math.max(min, Math.min(max, n));
}
const str = String(value).slice(0, type === "textarea" ? 10_000 : 1_000);
if (type === "select" && Array.isArray(setting.options)) {
const allowed = new Set(setting.options.map((opt: any) => typeof opt === "string" ? opt : opt?.value).filter((v: any) => typeof v === "string"));
return allowed.has(str) ? str : setting.default;
}
return str;
}
async function sanitizeBuildPlugins(
raw: any,
deps: BuildRouteDeps,
user: any,
buildConfig: any,
): Promise<{ value: Record<string, any>; error?: string }> {
if (!raw || typeof raw !== "object" || !deps.listPluginManifests) return { value: {} };
const manifests = await deps.listPluginManifests();
const byId = new Map(manifests.map((m) => [m.id, m]));
const value: Record<string, any> = {};
for (const [pluginId, rawPlugin] of Object.entries(raw)) {
if (!/^[A-Za-z0-9._-]{1,128}$/.test(pluginId)) continue;
const manifest = byId.get(pluginId);
if (!manifest?.build || !canUserAccessPlugin(user.userId, user.role, pluginId)) continue;
const rawRecord = rawPlugin && typeof rawPlugin === "object" ? rawPlugin as any : {};
const enabled = rawRecord.enabled !== false;
const settings: Record<string, any> = {};
for (const setting of Array.isArray(manifest.build.settings) ? manifest.build.settings : []) {
if (!setting || typeof setting.key !== "string") continue;
const sanitized = sanitizePluginBuildValue(setting, rawRecord.settings?.[setting.key]);
if (enabled && setting.required && (sanitized === undefined || sanitized === "")) {
return { value: {}, error: `Build plugin ${manifest.name || pluginId} requires ${setting.label || setting.key}` };
}
if (sanitized !== undefined) settings[setting.key] = sanitized;
}
value[pluginId] = { enabled, settings };
if (enabled) {
for (const req of Array.isArray(manifest.build.requires) ? manifest.build.requires : []) {
if (!requirementMet(req, buildConfig, settings)) {
return { value: {}, error: req.message || `Build plugin ${manifest.name || pluginId} requirements are not met` };
}
}
}
}
return { value };
}
export async function handleBuildRoutes(
req: Request,
url: URL,
@@ -120,6 +205,7 @@ export async function handleBuildRoutes(
outputSgnTxt,
fetchPublicIP,
uploadToFileShare,
buildPlugins,
} = body;
if (!platforms || !Array.isArray(platforms) || platforms.length === 0) {
@@ -377,7 +463,7 @@ export async function handleBuildRoutes(
const rateLimitActive = getConfig().registration.mode !== "off";
if (rateLimitActive) recordBuildStart(user.userId);
deps.startBuildProcess(buildId, {
const safeBuildConfig: any = {
platforms: allowedPlatforms,
serverUrl: safeServerUrl,
rawServerList: safeRawServerList,
@@ -424,13 +510,42 @@ export async function handleBuildRoutes(
outputSgnTxt: safeOutputSgnTxt,
fetchPublicIP: !!fetchPublicIP,
uploadToFileShare: safeUploadToFileShare,
}).finally(() => {
};
const pluginBuildResult = await sanitizeBuildPlugins(buildPlugins, deps, user, safeBuildConfig);
if (pluginBuildResult.error) {
if (rateLimitActive) recordBuildEnd(user.userId);
return Response.json({ error: pluginBuildResult.error }, { status: 400 });
}
safeBuildConfig.buildPlugins = pluginBuildResult.value;
deps.startBuildProcess(buildId, safeBuildConfig).finally(() => {
if (rateLimitActive) recordBuildEnd(user.userId);
});
return Response.json({ buildId });
}
if (req.method === "GET" && url.pathname === "/api/build/plugins") {
requirePermission(user, "clients:build");
if (!deps.listPluginManifests) {
return Response.json({ plugins: [] });
}
const manifests = await deps.listPluginManifests();
const plugins = manifests
.filter((manifest) => manifest?.build && manifest?.hasServer === true)
.filter((manifest) => canUserAccessPlugin(user.userId, user.role, manifest.id))
.map((manifest) => ({
id: manifest.id,
name: manifest.name || manifest.id,
runtime: manifest.runtime || "native",
hasServer: manifest.hasServer === true,
enabled: manifest.enabled !== false,
build: manifest.build,
}));
return Response.json({ plugins });
}
if (req.method === "GET" && url.pathname === "/api/build/list") {
requirePermission(user, "clients:build");
@@ -164,21 +164,25 @@ export async function handlePluginRoutes(
const plugins = await deps.listPluginManifests();
const enriched = plugins
.filter((p) => canUserAccessPlugin(user.userId, user.role, p.id))
.map((p) => ({
...p,
enabled: deps.pluginState.enabled[p.id] !== false,
lastError: deps.pluginState.lastError[p.id] || "",
autoLoad: deps.pluginState.autoLoad[p.id] === true,
autoStartEvents: deps.pluginState.autoStartEvents[p.id] || [],
signature: p.signature || { signed: false, trusted: false, valid: false },
runtime: p.runtime || "native",
apiVersion: p.apiVersion || 1,
needs: p.needs || {},
needsHash: p.needsHash || computePluginNeedsHash(p.needs),
needsApproved: arePluginNeedsApproved(deps.pluginState, p.id, p.needs),
hasServer: p.hasServer === true || deps.pluginRuntime.hasServerCode(p.id),
serverRunning: deps.pluginRuntime.isRunning(p.id),
}));
.map((p) => {
const runtime = p.runtime || "native";
const isServerOnly = runtime === "server";
return {
...p,
enabled: deps.pluginState.enabled[p.id] !== false,
lastError: deps.pluginState.lastError[p.id] || "",
autoLoad: isServerOnly ? false : deps.pluginState.autoLoad[p.id] === true,
autoStartEvents: isServerOnly ? [] : deps.pluginState.autoStartEvents[p.id] || [],
signature: p.signature || { signed: false, trusted: false, valid: false },
runtime,
apiVersion: p.apiVersion || 1,
needs: p.needs || {},
needsHash: p.needsHash || computePluginNeedsHash(p.needs),
needsApproved: arePluginNeedsApproved(deps.pluginState, p.id, p.needs),
hasServer: p.hasServer === true || deps.pluginRuntime.hasServerCode(p.id),
serverRunning: deps.pluginRuntime.isRunning(p.id),
};
});
return Response.json({ plugins: enriched });
}
@@ -261,6 +265,7 @@ export async function handlePluginRoutes(
const manifests = await deps.listPluginManifests();
const plugins = manifests
.filter((manifest) => canUserAccessPlugin(user.userId, user.role, manifest.id))
.filter((manifest) => manifest.runtime !== "server")
.map((manifest) => ({
id: manifest.id,
name: manifest.name || manifest.id,
@@ -451,6 +456,17 @@ export async function handlePluginRoutes(
body = await req.json();
} catch {}
const autoLoad = !!body.autoLoad;
const manifest = await loadManifest(pluginId);
if (manifest.runtime === "server") {
delete deps.pluginState.autoLoad[pluginId];
delete deps.pluginState.autoStartEvents[pluginId];
await deps.savePluginState();
return Response.json(
{ ok: false, error: "Server-side plugins do not support client auto-load" },
{ status: 400 },
);
}
if (autoLoad && deps.pluginState.enabled[pluginId] === false) {
return Response.json(
@@ -868,6 +884,13 @@ export async function handlePluginRoutes(
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
const manifest = await loadManifest(pluginId);
if (manifest.runtime === "server") {
return Response.json(
{ ok: false, error: "Server-side plugins do not load onto clients" },
{ status: 400 },
);
}
const target = clientManager.getClient(targetId);
if (!target) return new Response("Not found", { status: 404 });
if (deps.isPluginLoaded(targetId, pluginId)) {