diff --git a/Overlord-Server/public/assets/build.js b/Overlord-Server/public/assets/build.js
index c637df3..6448381 100644
--- a/Overlord-Server/public/assets/build.js
+++ b/Overlord-Server/public/assets/build.js
@@ -43,11 +43,15 @@ const startupNameMacosHint = document.getElementById("startup-name-macos-hint");
const startupNameDefaultHint = document.getElementById("startup-name-default-hint");
const startupNameError = document.getElementById("startup-name-error");
const platformInputs = document.querySelectorAll('input[name="platform"]');
+const buildPluginsSection = document.getElementById("build-plugins-section");
+const buildPluginsList = document.getElementById("build-plugins-list");
+const buildPluginsCount = document.getElementById("build-plugins-count");
let currentServerVersion = null;
let currentUserRole = null;
let currentUsername = null;
let showAllBuilds = false;
+let buildPlugins = [];
async function loadServerVersion() {
try {
@@ -123,6 +127,296 @@ function updateWindowsSectionVisibility() {
windowsSection.classList.toggle("hidden", !hasWindows);
}
+function pluginSettingInputId(pluginId, key) {
+ return `build-plugin-${pluginId}-${key}`.replace(/[^A-Za-z0-9_-]/g, "-");
+}
+
+function buildPluginCurrentSettings(plugin) {
+ const settings = {};
+ for (const setting of plugin.build?.settings || []) {
+ const el = document.querySelector(`[data-build-plugin-id="${plugin.id}"][data-build-setting-key="${setting.key}"]`);
+ if (!el) continue;
+ if (setting.type === "boolean") settings[setting.key] = !!el.checked;
+ else if (setting.type === "number") settings[setting.key] = Number(el.value || setting.default || 0);
+ else settings[setting.key] = el.value;
+ }
+ return settings;
+}
+
+function collectBuildPluginSettings() {
+ const result = {};
+ for (const plugin of buildPlugins) {
+ const enabledEl = document.querySelector(`[data-build-plugin-enable="${plugin.id}"]`);
+ result[plugin.id] = {
+ enabled: enabledEl ? !!enabledEl.checked : plugin.build?.enabledByDefault !== false,
+ settings: buildPluginCurrentSettings(plugin),
+ };
+ }
+ return result;
+}
+
+function setBuildField(field, value) {
+ const fieldMap = {
+ useDonut: "#donut-mode",
+ useLinuxShellcode: "#linux-shellcode-mode",
+ shellcodeConsole: "#shellcode-console",
+ useSgn: "#sgn-mode",
+ enableUpx: 'input[name="enable-upx"]',
+ upxStripHeaders: 'input[name="upx-strip-headers"]',
+ obfuscate: 'input[name="obfuscate"]',
+ garbleLiterals: 'input[name="garble-literals"]',
+ garbleTiny: 'input[name="garble-tiny"]',
+ disableCgo: 'input[name="disable-cgo"]',
+ stripDebug: 'input[name="strip-debug"]',
+ noPrinting: 'input[name="no-printing"]',
+ enableWebrtc: 'input[name="enable-webrtc"]',
+ enableWinRE: 'input[name="enable-winre"]',
+ fetchPublicIP: 'input[name="fetch-public-ip"]',
+ enablePersistence: 'input[name="enable-persistence"]',
+ hideConsole: 'input[name="hide-console"]',
+ requireAdmin: 'input[name="require-admin"]',
+ criticalProcess: 'input[name="critical-process"]',
+ outputName: "#output-name",
+ outputExtension: "#output-extension",
+ sgnIterations: "#sgn-iterations",
+ sleepSeconds: "#sleep-seconds",
+ };
+ const selector = fieldMap[field] || `#${field}`;
+ const el = document.querySelector(selector);
+ if (!el) return;
+ if (el.type === "checkbox") el.checked = !!value;
+ else el.value = String(value ?? "");
+ el.dispatchEvent(new Event("change", { bubbles: true }));
+ el.dispatchEvent(new Event("input", { bubbles: true }));
+ if (field === "useDonut") applyDonutMode(!!value);
+ if (field === "useLinuxShellcode") applyLinuxShellcodeMode(!!value);
+ if (field === "useSgn") applySgnMode(!!value);
+}
+
+function getRequirementValue(req, plugin) {
+ if (req.field) {
+ const settings = collectFormSettings();
+ return req.field.split(".").reduce((cur, key) => cur && cur[key], settings);
+ }
+ if (req.pluginSetting) {
+ const settings = buildPluginCurrentSettings(plugin);
+ return req.pluginSetting.split(".").reduce((cur, key) => cur && cur[key], settings);
+ }
+ return undefined;
+}
+
+function buildRequirementMet(req, plugin) {
+ if (Array.isArray(req.platforms)) {
+ const selected = Array.from(document.querySelectorAll('input[name="platform"]:checked')).map((el) => el.value);
+ return req.platforms.some((platform) => selected.includes(platform));
+ }
+ const value = getRequirementValue(req, plugin);
+ 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 validateBuildPluginRequirements() {
+ const messages = [];
+ for (const plugin of buildPlugins) {
+ const enabled = document.querySelector(`[data-build-plugin-enable="${plugin.id}"]`)?.checked;
+ if (!enabled) continue;
+ for (const req of plugin.build?.requires || []) {
+ if (!buildRequirementMet(req, plugin)) {
+ messages.push(req.message || `${plugin.name} has unmet build requirements`);
+ }
+ }
+ }
+ return messages;
+}
+
+function renderBuildPluginSetting(plugin, setting) {
+ const id = pluginSettingInputId(plugin.id, setting.key);
+ const wrap = document.createElement("label");
+ wrap.className = "flex flex-col gap-1 text-sm";
+
+ const label = document.createElement("span");
+ label.className = "text-slate-300 font-medium";
+ label.textContent = setting.label || setting.key;
+ wrap.appendChild(label);
+
+ let input;
+ if (setting.type === "boolean") {
+ wrap.className = "flex items-center gap-2 text-sm text-slate-300";
+ input = document.createElement("input");
+ input.type = "checkbox";
+ input.checked = !!setting.default;
+ input.className = "w-4 h-4";
+ wrap.innerHTML = "";
+ wrap.appendChild(input);
+ wrap.appendChild(label);
+ } else if (setting.type === "select") {
+ input = document.createElement("select");
+ input.className = "w-full px-3 py-2 bg-slate-800/70 border border-slate-700 rounded-lg text-sm focus:outline-none focus:border-fuchsia-500 transition-colors";
+ for (const opt of setting.options || []) {
+ const option = document.createElement("option");
+ option.value = typeof opt === "string" ? opt : opt.value;
+ option.textContent = typeof opt === "string" ? opt : (opt.label || opt.value);
+ input.appendChild(option);
+ }
+ if (setting.default !== undefined) input.value = String(setting.default);
+ wrap.appendChild(input);
+ } else if (setting.type === "textarea") {
+ input = document.createElement("textarea");
+ input.rows = 3;
+ input.value = setting.default !== undefined ? String(setting.default) : "";
+ input.placeholder = setting.placeholder || "";
+ input.className = "w-full px-3 py-2 bg-slate-800/70 border border-slate-700 rounded-lg text-sm focus:outline-none focus:border-fuchsia-500 transition-colors";
+ wrap.appendChild(input);
+ } else {
+ input = document.createElement("input");
+ input.type = setting.type === "number" ? "number" : "text";
+ if (setting.min !== undefined) input.min = String(setting.min);
+ if (setting.max !== undefined) input.max = String(setting.max);
+ input.value = setting.default !== undefined ? String(setting.default) : "";
+ input.placeholder = setting.placeholder || "";
+ input.className = "w-full px-3 py-2 bg-slate-800/70 border border-slate-700 rounded-lg text-sm focus:outline-none focus:border-fuchsia-500 transition-colors";
+ wrap.appendChild(input);
+ }
+ input.id = id;
+ input.dataset.buildPluginId = plugin.id;
+ input.dataset.buildSettingKey = setting.key;
+ input.addEventListener("input", saveFormSettings);
+ input.addEventListener("change", saveFormSettings);
+
+ if (setting.description) {
+ const desc = document.createElement("span");
+ desc.className = "text-xs text-slate-500";
+ desc.textContent = setting.description;
+ wrap.appendChild(desc);
+ }
+ return wrap;
+}
+
+function renderBuildPlugins() {
+ if (!buildPluginsSection || !buildPluginsList) return;
+ buildPluginsList.innerHTML = "";
+ if (buildPlugins.length === 0) {
+ buildPluginsSection.classList.add("hidden");
+ return;
+ }
+ buildPluginsSection.classList.remove("hidden");
+ if (buildPluginsCount) buildPluginsCount.textContent = `${buildPlugins.length} available`;
+
+ for (const plugin of buildPlugins) {
+ const card = document.createElement("div");
+ card.className = "rounded-lg border border-slate-700 bg-slate-800/40 p-3 flex flex-col gap-3";
+
+ const top = document.createElement("div");
+ top.className = "flex items-start justify-between gap-3";
+ const title = document.createElement("div");
+ const nameEl = document.createElement("div");
+ nameEl.className = "font-medium text-slate-100";
+ nameEl.textContent = plugin.build?.label || plugin.name;
+ title.appendChild(nameEl);
+ if (plugin.build?.description) {
+ const desc = document.createElement("div");
+ desc.className = "text-xs text-slate-500 mt-1";
+ desc.textContent = plugin.build.description;
+ title.appendChild(desc);
+ }
+ const toggle = document.createElement("label");
+ toggle.className = "flex items-center gap-2 text-xs text-slate-300 cursor-pointer";
+ const cb = document.createElement("input");
+ cb.type = "checkbox";
+ cb.checked = plugin.build?.enabledByDefault !== false;
+ cb.dataset.buildPluginEnable = plugin.id;
+ cb.className = "w-4 h-4";
+ cb.addEventListener("change", saveFormSettings);
+ toggle.appendChild(cb);
+ toggle.appendChild(document.createTextNode("Enabled"));
+ top.appendChild(title);
+ top.appendChild(toggle);
+ card.appendChild(top);
+
+ if (plugin.build?.settings?.length) {
+ const grid = document.createElement("div");
+ grid.className = "grid grid-cols-1 md:grid-cols-2 gap-3";
+ for (const setting of plugin.build.settings) grid.appendChild(renderBuildPluginSetting(plugin, setting));
+ card.appendChild(grid);
+ }
+
+ if (plugin.build?.actions?.length) {
+ const actions = document.createElement("div");
+ actions.className = "flex flex-wrap gap-2";
+ for (const action of plugin.build.actions) {
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = "inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-fuchsia-700 hover:bg-fuchsia-600 text-white text-sm transition-colors";
+ const icon = document.createElement("i");
+ icon.className = action.icon || "fa-solid fa-wand-magic-sparkles";
+ const text = document.createElement("span");
+ text.textContent = action.label;
+ btn.appendChild(icon);
+ btn.appendChild(text);
+ if (action.description) btn.title = action.description;
+ btn.addEventListener("click", () => {
+ for (const req of action.requires || []) {
+ if (!buildRequirementMet(req, plugin)) {
+ alert(req.message || "This action requires other build settings first.");
+ return;
+ }
+ }
+ for (const [field, value] of Object.entries(action.setBuild || {})) setBuildField(field, value);
+ for (const [key, value] of Object.entries(action.setSettings || {})) {
+ const el = document.querySelector(`[data-build-plugin-id="${plugin.id}"][data-build-setting-key="${key}"]`);
+ if (!el) continue;
+ if (el.type === "checkbox") el.checked = !!value;
+ else el.value = String(value ?? "");
+ el.dispatchEvent(new Event("change", { bubbles: true }));
+ }
+ saveFormSettings();
+ });
+ actions.appendChild(btn);
+ }
+ card.appendChild(actions);
+ }
+ buildPluginsList.appendChild(card);
+ }
+}
+
+function applyBuildPluginSettings(settings) {
+ if (!settings || typeof settings !== "object") return;
+ for (const plugin of buildPlugins) {
+ const saved = settings[plugin.id];
+ if (!saved || typeof saved !== "object") continue;
+ const enabled = document.querySelector(`[data-build-plugin-enable="${plugin.id}"]`);
+ if (enabled && saved.enabled !== undefined) enabled.checked = !!saved.enabled;
+ for (const [key, value] of Object.entries(saved.settings || {})) {
+ const el = document.querySelector(`[data-build-plugin-id="${plugin.id}"][data-build-setting-key="${key}"]`);
+ if (!el) continue;
+ if (el.type === "checkbox") el.checked = !!value;
+ else el.value = String(value ?? "");
+ }
+ }
+}
+
+async function loadBuildPlugins() {
+ try {
+ const res = await fetch("/api/build/plugins", { credentials: "include" });
+ if (!res.ok) return;
+ const data = await res.json();
+ buildPlugins = Array.isArray(data.plugins) ? data.plugins : [];
+ renderBuildPlugins();
+ try {
+ const raw = localStorage.getItem(BUILD_SETTINGS_KEY);
+ if (raw) applyBuildPluginSettings(JSON.parse(raw).buildPlugins);
+ } catch {}
+ } catch (err) {
+ console.warn("Failed to load build plugins:", err);
+ }
+}
+
const BUILD_SETTINGS_KEY = "overlord_build_settings";
function collectFormSettings() {
@@ -169,6 +463,7 @@ function collectFormSettings() {
shellcodeConsole: document.getElementById("shellcode-console")?.checked ?? false,
useSgn: document.getElementById("sgn-mode")?.checked ?? false,
sgnIterations: parseInt(document.getElementById("sgn-iterations")?.value, 10) || 1,
+ buildPlugins: collectBuildPluginSettings(),
};
}
@@ -247,6 +542,7 @@ function applyFormSettings(settings) {
if (settings.useSgn) applySgnMode(true);
}
if (settings.sgnIterations !== undefined) setVal("sgn-iterations", settings.sgnIterations);
+ if (settings.buildPlugins !== undefined) applyBuildPluginSettings(settings.buildPlugins);
const restoredObfuscate = document.querySelector('input[name="obfuscate"]');
const garbleContainer = document.getElementById("garble-settings-container");
@@ -509,6 +805,7 @@ function applyLinuxShellcodeMode(enabled) {
restoreFormSettings();
initAccordions();
+loadBuildPlugins();
updateWindowsSectionVisibility();
updateShellcodeCheckboxVisibility();
init();
@@ -1445,6 +1742,7 @@ form?.addEventListener("submit", async (e) => {
sgnIterations: parseInt(document.getElementById("sgn-iterations")?.value, 10) || 1,
outputSgnTxt: pendingSgnTxtBuild,
uploadToFileShare: pendingUpload,
+ buildPlugins: collectBuildPluginSettings(),
};
const hasAndroid = platforms.some(p => p.startsWith('android-'));
@@ -1486,6 +1784,12 @@ form?.addEventListener("submit", async (e) => {
}
}
+ const pluginRequirementErrors = validateBuildPluginRequirements();
+ if (pluginRequirementErrors.length > 0) {
+ alert(`Build plugin requirements are not met:\n\n${pluginRequirementErrors.map((msg) => `- ${msg}`).join("\n")}`);
+ return;
+ }
+
await startBuild(buildConfig);
});
diff --git a/Overlord-Server/public/assets/plugins.js b/Overlord-Server/public/assets/plugins.js
index 73508c8..cb12f35 100644
--- a/Overlord-Server/public/assets/plugins.js
+++ b/Overlord-Server/public/assets/plugins.js
@@ -114,9 +114,12 @@ function renderPlugins(plugins) {
return;
}
for (const plugin of plugins) {
+ const isServerOnly = plugin.runtime === "server";
const card = document.createElement("div");
card.className =
- "rounded-xl border border-slate-800 bg-slate-950/50 px-4 py-3 flex items-center justify-between";
+ isServerOnly
+ ? "rounded-xl border border-fuchsia-800/60 bg-fuchsia-950/20 px-4 py-3 flex items-center justify-between"
+ : "rounded-xl border border-slate-800 bg-slate-950/50 px-4 py-3 flex items-center justify-between";
const meta = document.createElement("div");
const titleRow = document.createElement("div");
titleRow.className = "flex items-center gap-2";
@@ -137,13 +140,17 @@ function renderPlugins(plugins) {
const isWasm = plugin.runtime === "wasm";
const isV2 = Number(plugin.apiVersion || 1) >= 2;
runtimeBadge.className = `inline-flex items-center gap-1 text-[10px] uppercase tracking-wider px-2 py-0.5 rounded-full border ${
- isWasm
+ isServerOnly
+ ? "text-fuchsia-200 border-fuchsia-700 bg-fuchsia-950/50"
+ : isWasm
? "text-cyan-300 border-cyan-700 bg-cyan-950/40"
: isV2
? "text-emerald-300 border-emerald-700 bg-emerald-950/40"
: "text-slate-300 border-slate-700 bg-slate-900/60"
}`;
- runtimeBadge.innerHTML = isWasm
+ runtimeBadge.innerHTML = isServerOnly
+ ? ' Server Extension'
+ : isWasm
? ' WASM 2.0'
: isV2
? ' Plugin 2.0'
@@ -171,12 +178,37 @@ function renderPlugins(plugins) {
needsBox.innerHTML = `
- ${plugin.needsApproved ? "Needs approved" : "Needs approval before load"}
+ ${plugin.needsApproved ? "Needs approved" : isServerOnly ? "Needs approval before enable" : "Needs approval before load"}
${needLines.map((line) => `${escapeHtml(line)}
`).join("")}
`;
meta.appendChild(needsBox);
}
+ if (isServerOnly) {
+ const serverBox = document.createElement("div");
+ serverBox.className = "mt-2 flex flex-wrap items-center gap-2 text-xs";
+ const state = document.createElement("span");
+ state.className = `inline-flex items-center gap-1 px-2 py-1 rounded-lg border ${
+ plugin.serverRunning
+ ? "text-emerald-200 border-emerald-700 bg-emerald-950/40"
+ : plugin.enabled
+ ? "text-amber-200 border-amber-700 bg-amber-950/40"
+ : "text-slate-300 border-slate-700 bg-slate-900/60"
+ }`;
+ state.innerHTML = plugin.serverRunning
+ ? ' Worker running'
+ : plugin.enabled
+ ? ' Enabled, worker stopped'
+ : ' Disabled';
+ serverBox.appendChild(state);
+ if (plugin.build) {
+ const buildBadge = document.createElement("span");
+ buildBadge.className = "inline-flex items-center gap-1 px-2 py-1 rounded-lg border text-blue-200 border-blue-700 bg-blue-950/40";
+ buildBadge.innerHTML = ' Build plugin';
+ serverBox.appendChild(buildBadge);
+ }
+ meta.appendChild(serverBox);
+ }
const actions = document.createElement("div");
actions.className = "flex items-center gap-2";
@@ -186,9 +218,13 @@ function renderPlugins(plugins) {
(plugin.enabled
? " border-emerald-600 text-emerald-200 bg-emerald-900/40"
: " border-slate-600 text-slate-300 bg-slate-800/60");
- toggle.innerHTML = plugin.enabled
- ? ' Enabled'
- : ' Disabled';
+ toggle.innerHTML = isServerOnly
+ ? plugin.enabled
+ ? ' Disable'
+ : ' Enable'
+ : plugin.enabled
+ ? ' Enabled'
+ : ' Disabled';
toggle.addEventListener("click", async () => {
const wantEnabled = !plugin.enabled;
try {
@@ -219,56 +255,59 @@ function renderPlugins(plugins) {
}
});
- const autoLoadBtn = document.createElement("button");
- const autoLoadDisabled = !plugin.enabled;
- autoLoadBtn.className =
- "inline-flex items-center gap-2 px-3 py-2 rounded-lg border" +
- (autoLoadDisabled
- ? " border-slate-700 text-slate-500 bg-slate-900/40 cursor-not-allowed opacity-50"
+ let autoLoadBtn = null;
+ if (!isServerOnly) {
+ autoLoadBtn = document.createElement("button");
+ const autoLoadDisabled = !plugin.enabled;
+ autoLoadBtn.className =
+ "inline-flex items-center gap-2 px-3 py-2 rounded-lg border" +
+ (autoLoadDisabled
+ ? " border-slate-700 text-slate-500 bg-slate-900/40 cursor-not-allowed opacity-50"
+ : plugin.autoLoad
+ ? " border-amber-600 text-amber-200 bg-amber-900/40"
+ : " border-slate-600 text-slate-300 bg-slate-800/60");
+ autoLoadBtn.innerHTML = plugin.autoLoad
+ ? ' Auto-load'
+ : ' Auto-load off';
+ autoLoadBtn.title = autoLoadDisabled
+ ? "Plugin must be enabled before auto-load can be turned on"
: plugin.autoLoad
- ? " border-amber-600 text-amber-200 bg-amber-900/40"
- : " border-slate-600 text-slate-300 bg-slate-800/60");
- autoLoadBtn.innerHTML = plugin.autoLoad
- ? ' Auto-load'
- : ' Auto-load off';
- autoLoadBtn.title = autoLoadDisabled
- ? "Plugin must be enabled before auto-load can be turned on"
- : plugin.autoLoad
- ? "Plugin will auto-load on all new client connections. Click to disable."
- : "Click to auto-load this plugin on all new client connections.";
- if (autoLoadDisabled) {
- autoLoadBtn.disabled = true;
- } else {
- autoLoadBtn.addEventListener("click", async () => {
- const res = await fetch(`/api/plugins/${plugin.id}/autoload`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- autoLoad: !plugin.autoLoad,
- autoStartEvents: plugin.autoStartEvents || [],
- }),
- });
- if (!res.ok) {
- const data = await res.json().catch(() => null);
- if (data && data.error === "needs_approval_required") {
- showNeedsApprovalModal(plugin, data.needs, data.needsHash, async () => {
- await fetch(`/api/plugins/${plugin.id}/autoload`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- autoLoad: !plugin.autoLoad,
- autoStartEvents: plugin.autoStartEvents || [],
- }),
+ ? "Plugin will auto-load on all new client connections. Click to disable."
+ : "Click to auto-load this plugin on all new client connections.";
+ if (autoLoadDisabled) {
+ autoLoadBtn.disabled = true;
+ } else {
+ autoLoadBtn.addEventListener("click", async () => {
+ const res = await fetch(`/api/plugins/${plugin.id}/autoload`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ autoLoad: !plugin.autoLoad,
+ autoStartEvents: plugin.autoStartEvents || [],
+ }),
+ });
+ if (!res.ok) {
+ const data = await res.json().catch(() => null);
+ if (data && data.error === "needs_approval_required") {
+ showNeedsApprovalModal(plugin, data.needs, data.needsHash, async () => {
+ await fetch(`/api/plugins/${plugin.id}/autoload`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ autoLoad: !plugin.autoLoad,
+ autoStartEvents: plugin.autoStartEvents || [],
+ }),
+ });
+ await refresh();
});
- await refresh();
- });
+ return;
+ }
+ setStatus(`Auto-load toggle failed: ${data?.error || res.statusText}`, true);
return;
}
- setStatus(`Auto-load toggle failed: ${data?.error || res.statusText}`, true);
- return;
- }
- await refresh();
- });
+ await refresh();
+ });
+ }
}
if (needLines.length && !plugin.needsApproved) {
@@ -296,7 +335,7 @@ function renderPlugins(plugins) {
await refresh();
});
actions.appendChild(toggle);
- actions.appendChild(autoLoadBtn);
+ if (autoLoadBtn) actions.appendChild(autoLoadBtn);
actions.appendChild(removeBtn);
card.appendChild(meta);
card.appendChild(actions);
@@ -339,7 +378,7 @@ function showNeedsApprovalModal(plugin, needs, needsHash, afterApprove) {
Approve Plugin Needs
- ${escapeHtml(plugin.name || plugin.id)} is asking for filesystem bridges before it can be sent to clients.
+ ${escapeHtml(plugin.name || plugin.id)} is asking for filesystem bridges before it can ${plugin.runtime === "server" ? "be enabled" : "be sent to clients"}.
${needLines.length ? needLines.map((line) => `
${escapeHtml(line)}
`).join("") : '
No filesystem needs declared.
'}
diff --git a/Overlord-Server/public/build.html b/Overlord-Server/public/build.html
index 79fd02b..fe01421 100644
--- a/Overlord-Server/public/build.html
+++ b/Overlord-Server/public/build.html
@@ -930,6 +930,23 @@
+
+
+
Plugins
-
Upload zip bundles and manage plugin UI
+
Upload zip bundles and manage client plugins or server extensions
diff --git a/Overlord-Server/src/main-server.ts b/Overlord-Server/src/main-server.ts
index a03d63f..6c32868 100644
--- a/Overlord-Server/src/main-server.ts
+++ b/Overlord-Server/src/main-server.ts
@@ -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();
@@ -496,6 +497,7 @@ async function startServer() {
startBuildProcess,
sanitizeMutex,
allowedPlatforms: ALLOWED_PLATFORMS,
+ listPluginManifests,
},
deploy: {
DEPLOY_ROOT,
diff --git a/Overlord-Server/src/protocol.ts b/Overlord-Server/src/protocol.ts
index 4aadcb2..5c99140 100644
--- a/Overlord-Server/src/protocol.ts
+++ b/Overlord-Server/src/protocol.ts
@@ -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;
+ setSettings?: Record;
+ 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>;
diff --git a/Overlord-Server/src/server/build-process.ts b/Overlord-Server/src/server/build-process.ts
index a38c482..743d48a 100644
--- a/Overlord-Server/src/server/build-process.ts
+++ b/Overlord-Server/src/server/build-process.ts
@@ -122,8 +122,110 @@ type BuildProcessConfig = {
iosBundleId?: string;
fetchPublicIP?: boolean;
uploadToFileShare?: boolean;
+ buildPlugins?: Record }>;
};
+type BuildHookRunner = (
+ hook: string,
+ payload: unknown,
+) => Promise>;
+
+type BuildHookMessage = {
+ text: string;
+ level?: "debug" | "info" | "warn" | "error" | "success";
+};
+
+function cloneForHook(value: T): T {
+ return JSON.parse(JSON.stringify(value ?? null));
+}
+
+function isRecord(value: unknown): value is Record {
+ 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> {
+ 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;
+}) {
+ 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 });
diff --git a/Overlord-Server/src/server/plugin-runtime/runtime.ts b/Overlord-Server/src/server/plugin-runtime/runtime.ts
index 6093869..3ee6178 100644
--- a/Overlord-Server/src/server/plugin-runtime/runtime.ts
+++ b/Overlord-Server/src/server/plugin-runtime/runtime.ts
@@ -11,7 +11,7 @@ type Subscriber = {
close: () => void;
};
-type PendingRpc = {
+type PendingCall = {
resolve: (value: unknown) => void;
reject: (err: Error) => void;
timer: ReturnType;
@@ -24,12 +24,14 @@ type PluginInstance = {
resolveReady: () => void;
rejectReady: (err: Error) => void;
isReady: boolean;
- pending: Map;
+ pending: Map;
+ pendingBuildHooks: Map;
subscribers: Set;
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;
+ runBuildHook: (pluginId: string, hook: string, payload: unknown) => Promise;
+ runBuildHookForAll: (hook: string, payload: unknown) => Promise>;
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;
};
@@ -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 {
+ 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> {
+ 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,
};
}
diff --git a/Overlord-Server/src/server/plugin-runtime/types.ts b/Overlord-Server/src/server/plugin-runtime/types.ts
index 291defa..0387ba9 100644
--- a/Overlord-Server/src/server/plugin-runtime/types.ts
+++ b/Overlord-Server/src/server/plugin-runtime/types.ts
@@ -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 };
diff --git a/Overlord-Server/src/server/plugin-runtime/worker-host.ts b/Overlord-Server/src/server/plugin-runtime/worker-host.ts
index 15484e0..74bbcd4 100644
--- a/Overlord-Server/src/server/plugin-runtime/worker-host.ts
+++ b/Overlord-Server/src/server/plugin-runtime/worker-host.ts
@@ -12,6 +12,27 @@ type PluginExports = {
setup?: (ctx: PluginContext) => unknown | Promise;
onEvent?: (ctx: PluginContext, clientId: string, event: string, payload: unknown) => unknown | Promise;
rpc?: Record unknown | Promise>;
+ buildHooks?: Record unknown | Promise>;
+ onBuildPrepare?: (ctx: PluginContext, payload: unknown) => unknown | Promise;
+ onBuildTarget?: (ctx: PluginContext, payload: unknown) => unknown | Promise;
+ onBuildPostBuild?: (ctx: PluginContext, payload: unknown) => unknown | Promise;
+ onBuildBeforeUpx?: (ctx: PluginContext, payload: unknown) => unknown | Promise;
+ onBuildAfterUpx?: (ctx: PluginContext, payload: unknown) => unknown | Promise;
+ onBuildBeforeScriptWrapper?: (ctx: PluginContext, payload: unknown) => unknown | Promise;
+ onBuildAfterScriptWrapper?: (ctx: PluginContext, payload: unknown) => unknown | Promise;
+ onBuildBeforeIpa?: (ctx: PluginContext, payload: unknown) => unknown | Promise;
+ onBuildAfterIpa?: (ctx: PluginContext, payload: unknown) => unknown | Promise;
+ onBuildBeforeDonut?: (ctx: PluginContext, payload: unknown) => unknown | Promise;
+ onBuildAfterDonut?: (ctx: PluginContext, payload: unknown) => unknown | Promise;
+ onBuildBeforeLinuxShellcode?: (ctx: PluginContext, payload: unknown) => unknown | Promise;
+ onBuildAfterLinuxShellcode?: (ctx: PluginContext, payload: unknown) => unknown | Promise;
+ onBuildBeforeSgn?: (ctx: PluginContext, payload: unknown) => unknown | Promise;
+ onBuildAfterSgn?: (ctx: PluginContext, payload: unknown) => unknown | Promise;
+ onBuildBeforeSgnTxt?: (ctx: PluginContext, payload: unknown) => unknown | Promise;
+ onBuildAfterSgnTxt?: (ctx: PluginContext, payload: unknown) => unknown | Promise;
+ onBuildArtifact?: (ctx: PluginContext, payload: unknown) => unknown | Promise;
+ onBuildComplete?: (ctx: PluginContext, payload: unknown) => unknown | Promise;
+ onBuildFailed?: (ctx: PluginContext, payload: unknown) => unknown | Promise;
teardown?: (ctx: PluginContext) => unknown | Promise;
};
@@ -118,6 +139,49 @@ self.onmessage = async (e: MessageEvent) => {
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 {
diff --git a/Overlord-Server/src/server/plugin-state-bundle.ts b/Overlord-Server/src/server/plugin-state-bundle.ts
index 55297bc..9cce764 100644
--- a/Overlord-Server/src/server/plugin-state-bundle.ts
+++ b/Overlord-Server/src/server/plugin-state-bundle.ts
@@ -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) {
diff --git a/Overlord-Server/src/server/routes/build-routes.ts b/Overlord-Server/src/server/routes/build-routes.ts
index 45502dc..02e9cc7 100644
--- a/Overlord-Server/src/server/routes/build-routes.ts
+++ b/Overlord-Server/src/server/routes/build-routes.ts
@@ -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;
sanitizeMutex: (value?: string) => string | undefined;
allowedPlatforms: Set;
+ listPluginManifests?: () => Promise;
};
+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; 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 = {};
+ 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 = {};
+ 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");
diff --git a/Overlord-Server/src/server/routes/plugin-routes.ts b/Overlord-Server/src/server/routes/plugin-routes.ts
index ef9a654..98d1905 100644
--- a/Overlord-Server/src/server/routes/plugin-routes.ts
+++ b/Overlord-Server/src/server/routes/plugin-routes.ts
@@ -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)) {
diff --git a/plugins/PLUGINS.md b/plugins/PLUGINS.md
index 24a0734..4156ed8 100644
--- a/plugins/PLUGINS.md
+++ b/plugins/PLUGINS.md
@@ -1190,6 +1190,146 @@ export default {
| `ctx.log` | `{debug, info, warn, error}` | Each call is forwarded to the main server logger as `[plugin:] `. |
| `ctx.broadcast(channel, data)` | function | Push an event to every UI currently subscribed to this plugin's SSE stream. `data` must be JSON-serializable. |
+### Build hooks
+
+Server-side plugins can hook the agent builder without shipping any agent-side code. Only plugins with `server.js` or compiled `src/server.ts` can do this; browser UI code is never called from the build process.
+
+Add any of these handlers to your default export:
+
+```js
+export default {
+ onBuildPrepare(ctx, payload) {
+ // Runs once before the builder prepares toolchains. Return a config patch.
+ return { config: { outputName: "custom-agent" } };
+ },
+
+ onBuildTarget(ctx, payload) {
+ // Runs once per target after env/ldflags/tags are assembled.
+ if (payload.platform === "linux-amd64") {
+ return {
+ env: { CGO_ENABLED: "0" },
+ addTags: ["my_feature"],
+ ldflagsAppend: "-X overlord-client/cmd/agent/config.PluginChannel=stable",
+ message: "linux-amd64 template patched",
+ };
+ }
+ },
+
+ buildHooks: {
+ before_donut(ctx, payload) {
+ ctx.log.info(`Donut input: ${payload.file.filename}`);
+ },
+ after_donut(ctx, payload) {
+ ctx.log.info(`Donut output: ${payload.file.filename}`);
+ },
+ before_sgn(ctx, payload) {
+ ctx.log.info(`SGN iterations: ${payload.iterations}`);
+ },
+ after_sgn(ctx, payload) {
+ ctx.log.info(`SGN output: ${payload.file.filename}`);
+ },
+ },
+
+ async onBuildArtifact(ctx, payload) {
+ // Runs after each final artifact is written. Upload/copy/sign here.
+ const filePath = payload.file.path;
+ ctx.log.info(`artifact ready: ${filePath}`);
+ return { message: `saw ${payload.file.filename}` };
+ },
+
+ async onBuildComplete(ctx, payload) {
+ // Runs after all targets are built and before the build stream completes.
+ ctx.log.info(`build ${payload.buildId} produced ${payload.files.length} files`);
+ },
+
+ onBuildFailed(ctx, payload) {
+ ctx.log.warn(`build failed: ${payload.error}`);
+ },
+};
+```
+
+`onBuildTarget` may return:
+
+| Field | Effect |
+|-------|--------|
+| `env` | Merges string values into the Go build environment. |
+| `ldflags` | Replaces the full `-ldflags` string. |
+| `ldflagsAppend` | Appends to the existing `-ldflags` string. |
+| `tags` | Replaces the full tag list. |
+| `addTags` / `removeTags` | Adds or removes build tags. |
+| `outputName` | Renames the target output file. The name is sanitized by the server. |
+| `skip: true` | Skips that target. |
+| `message` / `messages` | Writes informational lines into the build stream. |
+
+The builder also emits these transform-stage hooks. You can implement them either in the `buildHooks` map using the snake-case name, or as a named method such as `onBuildBeforeDonut`.
+
+| Hook | Named method | When it runs |
+|------|--------------|--------------|
+| `post_build` | `onBuildPostBuild` | After the raw Go build output exists. |
+| `before_upx` / `after_upx` | `onBuildBeforeUpx` / `onBuildAfterUpx` | Around UPX compression. |
+| `before_script_wrapper` / `after_script_wrapper` | `onBuildBeforeScriptWrapper` / `onBuildAfterScriptWrapper` | Around `.bat`, `.cmd`, and `.ps1` wrapping. |
+| `before_ipa` / `after_ipa` | `onBuildBeforeIpa` / `onBuildAfterIpa` | Around iOS IPA packaging. |
+| `before_donut` / `after_donut` | `onBuildBeforeDonut` / `onBuildAfterDonut` | Around Windows PE to shellcode conversion with Donut. |
+| `before_linux_shellcode` / `after_linux_shellcode` | `onBuildBeforeLinuxShellcode` / `onBuildAfterLinuxShellcode` | Around Linux ELF shellcode wrapping. |
+| `before_sgn` / `after_sgn` | `onBuildBeforeSgn` / `onBuildAfterSgn` | Around SGN shellcode encoding. |
+| `before_sgn_txt` / `after_sgn_txt` | `onBuildBeforeSgnTxt` / `onBuildAfterSgnTxt` | Around SGN TXT artifact conversion. |
+
+Transform hook payloads include `payload.file` with `{ filename, path, platform, size }`, plus `buildId`, `platform`, `os`, `arch`, `targetKey`, `outDir`, `clientDir`, and `config`. Some stages include extras, such as `outputFilename`/`outputPath`, `donutArch`, `shellcodeArch`, or `iterations`.
+
+`onBuildArtifact` receives `payload.file` with `{ name, filename, path, platform, version, size }`. It can return `file: { filename }` to point the build record at another file already inside `dist-clients`, or `files: [...]` to add extra artifacts from `dist-clients`. Paths outside the build output directory are ignored for downloadable artifact metadata, but the hook can still upload or copy files itself because server-side plugins run with normal server privileges.
+
+Build plugins can declare their own builder UI in `config.json`:
+
+```json
+{
+ "runtime": "server",
+ "build": {
+ "label": "Uploader",
+ "description": "Uploads completed builds to an internal bucket.",
+ "enabledByDefault": true,
+ "settings": [
+ {
+ "key": "bucket",
+ "label": "Bucket",
+ "type": "string",
+ "required": true,
+ "placeholder": "release-builds"
+ },
+ {
+ "key": "publish",
+ "label": "Publish after build",
+ "type": "boolean",
+ "default": false
+ }
+ ],
+ "actions": [
+ {
+ "id": "shellcode-release",
+ "label": "Shellcode Release",
+ "icon": "fa-solid fa-fire",
+ "setBuild": { "useDonut": true, "useSgn": true },
+ "setSettings": { "publish": true }
+ }
+ ],
+ "requires": [
+ {
+ "field": "useSgn",
+ "truthy": true,
+ "message": "Uploader release mode requires SGN to be enabled."
+ }
+ ]
+ }
+}
+```
+
+Supported setting types are `string`, `textarea`, `number`, `boolean`, and `select`. The Build page saves these values with normal build profiles and sends them as:
+
+```js
+payload.config.buildPlugins[ctx.pluginId] // { enabled, settings }
+```
+
+Buttons declared in `build.actions` can set core builder fields through `setBuild` and plugin fields through `setSettings`. Requirements can check a core build field with `field`, a plugin setting with `pluginSetting`, or selected platforms with `platforms`.
+
### Calling the plugin from the UI
Plugin UI JS can call any RPC method:
diff --git a/plugins/sample-build-hooks/README.md b/plugins/sample-build-hooks/README.md
new file mode 100644
index 0000000..8ec8d3d
--- /dev/null
+++ b/plugins/sample-build-hooks/README.md
@@ -0,0 +1,16 @@
+# Sample Build Hooks Plugin
+
+This is a server-side-only sample for the agent builder hook API.
+
+It demonstrates:
+
+- `onBuildPrepare` and `onBuildTarget` for observing build state.
+- `buildHooks.post_build` for seeing the raw Go build output.
+- `buildHooks.before_donut` and `buildHooks.after_donut` for Donut shellcode conversion.
+- `buildHooks.before_sgn` and `buildHooks.after_sgn` for SGN shellcode encoding.
+- `onBuildArtifact` for replacing the downloadable output with a `.txt` file containing `test`.
+- A custom Build Plugins button named `Build Test TXT`.
+- `onBuildComplete` and `onBuildFailed` for post-build automation.
+- A small UI that reads recent hook activity from the plugin's SQLite database.
+
+Run `build.bat` from this directory to create `sample-build-hooks.zip`, then upload it on the Plugins page or place the zip where the server loads plugins.
diff --git a/plugins/sample-build-hooks/build.bat b/plugins/sample-build-hooks/build.bat
new file mode 100644
index 0000000..1b60aad
--- /dev/null
+++ b/plugins/sample-build-hooks/build.bat
@@ -0,0 +1,26 @@
+@echo off
+setlocal enabledelayedexpansion
+
+set "PLUGIN_DIR=%~dp0"
+set "PLUGIN_NAME=sample-build-hooks"
+set "ZIP_OUT=%PLUGIN_DIR%%PLUGIN_NAME%.zip"
+
+if exist "%ZIP_OUT%" del /f /q "%ZIP_OUT%"
+
+pushd "%PLUGIN_DIR%"
+powershell -NoProfile -ExecutionPolicy Bypass -Command "$zip = '%ZIP_OUT%'; $items = @('config.json', '%PLUGIN_NAME%.html', '%PLUGIN_NAME%.css', '%PLUGIN_NAME%.js', 'server.js'); Compress-Archive -Path $items -DestinationPath $zip -Force"
+set "ZIP_STATUS=%ERRORLEVEL%"
+popd
+if not "%ZIP_STATUS%"=="0" exit /b %ZIP_STATUS%
+
+if defined PLUGIN_SIGN_KEY (
+ where bun >nul 2>&1
+ if not errorlevel 1 (
+ echo [sign] Signing plugin with key: %PLUGIN_SIGN_KEY%
+ bun run "%~dp0..\..\Overlord-Server\scripts\plugin-sign.ts" --key "%PLUGIN_SIGN_KEY%" "%ZIP_OUT%"
+ ) else (
+ echo [warn] bun not found, skipping plugin signing
+ )
+)
+
+echo [ok] %ZIP_OUT%
diff --git a/plugins/sample-build-hooks/sample-build-hooks.css b/plugins/sample-build-hooks/sample-build-hooks.css
new file mode 100644
index 0000000..e9eb260
--- /dev/null
+++ b/plugins/sample-build-hooks/sample-build-hooks.css
@@ -0,0 +1,41 @@
+.sample-build-hooks {
+ display: grid;
+ gap: 12px;
+}
+
+.sample-build-hooks__header {
+ align-items: center;
+ display: flex;
+ gap: 12px;
+ justify-content: space-between;
+}
+
+.sample-build-hooks h2 {
+ color: #e5e7eb;
+ font-size: 18px;
+ font-weight: 700;
+ margin: 0;
+}
+
+.sample-build-hooks button {
+ background: #0f766e;
+ border: 1px solid #14b8a6;
+ border-radius: 8px;
+ color: #ecfeff;
+ cursor: pointer;
+ font-size: 13px;
+ padding: 8px 12px;
+}
+
+.sample-build-hooks pre {
+ background: rgba(15, 23, 42, 0.7);
+ border: 1px solid #334155;
+ border-radius: 8px;
+ color: #cbd5e1;
+ font-size: 12px;
+ margin: 0;
+ min-height: 180px;
+ overflow: auto;
+ padding: 12px;
+ white-space: pre-wrap;
+}
diff --git a/plugins/sample-build-hooks/sample-build-hooks.html b/plugins/sample-build-hooks/sample-build-hooks.html
new file mode 100644
index 0000000..c14e13d
--- /dev/null
+++ b/plugins/sample-build-hooks/sample-build-hooks.html
@@ -0,0 +1,7 @@
+
diff --git a/plugins/sample-build-hooks/sample-build-hooks.js b/plugins/sample-build-hooks/sample-build-hooks.js
new file mode 100644
index 0000000..afa5aa9
--- /dev/null
+++ b/plugins/sample-build-hooks/sample-build-hooks.js
@@ -0,0 +1,26 @@
+const output = document.getElementById("sample-build-hooks-output");
+const refreshButton = document.getElementById("sample-build-hooks-refresh");
+
+async function callRpc(method, params) {
+ const res = await fetch("/api/plugins/sample-build-hooks/rpc", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ method, params }),
+ });
+ const data = await res.json();
+ if (!data.ok) throw new Error(data.error || "RPC failed");
+ return data.result;
+}
+
+async function refresh() {
+ output.textContent = "Loading...";
+ try {
+ const result = await callRpc("recent");
+ output.textContent = JSON.stringify(result, null, 2);
+ } catch (err) {
+ output.textContent = `Error: ${err.message || err}`;
+ }
+}
+
+refreshButton?.addEventListener("click", refresh);
+refresh();
diff --git a/plugins/sample-build-hooks/server.js b/plugins/sample-build-hooks/server.js
new file mode 100644
index 0000000..fdffc33
--- /dev/null
+++ b/plugins/sample-build-hooks/server.js
@@ -0,0 +1,189 @@
+import fs from "fs";
+import path from "path";
+
+function record(ctx, hook, data = {}) {
+ ctx.db
+ .prepare("INSERT INTO build_hook_events(hook, data, created_at) VALUES (?, ?, ?)")
+ .run(hook, JSON.stringify(data), Date.now());
+}
+
+function safeJson(value) {
+ try {
+ return JSON.stringify(value, null, 2);
+ } catch {
+ return String(value);
+ }
+}
+
+function buildSettings(ctx, payload) {
+ const record = payload.config?.buildPlugins?.[ctx.pluginId];
+ return {
+ enabled: record?.enabled !== false,
+ replaceOutput: record?.settings?.replaceOutput !== false,
+ note: String(record?.settings?.note || "test"),
+ };
+}
+
+export default {
+ setup(ctx) {
+ ctx.db.exec(`
+ CREATE TABLE IF NOT EXISTS build_hook_events (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ hook TEXT NOT NULL,
+ data TEXT NOT NULL,
+ created_at INTEGER NOT NULL
+ );
+ CREATE INDEX IF NOT EXISTS build_hook_events_recent ON build_hook_events(created_at DESC);
+ `);
+ ctx.log.info("sample build hook plugin ready");
+ },
+
+ onBuildPrepare(ctx, payload) {
+ const settings = buildSettings(ctx, payload);
+ if (!settings.enabled) return null;
+ record(ctx, "prepare", {
+ buildId: payload.buildId,
+ platforms: payload.config?.platforms || [],
+ settings,
+ });
+ return {
+ message: "prepare hook observed build config",
+ };
+ },
+
+ onBuildTarget(ctx, payload) {
+ const settings = buildSettings(ctx, payload);
+ if (!settings.enabled) return null;
+ record(ctx, "target", {
+ buildId: payload.buildId,
+ platform: payload.platform,
+ outputName: payload.outputName,
+ settings,
+ });
+
+ return { message: `target hook observed ${payload.platform}` };
+ },
+
+ buildHooks: {
+ post_build(ctx, payload) {
+ if (!buildSettings(ctx, payload).enabled) return null;
+ record(ctx, "post_build", {
+ buildId: payload.buildId,
+ platform: payload.platform,
+ filename: payload.file?.filename,
+ size: payload.file?.size,
+ });
+ },
+
+ before_donut(ctx, payload) {
+ if (!buildSettings(ctx, payload).enabled) return null;
+ record(ctx, "before_donut", {
+ buildId: payload.buildId,
+ platform: payload.platform,
+ input: payload.file?.filename,
+ output: payload.outputFilename,
+ donutArch: payload.donutArch,
+ });
+ return { message: `before Donut: ${payload.file?.filename}` };
+ },
+
+ after_donut(ctx, payload) {
+ if (!buildSettings(ctx, payload).enabled) return null;
+ record(ctx, "after_donut", {
+ buildId: payload.buildId,
+ platform: payload.platform,
+ filename: payload.file?.filename,
+ size: payload.file?.size,
+ donutArch: payload.donutArch,
+ });
+ return { message: `after Donut: ${payload.file?.filename}` };
+ },
+
+ before_sgn(ctx, payload) {
+ if (!buildSettings(ctx, payload).enabled) return null;
+ record(ctx, "before_sgn", {
+ buildId: payload.buildId,
+ platform: payload.platform,
+ input: payload.file?.filename,
+ output: payload.outputFilename,
+ iterations: payload.iterations,
+ });
+ return { message: `before SGN: ${payload.file?.filename}` };
+ },
+
+ after_sgn(ctx, payload) {
+ if (!buildSettings(ctx, payload).enabled) return null;
+ record(ctx, "after_sgn", {
+ buildId: payload.buildId,
+ platform: payload.platform,
+ filename: payload.file?.filename,
+ size: payload.file?.size,
+ iterations: payload.iterations,
+ });
+ return { message: `after SGN: ${payload.file?.filename}` };
+ },
+ },
+
+ onBuildArtifact(ctx, payload) {
+ const settings = buildSettings(ctx, payload);
+ if (!settings.enabled) return null;
+ record(ctx, "artifact", {
+ buildId: payload.buildId,
+ platform: payload.platform,
+ filename: payload.file?.filename,
+ size: payload.file?.size,
+ settings,
+ });
+
+ if (!settings.replaceOutput) {
+ return { message: "test TXT replacement disabled by plugin setting" };
+ }
+
+ const txtName = `${path.parse(payload.file.filename).name}.txt`;
+ const txtPath = path.join(payload.outDir, txtName);
+ fs.writeFileSync(txtPath, `${settings.note}\n`, "utf8");
+
+ return {
+ file: { filename: txtName },
+ message: `replaced build output with ${txtName}`,
+ };
+ },
+
+ onBuildComplete(ctx, payload) {
+ if (!buildSettings(ctx, payload).enabled) return null;
+ record(ctx, "complete", {
+ buildId: payload.buildId,
+ files: payload.files?.map((file) => file.filename) || [],
+ });
+ ctx.broadcast("build_complete", { buildId: payload.buildId, files: payload.files?.length || 0 });
+ },
+
+ onBuildFailed(ctx, payload) {
+ if (!buildSettings(ctx, payload).enabled) return null;
+ record(ctx, "failed", {
+ buildId: payload.buildId,
+ error: payload.error,
+ });
+ ctx.broadcast("build_failed", { buildId: payload.buildId, error: payload.error });
+ },
+
+ rpc: {
+ recent(ctx) {
+ const rows = ctx.db
+ .prepare("SELECT hook, data, created_at FROM build_hook_events ORDER BY created_at DESC LIMIT 25")
+ .all();
+ return rows.map((row) => ({
+ hook: row.hook,
+ createdAt: new Date(row.created_at).toISOString(),
+ data: JSON.parse(row.data),
+ }));
+ },
+
+ clear(ctx, _params, { caller }) {
+ if (caller.role !== "admin") throw new Error("Admin only");
+ ctx.db.exec("DELETE FROM build_hook_events");
+ ctx.broadcast("cleared", { by: caller.id });
+ return { ok: true };
+ },
+ },
+};