diff --git a/vibn-agent-runner/src/server.ts b/vibn-agent-runner/src/server.ts index 3185b1b..aeb7ad1 100644 --- a/vibn-agent-runner/src/server.ts +++ b/vibn-agent-runner/src/server.ts @@ -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,51 +25,56 @@ const PORT = process.env.PORT || 3333; // --------------------------------------------------------------------------- function ensureWorkspace(repo?: string): string { - const base = process.env.WORKSPACE_BASE || '/workspaces'; - if (!repo) { - const dir = path.join(base, 'default'); - fs.mkdirSync(dir, { recursive: true }); - return dir; - } - 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 || '' - }; - 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}@`); - try { - 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' }); - } - } + const base = process.env.WORKSPACE_BASE || "/workspaces"; + if (!repo) { + const dir = path.join(base, "default"); + fs.mkdirSync(dir, { recursive: true }); return dir; + } + 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 || "", + }; + 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}@`, + ); + try { + 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", + }); + } + } + return dir; } function buildContext(repo?: string): ToolContext { - const workspaceRoot = ensureWorkspace(repo); + const workspaceRoot = ensureWorkspace(repo); - return { - workspaceRoot, - gitea: { - 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 || '' - }, - mcpToken: '', - vibnApiUrl: process.env.VIBN_API_URL ?? 'https://vibnai.com', - memoryUpdates: [] - }; + return { + workspaceRoot, + gitea: { + 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 || "", + }, + mcpToken: "", + vibnApiUrl: process.env.VIBN_API_URL ?? "https://vibnai.com", + memoryUpdates: [], + }; } // --------------------------------------------------------------------------- @@ -78,226 +82,278 @@ 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) => { - const { github_url, gitea_repo, project_name, github_token } = req.body as { - github_url?: string; - gitea_repo?: string; // e.g. "mark/opsos" - project_name?: string; - github_token?: string; // PAT for private repos - }; +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" + project_name?: string; + github_token?: string; // PAT for private repos + }; - if (!github_url || !gitea_repo) { - res.status(400).json({ error: '"github_url" and "gitea_repo" are required' }); - return; + if (!github_url || !gitea_repo) { + 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 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 || "", + }; + + 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}@`, + ); + + console.log(`[mirror] Cloning ${github_url} → ${tmpDir}`); + fs.mkdirSync(tmpDir, { recursive: true }); + + // Build authenticated clone URL for private repos + let cloneUrl = github_url; + if (github_token) { + cloneUrl = github_url.replace("https://", `https://${github_token}@`); } - const { execSync } = await import('child_process'); - const fs = await import('fs'); - const path = await import('path'); - const os = await import('os'); + // Mirror-clone the GitHub repo (preserves all branches + tags) + execSync(`git clone --mirror "${cloneUrl}" "${tmpDir}/.git"`, { + stdio: "pipe", + timeout: 120_000, + }); + execSync(`git config --bool core.bare false`, { + cwd: tmpDir, + stdio: "pipe", + }); + execSync(`git checkout`, { cwd: tmpDir, stdio: "pipe" }); - 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 || '' - }; + // 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, + }); + 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 }); + } finally { + // Clean up temp dir 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}@`); - - console.log(`[mirror] Cloning ${github_url} → ${tmpDir}`); - fs.mkdirSync(tmpDir, { recursive: true }); - - // Build authenticated clone URL for private repos - let cloneUrl = github_url; - if (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 - }); - 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 }); - - 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 }); - } 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 => ({ - name: a.name, - description: a.description, - tools: a.tools.map(t => t.name) - })); - res.json(agents); +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), + })); + res.json(agents); }); - const activeSessions = new Map(); -app.post('/agent/execute', async (req: Request, res: Response) => { - const { - sessionId, projectId, appName, appPath, giteaRepo, task, continueTask, - autoApprove, coolifyAppUuid, mcpToken, - } = req.body as { - sessionId?: string; - projectId?: string; - appName?: string; - appPath?: string; - giteaRepo?: string; - task?: string; - continueTask?: string; - autoApprove?: boolean; - coolifyAppUuid?: string; - mcpToken?: string; - }; +app.post("/agent/execute", async (req: Request, res: Response) => { + const { + sessionId, + projectId, + appName, + appPath: rawAppPath, + giteaRepo, + task, + continueTask, + autoApprove, + coolifyAppUuid, + mcpToken, + } = req.body as { + sessionId?: string; + projectId?: string; + appName?: string; + appPath?: string; + giteaRepo?: string; + task?: string; + continueTask?: string; + autoApprove?: boolean; + coolifyAppUuid?: string; + mcpToken?: string; + }; - if (!sessionId || !projectId || !appPath || !task) { - res.status(400).json({ error: 'sessionId, projectId, appPath and task are required' }); - return; - } + const appPath = + rawAppPath === undefined || rawAppPath === null || rawAppPath === "" + ? "." + : rawAppPath; - const vibnApiUrl = process.env.VIBN_API_URL ?? 'https://vibnai.com'; + if (!sessionId || !projectId || !appPath || !task) { + res + .status(400) + .json({ error: "sessionId, projectId, appPath and task are required" }); + return; + } - // Register session as active - const sessionState = { stopped: false }; - activeSessions.set(sessionId, sessionState); + const vibnApiUrl = process.env.VIBN_API_URL ?? "https://vibnai.com"; - // Respond immediately — execution is async - res.status(202).json({ sessionId, status: 'running' }); + const patchHeaders = { + "Content-Type": "application/json", + ...(process.env.AGENT_RUNNER_SECRET + ? { "x-agent-runner-secret": process.env.AGENT_RUNNER_SECRET } + : {}), + }; - // Build workspace context — clone/update the Gitea repo if provided - let ctx: ReturnType; - try { - ctx = buildContext(giteaRepo); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - 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(() => {}); - activeSessions.delete(sessionId); - return; - } + // Register session as active + const sessionState = { stopped: false }; + activeSessions.set(sessionId, sessionState); - // Capture repo root before scoping to appPath — needed for git commit in auto-approve - const repoRoot = ctx.workspaceRoot; + // Respond immediately — execution is async + res.status(202).json({ sessionId, status: "running" }); - // Wire the ToolContext so its tools can call back into the VIBN frontend MCP - // with the right URL and auth. buildContext() defaults these to safe values, - // but the authoritative ones come from env (VIBN_API_URL) and the frontend - // (mcpToken passed in the /agent/execute body). Without this, tools fetch - // http://localhost:3000 with no token and fail with "fetch failed". - ctx.vibnApiUrl = vibnApiUrl; - ctx.mcpToken = mcpToken ?? ctx.mcpToken; - ctx.projectId = projectId; + // Build workspace context — clone/update the Gitea repo if provided + let ctx: ReturnType; + try { + ctx = buildContext(giteaRepo); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error("[agent/execute] buildContext failed:", msg); + // Notify VIBN DB of failure + fetch( + `${vibnApiUrl}/api/projects/${projectId}/agent/sessions/${sessionId}`, + { + method: "PATCH", + headers: patchHeaders, + body: JSON.stringify({ status: "failed", error: msg }), + }, + ).catch(() => {}); + activeSessions.delete(sessionId); + return; + } - // Scope workspace to the app subdirectory so the agent works there naturally - if (appPath) { - const path = require('path') as typeof import('path'); - ctx.workspaceRoot = path.join(ctx.workspaceRoot, appPath); - const fs = require('fs') as typeof import('fs'); - fs.mkdirSync(ctx.workspaceRoot, { recursive: true }); - } + // Capture repo root before scoping to appPath — needed for git commit in auto-approve + const repoRoot = ctx.workspaceRoot; - 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(() => {}); - activeSessions.delete(sessionId); - return; - } + // Wire the ToolContext so its tools can call back into the VIBN frontend MCP + // with the right URL and auth. buildContext() defaults these to safe values, + // but the authoritative ones come from env (VIBN_API_URL) and the frontend + // (mcpToken passed in the /agent/execute body). Without this, tools fetch + // http://localhost:3000 with no token and fail with "fetch failed". + ctx.vibnApiUrl = vibnApiUrl; + ctx.mcpToken = mcpToken ?? ctx.mcpToken; + ctx.projectId = projectId; - // If continuing a previous task, combine into a single prompt so the agent - // understands what was already attempted. - const effectiveTask = continueTask - ? `Original task: ${task}\n\nFollow-up instruction: ${continueTask}` - : task!; + // Scope workspace to the app subdirectory so the agent works there naturally + if (appPath) { + const path = require("path") as typeof import("path"); + ctx.workspaceRoot = path.join(ctx.workspaceRoot, appPath); + const fs = require("fs") as typeof import("fs"); + fs.mkdirSync(ctx.workspaceRoot, { recursive: true }); + } - // Run the streaming agent loop (fire and forget) - runSessionAgent(agentConfig, effectiveTask, ctx, { - sessionId, - projectId, - vibnApiUrl, - appPath, - repoRoot, - isStopped: () => sessionState.stopped, - autoApprove: autoApprove ?? true, - giteaRepo, - coolifyAppUuid, - coolifyApiUrl: process.env.COOLIFY_API_URL, - coolifyApiToken: process.env.COOLIFY_API_TOKEN, - }) - .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(() => {}); + const agentConfig = AGENTS["Coder"]; + if (!agentConfig) { + 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; + } + + // If continuing a previous task, combine into a single prompt so the agent + // understands what was already attempted. + const effectiveTask = continueTask + ? `Original task: ${task}\n\nFollow-up instruction: ${continueTask}` + : task!; + + // Run the streaming agent loop (fire and forget) + runSessionAgent(agentConfig, effectiveTask, ctx, { + sessionId, + projectId, + vibnApiUrl, + appPath, + repoRoot, + isStopped: () => sessionState.stopped, + autoApprove: autoApprove ?? true, + giteaRepo, + coolifyAppUuid, + coolifyApiUrl: process.env.COOLIFY_API_URL, + coolifyApiToken: process.env.COOLIFY_API_TOKEN, + }) + .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: patchHeaders, + body: JSON.stringify({ status: "failed", error: msg }), + }, + ).catch(() => {}); }) .finally(() => { - activeSessions.delete(sessionId); + activeSessions.delete(sessionId); }); }); -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; } - const session = activeSessions.get(sessionId); - if (session) { - session.stopped = true; - res.json({ status: 'stopped' }); - } else { - res.status(404).json({ error: 'session not found or not running' }); - } +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; + } + const session = activeSessions.get(sessionId); + if (session) { + session.stopped = true; + res.json({ status: "stopped" }); + } else { + 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(', ')}`); - if (!process.env.GOOGLE_API_KEY) { - console.warn('WARNING: GOOGLE_API_KEY is not set — agents will fail'); - } + console.log(`AgentRunner listening on port ${PORT}`); + 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"); + } }); diff --git a/vibn-agent-runner/src/test-execute-hardening.ts b/vibn-agent-runner/src/test-execute-hardening.ts new file mode 100644 index 0000000..ecb1e7f --- /dev/null +++ b/vibn-agent-runner/src/test-execute-hardening.ts @@ -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();