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
+304
View File
@@ -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);
});
+47 -8
View File
@@ -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
? '<i class="fa-solid fa-server"></i> Server Extension'
: isWasm
? '<i class="fa-solid fa-cube"></i> WASM 2.0'
: isV2
? '<i class="fa-solid fa-code"></i> Plugin 2.0'
@@ -171,12 +178,37 @@ function renderPlugins(plugins) {
needsBox.innerHTML = `
<div class="${plugin.needsApproved ? "text-emerald-300" : "text-amber-300"}">
<i class="fa-solid ${plugin.needsApproved ? "fa-lock-open" : "fa-lock"} mr-1"></i>
${plugin.needsApproved ? "Needs approved" : "Needs approval before load"}
${plugin.needsApproved ? "Needs approved" : isServerOnly ? "Needs approval before enable" : "Needs approval before load"}
</div>
${needLines.map((line) => `<div class="font-mono text-slate-400">${escapeHtml(line)}</div>`).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
? '<i class="fa-solid fa-circle-play"></i> Worker running'
: plugin.enabled
? '<i class="fa-solid fa-triangle-exclamation"></i> Enabled, worker stopped'
: '<i class="fa-solid fa-circle-stop"></i> 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 = '<i class="fa-solid fa-hammer"></i> Build plugin';
serverBox.appendChild(buildBadge);
}
meta.appendChild(serverBox);
}
const actions = document.createElement("div");
actions.className = "flex items-center gap-2";
@@ -186,7 +218,11 @@ 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
toggle.innerHTML = isServerOnly
? plugin.enabled
? '<i class="fa-solid fa-power-off"></i> Disable'
: '<i class="fa-solid fa-server"></i> Enable'
: plugin.enabled
? '<i class="fa-solid fa-toggle-on"></i> Enabled'
: '<i class="fa-solid fa-toggle-off"></i> Disabled';
toggle.addEventListener("click", async () => {
@@ -219,7 +255,9 @@ function renderPlugins(plugins) {
}
});
const autoLoadBtn = document.createElement("button");
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" +
@@ -270,6 +308,7 @@ function renderPlugins(plugins) {
await refresh();
});
}
}
if (needLines.length && !plugin.needsApproved) {
const approveBtn = document.createElement("button");
@@ -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) {
<i class="fa-solid fa-key text-2xl text-amber-300"></i>
<h3 class="text-lg font-semibold text-slate-100">Approve Plugin Needs</h3>
</div>
<p class="text-sm text-slate-300 mb-3"><strong class="text-slate-100">${escapeHtml(plugin.name || plugin.id)}</strong> is asking for filesystem bridges before it can be sent to clients.</p>
<p class="text-sm text-slate-300 mb-3"><strong class="text-slate-100">${escapeHtml(plugin.name || plugin.id)}</strong> is asking for filesystem bridges before it can ${plugin.runtime === "server" ? "be enabled" : "be sent to clients"}.</p>
<div class="rounded-lg border border-slate-700 bg-slate-950/70 p-3 mb-3 space-y-2">
${needLines.length ? needLines.map((line) => `<div class="text-xs font-mono text-slate-300">${escapeHtml(line)}</div>`).join("") : '<div class="text-sm text-slate-400">No filesystem needs declared.</div>'}
</div>
+17
View File
@@ -930,6 +930,23 @@
</div>
</div>
<!-- == Build Plugin Settings (loaded from server-side plugins) == -->
<div id="build-plugins-section" class="accordion-section border border-slate-800 rounded-lg overflow-hidden hidden" data-open="false">
<button type="button" class="accordion-header w-full flex items-center justify-between px-4 py-3 bg-slate-800/50 hover:bg-slate-800/80 transition-colors text-left">
<span class="flex items-center gap-2 text-sm font-medium text-slate-200">
<i class="fa-solid fa-puzzle-piece text-fuchsia-400"></i>
Build Plugins
<span id="build-plugins-count" class="text-xs text-slate-500 font-normal"></span>
</span>
<i class="fa-solid fa-chevron-down accordion-chevron text-slate-400 text-xs"></i>
</button>
<div class="accordion-body">
<div class="accordion-inner px-4 pb-4 pt-3 flex flex-col gap-3">
<div id="build-plugins-list" class="flex flex-col gap-3"></div>
</div>
</div>
</div>
<!-- Build Button -->
<div class="flex gap-2">
<button type="submit" id="build-btn"
+1 -1
View File
@@ -21,7 +21,7 @@
<div class="max-w-5xl mx-auto flex flex-col gap-5">
<div>
<h1 class="text-2xl font-semibold">Plugins</h1>
<p class="text-slate-400">Upload zip bundles and manage plugin UI</p>
<p class="text-slate-400">Upload zip bundles and manage client plugins or server extensions</p>
</div>
<section class="rounded-2xl border border-slate-800 bg-slate-900/50 p-5">
+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) => ({
.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: deps.pluginState.autoLoad[p.id] === true,
autoStartEvents: deps.pluginState.autoStartEvents[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: p.runtime || "native",
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)) {
+140
View File
@@ -1190,6 +1190,146 @@ export default {
| `ctx.log` | `{debug, info, warn, error}` | Each call is forwarded to the main server logger as `[plugin:<id>] <message>`. |
| `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:
+16
View File
@@ -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.
+26
View File
@@ -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%
@@ -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;
}
@@ -0,0 +1,7 @@
<div class="sample-build-hooks">
<div class="sample-build-hooks__header">
<h2>Sample Build Hooks</h2>
<button id="sample-build-hooks-refresh" type="button">Refresh</button>
</div>
<pre id="sample-build-hooks-output">Loading...</pre>
</div>
@@ -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();
+189
View File
@@ -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 };
},
},
};