chore: harden agent-runner execute validation and add callback auth headers

This commit is contained in:
2026-06-02 11:41:02 -07:00
parent d04c85d7b8
commit b1625dac88
2 changed files with 454 additions and 234 deletions

View File

@@ -1,13 +1,12 @@
import express, { Request, Response, NextFunction } from 'express';
import cors from 'cors';
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
import { execSync } from 'child_process';
import { runSessionAgent } from './agent-session-runner';
import { AGENTS } from './agents';
import { ToolContext } from './tools';
import express, { Request, Response, NextFunction } from "express";
import cors from "cors";
import * as fs from "fs";
import * as path from "path";
import * as crypto from "crypto";
import { execSync } from "child_process";
import { runSessionAgent } from "./agent-session-runner";
import { AGENTS } from "./agents";
import { ToolContext } from "./tools";
const app = express();
app.use(cors());
@@ -15,7 +14,7 @@ app.use(cors());
const startTime = new Date();
// Raw body capture for webhook HMAC — must come before express.json()
app.use('/webhook/gitea', express.raw({ type: '*/*' }));
app.use("/webhook/gitea", express.raw({ type: "*/*" }));
app.use(express.json());
@@ -26,28 +25,33 @@ const PORT = process.env.PORT || 3333;
// ---------------------------------------------------------------------------
function ensureWorkspace(repo?: string): string {
const base = process.env.WORKSPACE_BASE || '/workspaces';
const base = process.env.WORKSPACE_BASE || "/workspaces";
if (!repo) {
const dir = path.join(base, 'default');
const dir = path.join(base, "default");
fs.mkdirSync(dir, { recursive: true });
return dir;
}
const dir = path.join(base, repo.replace('/', '_'));
const dir = path.join(base, repo.replace("/", "_"));
const gitea = {
apiUrl: process.env.GITEA_API_URL || '',
apiToken: process.env.GITEA_API_TOKEN || '',
username: process.env.GITEA_USERNAME || ''
apiUrl: process.env.GITEA_API_URL || "",
apiToken: process.env.GITEA_API_TOKEN || "",
username: process.env.GITEA_USERNAME || "",
};
if (!fs.existsSync(path.join(dir, '.git'))) {
if (!fs.existsSync(path.join(dir, ".git"))) {
fs.mkdirSync(dir, { recursive: true });
const authedUrl = `${gitea.apiUrl}/${repo}.git`
.replace('https://', `https://${gitea.username}:${gitea.apiToken}@`);
const authedUrl = `${gitea.apiUrl}/${repo}.git`.replace(
"https://",
`https://${gitea.username}:${gitea.apiToken}@`,
);
try {
execSync(`git clone "${authedUrl}" "${dir}"`, { stdio: 'pipe' });
execSync(`git clone "${authedUrl}" "${dir}"`, { stdio: "pipe" });
} catch {
// Repo may not exist yet — just init
execSync(`git init`, { cwd: dir, stdio: 'pipe' });
execSync(`git remote add origin "${authedUrl}"`, { cwd: dir, stdio: 'pipe' });
execSync(`git init`, { cwd: dir, stdio: "pipe" });
execSync(`git remote add origin "${authedUrl}"`, {
cwd: dir,
stdio: "pipe",
});
}
}
return dir;
@@ -59,17 +63,17 @@ function buildContext(repo?: string): ToolContext {
return {
workspaceRoot,
gitea: {
apiUrl: process.env.GITEA_API_URL || '',
apiToken: process.env.GITEA_API_TOKEN || '',
username: process.env.GITEA_USERNAME || ''
apiUrl: process.env.GITEA_API_URL || "",
apiToken: process.env.GITEA_API_TOKEN || "",
username: process.env.GITEA_USERNAME || "",
},
coolify: {
apiUrl: process.env.COOLIFY_API_URL || '',
apiToken: process.env.COOLIFY_API_TOKEN || ''
apiUrl: process.env.COOLIFY_API_URL || "",
apiToken: process.env.COOLIFY_API_TOKEN || "",
},
mcpToken: '',
vibnApiUrl: process.env.VIBN_API_URL ?? 'https://vibnai.com',
memoryUpdates: []
mcpToken: "",
vibnApiUrl: process.env.VIBN_API_URL ?? "https://vibnai.com",
memoryUpdates: [],
};
}
@@ -78,15 +82,15 @@ function buildContext(repo?: string): ToolContext {
// ---------------------------------------------------------------------------
// Health check
app.get('/health', (_req: Request, res: Response) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
app.get("/health", (_req: Request, res: Response) => {
res.json({ status: "ok", timestamp: new Date().toISOString() });
});
// ---------------------------------------------------------------------------
// GitHub mirror — clone a public GitHub repo and push to Gitea as-is
// ---------------------------------------------------------------------------
app.post('/api/mirror', async (req: Request, res: Response) => {
app.post("/api/mirror", async (req: Request, res: Response) => {
const { github_url, gitea_repo, project_name, github_token } = req.body as {
github_url?: string;
gitea_repo?: string; // e.g. "mark/opsos"
@@ -95,30 +99,34 @@ app.post('/api/mirror', async (req: Request, res: Response) => {
};
if (!github_url || !gitea_repo) {
res.status(400).json({ error: '"github_url" and "gitea_repo" are required' });
res
.status(400)
.json({ error: '"github_url" and "gitea_repo" are required' });
return;
}
const { execSync } = await import('child_process');
const fs = await import('fs');
const path = await import('path');
const os = await import('os');
const { execSync } = await import("child_process");
const fs = await import("fs");
const path = await import("path");
const os = await import("os");
const mirrorId = `mirror_${Date.now()}`;
const tmpDir = path.join(os.tmpdir(), mirrorId);
const gitea = {
apiUrl: process.env.GITEA_API_URL || '',
apiToken: process.env.GITEA_API_TOKEN || '',
username: process.env.GITEA_USERNAME || ''
apiUrl: process.env.GITEA_API_URL || "",
apiToken: process.env.GITEA_API_TOKEN || "",
username: process.env.GITEA_USERNAME || "",
};
try {
// Build authenticated Gitea push URL
// GITEA_API_URL is like https://git.vibnai.com — strip /api/v1 if present
const giteaBase = gitea.apiUrl.replace(/\/api\/v1\/?$/, '');
const authedPushUrl = `${giteaBase}/${gitea_repo}.git`
.replace('https://', `https://${gitea.username}:${gitea.apiToken}@`);
const giteaBase = gitea.apiUrl.replace(/\/api\/v1\/?$/, "");
const authedPushUrl = `${giteaBase}/${gitea_repo}.git`.replace(
"https://",
`https://${gitea.username}:${gitea.apiToken}@`,
);
console.log(`[mirror] Cloning ${github_url}${tmpDir}`);
fs.mkdirSync(tmpDir, { recursive: true });
@@ -126,53 +134,72 @@ app.post('/api/mirror', async (req: Request, res: Response) => {
// Build authenticated clone URL for private repos
let cloneUrl = github_url;
if (github_token) {
cloneUrl = github_url.replace('https://', `https://${github_token}@`);
cloneUrl = github_url.replace("https://", `https://${github_token}@`);
}
// Mirror-clone the GitHub repo (preserves all branches + tags)
execSync(`git clone --mirror "${cloneUrl}" "${tmpDir}/.git"`, {
stdio: 'pipe',
timeout: 120_000
stdio: "pipe",
timeout: 120_000,
});
execSync(`git config --bool core.bare false`, { cwd: tmpDir, stdio: 'pipe' });
execSync(`git checkout`, { cwd: tmpDir, stdio: 'pipe' });
execSync(`git config --bool core.bare false`, {
cwd: tmpDir,
stdio: "pipe",
});
execSync(`git checkout`, { cwd: tmpDir, stdio: "pipe" });
// Point origin at Gitea and push all refs
execSync(`git remote set-url origin "${authedPushUrl}"`, { cwd: tmpDir, stdio: 'pipe' });
execSync(`git push --mirror origin`, { cwd: tmpDir, stdio: 'pipe', timeout: 120_000 });
execSync(`git remote set-url origin "${authedPushUrl}"`, {
cwd: tmpDir,
stdio: "pipe",
});
execSync(`git push --mirror origin`, {
cwd: tmpDir,
stdio: "pipe",
timeout: 120_000,
});
console.log(`[mirror] Pushed ${gitea_repo} successfully`);
res.json({ success: true, gitea_repo, github_url });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[mirror] Failed:`, msg);
res.status(500).json({ error: 'Mirror failed', details: msg });
res.status(500).json({ error: "Mirror failed", details: msg });
} finally {
// Clean up temp dir
try {
const { execSync: rm } = await import('child_process');
rm(`rm -rf "${tmpDir}"`, { stdio: 'pipe' });
} catch { /* best effort */ }
const { execSync: rm } = await import("child_process");
rm(`rm -rf "${tmpDir}"`, { stdio: "pipe" });
} catch {
/* best effort */
}
}
});
// List available agents
app.get('/api/agents', (_req: Request, res: Response) => {
const agents = Object.values(AGENTS).map(a => ({
app.get("/api/agents", (_req: Request, res: Response) => {
const agents = Object.values(AGENTS).map((a) => ({
name: a.name,
description: a.description,
tools: a.tools.map(t => t.name)
tools: a.tools.map((t) => t.name),
}));
res.json(agents);
});
const activeSessions = new Map<string, { stopped: boolean }>();
app.post('/agent/execute', async (req: Request, res: Response) => {
app.post("/agent/execute", async (req: Request, res: Response) => {
const {
sessionId, projectId, appName, appPath, giteaRepo, task, continueTask,
autoApprove, coolifyAppUuid, mcpToken,
sessionId,
projectId,
appName,
appPath: rawAppPath,
giteaRepo,
task,
continueTask,
autoApprove,
coolifyAppUuid,
mcpToken,
} = req.body as {
sessionId?: string;
projectId?: string;
@@ -186,19 +213,33 @@ app.post('/agent/execute', async (req: Request, res: Response) => {
mcpToken?: string;
};
const appPath =
rawAppPath === undefined || rawAppPath === null || rawAppPath === ""
? "."
: rawAppPath;
if (!sessionId || !projectId || !appPath || !task) {
res.status(400).json({ error: 'sessionId, projectId, appPath and task are required' });
res
.status(400)
.json({ error: "sessionId, projectId, appPath and task are required" });
return;
}
const vibnApiUrl = process.env.VIBN_API_URL ?? 'https://vibnai.com';
const vibnApiUrl = process.env.VIBN_API_URL ?? "https://vibnai.com";
const patchHeaders = {
"Content-Type": "application/json",
...(process.env.AGENT_RUNNER_SECRET
? { "x-agent-runner-secret": process.env.AGENT_RUNNER_SECRET }
: {}),
};
// Register session as active
const sessionState = { stopped: false };
activeSessions.set(sessionId, sessionState);
// Respond immediately — execution is async
res.status(202).json({ sessionId, status: 'running' });
res.status(202).json({ sessionId, status: "running" });
// Build workspace context — clone/update the Gitea repo if provided
let ctx: ReturnType<typeof buildContext>;
@@ -206,13 +247,16 @@ app.post('/agent/execute', async (req: Request, res: Response) => {
ctx = buildContext(giteaRepo);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error('[agent/execute] buildContext failed:', msg);
console.error("[agent/execute] buildContext failed:", msg);
// Notify VIBN DB of failure
fetch(`${vibnApiUrl}/api/projects/${projectId}/agent/sessions/${sessionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'failed', error: msg }),
}).catch(() => {});
fetch(
`${vibnApiUrl}/api/projects/${projectId}/agent/sessions/${sessionId}`,
{
method: "PATCH",
headers: patchHeaders,
body: JSON.stringify({ status: "failed", error: msg }),
},
).catch(() => {});
activeSessions.delete(sessionId);
return;
}
@@ -231,19 +275,25 @@ app.post('/agent/execute', async (req: Request, res: Response) => {
// Scope workspace to the app subdirectory so the agent works there naturally
if (appPath) {
const path = require('path') as typeof import('path');
const path = require("path") as typeof import("path");
ctx.workspaceRoot = path.join(ctx.workspaceRoot, appPath);
const fs = require('fs') as typeof import('fs');
const fs = require("fs") as typeof import("fs");
fs.mkdirSync(ctx.workspaceRoot, { recursive: true });
}
const agentConfig = AGENTS['Coder'];
const agentConfig = AGENTS["Coder"];
if (!agentConfig) {
fetch(`${vibnApiUrl}/api/projects/${projectId}/agent/sessions/${sessionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'failed', error: 'Coder agent not registered' }),
}).catch(() => {});
fetch(
`${vibnApiUrl}/api/projects/${projectId}/agent/sessions/${sessionId}`,
{
method: "PATCH",
headers: patchHeaders,
body: JSON.stringify({
status: "failed",
error: "Coder agent not registered",
}),
},
).catch(() => {});
activeSessions.delete(sessionId);
return;
}
@@ -268,36 +318,42 @@ app.post('/agent/execute', async (req: Request, res: Response) => {
coolifyApiUrl: process.env.COOLIFY_API_URL,
coolifyApiToken: process.env.COOLIFY_API_TOKEN,
})
.catch(err => {
.catch((err) => {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[agent/execute] session ${sessionId} crashed:`, msg);
fetch(`${vibnApiUrl}/api/projects/${projectId}/agent/sessions/${sessionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'failed', error: msg }),
}).catch(() => {});
fetch(
`${vibnApiUrl}/api/projects/${projectId}/agent/sessions/${sessionId}`,
{
method: "PATCH",
headers: patchHeaders,
body: JSON.stringify({ status: "failed", error: msg }),
},
).catch(() => {});
})
.finally(() => {
activeSessions.delete(sessionId);
});
});
app.post('/agent/stop', (req: Request, res: Response) => {
app.post("/agent/stop", (req: Request, res: Response) => {
const { sessionId } = req.body as { sessionId?: string };
if (!sessionId) { res.status(400).json({ error: 'sessionId required' }); return; }
if (!sessionId) {
res.status(400).json({ error: "sessionId required" });
return;
}
const session = activeSessions.get(sessionId);
if (session) {
session.stopped = true;
res.json({ status: 'stopped' });
res.json({ status: "stopped" });
} else {
res.status(404).json({ error: 'session not found or not running' });
res.status(404).json({ error: "session not found or not running" });
}
});
app.listen(PORT, () => {
console.log(`AgentRunner listening on port ${PORT}`);
console.log(`Agents available: ${Object.keys(AGENTS).join(', ')}`);
console.log(`Agents available: ${Object.keys(AGENTS).join(", ")}`);
if (!process.env.GOOGLE_API_KEY) {
console.warn('WARNING: GOOGLE_API_KEY is not set — agents will fail');
console.warn("WARNING: GOOGLE_API_KEY is not set — agents will fail");
}
});

View File

@@ -0,0 +1,164 @@
import { spawn } from "child_process";
import http from "http";
// We will start the runner server on port 3334
const PORT = 3334;
const BASE_URL = `http://localhost:${PORT}`;
console.log("🧪 Starting AgentRunner Hardening Test Suite...");
// Set up environment variables
const env = {
...process.env,
PORT: String(PORT),
AGENT_RUNNER_SECRET: "test-secret-123",
GOOGLE_API_KEY: "dummy-key-for-testing", // Pass dummy key to avoid Gemini API initialization crash
VIBN_API_URL: "http://localhost:3335", // Mock backend
};
// Start mock backend on port 3335 to catch PATCH callbacks and verify headers
let receivedHeaders: any = null;
let receivedBody: any = null;
const mockBackend = http.createServer((req, res) => {
receivedHeaders = req.headers;
let body = "";
req.on("data", (chunk) => {
body += chunk;
});
req.on("end", () => {
try {
receivedBody = JSON.parse(body);
} catch {
receivedBody = body;
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true }));
});
});
mockBackend.listen(3335, () => {
console.log("✓ Mock backend server listening on port 3335");
});
// Spawn the runner server
const serverProcess = spawn("npx", ["ts-node", "src/server.ts"], {
env,
stdio: "pipe",
});
// Wait for server to start
serverProcess.stdout.on("data", (data) => {
const output = data.toString();
console.log(`[Server Out] ${output.trim()}`);
});
serverProcess.stderr.on("data", (data) => {
console.error(`[Server Err] ${data.toString()}`);
});
// Helper function to sleep
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
async function runTests() {
// Wait 4 seconds for server to boot
await sleep(4000);
let passed = 0;
let failed = 0;
const assert = (condition: boolean, message: string) => {
if (condition) {
console.log(` 🟢 PASSED: ${message}`);
passed++;
} else {
console.error(` 🔴 FAILED: ${message}`);
failed++;
}
};
try {
// Test 1: Empty appPath should be accepted and fall back to "."
console.log("\n1⃣ Testing appPath empty string fallback...");
const res1 = await fetch(`${BASE_URL}/agent/execute`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId: "test-session-1",
projectId: "test-project-1",
task: "Test empty appPath",
appPath: "", // Empty string!
giteaRepo: "test-repo",
}),
});
assert(res1.status === 202, `Should return 202, got ${res1.status}`);
const data1 = (await res1.json()) as any;
assert(
data1.sessionId === "test-session-1",
`Should return correct sessionId, got ${data1.sessionId}`,
);
// Test 2: Missing sessionId should return 400
console.log("\n2⃣ Testing missing required parameters (sessionId)...");
const res2 = await fetch(`${BASE_URL}/agent/execute`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
projectId: "test-project-1",
task: "Test missing sessionId",
appPath: ".",
}),
});
assert(res2.status === 400, `Should return 400, got ${res2.status}`);
// Test 3: Emergency callback headers should include x-agent-runner-secret
console.log("\n3⃣ Testing early failure callback headers...");
// Trigger a clone failure by passing a malformed giteaRepo containing slash,
// which triggers clone instead of default workspace but will fail clone.
console.log("Triggering clone failure on mock Gitea...");
const res3 = await fetch(`${BASE_URL}/agent/execute`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId: "test-session-3",
projectId: "test-project-3",
task: "Trigger crash",
appPath: ".",
giteaRepo: "invalid_owner/invalid_repo",
}),
});
assert(
res3.status === 202,
`Should return 202 Accepted, got ${res3.status}`,
);
// Wait for server to process async task and fail, calling our mock backend PATCH
console.log("Waiting for runner callback on mock backend...");
await sleep(4000);
assert(receivedHeaders !== null, "Should call mock backend PATCH endpoint");
if (receivedHeaders) {
assert(
receivedHeaders["x-agent-runner-secret"] === "test-secret-123",
`Callback should include secret header 'test-secret-123', got '${receivedHeaders["x-agent-runner-secret"]}'`,
);
assert(
receivedBody && receivedBody.status === "failed",
`Callback body should have status 'failed', got '${receivedBody?.status}'`,
);
}
} catch (err) {
console.error("Test execution failed with exception:", err);
} finally {
console.log("\n🧹 Cleaning up test servers...");
serverProcess.kill();
mockBackend.close();
console.log(`\n📊 Tests complete. Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}
}
runTests();