From 2c96c62ca93ea171d7b675f4c44ce53160b097e2 Mon Sep 17 00:00:00 2001 From: underwaterresearch Date: Sat, 4 Apr 2026 11:11:17 +0000 Subject: [PATCH] fix: centralize gateway availability status policy --- backend-api/__tests__/controlPlane.test.js | 20 ++++++++++++++++++++ backend-api/agentStatus.js | 6 +++++- backend-api/routes/agents.js | 4 ++-- backend-api/server.js | 12 +++++++----- 4 files changed, 34 insertions(+), 8 deletions(-) diff --git a/backend-api/__tests__/controlPlane.test.js b/backend-api/__tests__/controlPlane.test.js index 1aee096..011ef98 100644 --- a/backend-api/__tests__/controlPlane.test.js +++ b/backend-api/__tests__/controlPlane.test.js @@ -109,6 +109,7 @@ describe("gateway control-plane embed", () => { host: "10.0.0.10", gateway_token: "gateway-password", gateway_host_port: null, + status: "running", }], }); global.fetch.mockResolvedValue({ @@ -143,6 +144,7 @@ describe("gateway control-plane embed", () => { host: "10.0.0.10", gateway_token: "gateway-password", gateway_host_port: 19123, + status: "running", }], }); global.fetch.mockResolvedValue({ @@ -161,4 +163,22 @@ describe("gateway control-plane embed", () => { expect.any(Object) ); }); + + it("rejects embed for error agents so failed control-plane state stays closed", async () => { + mockDb.query.mockResolvedValueOnce({ + rows: [{ + host: "10.0.0.10", + gateway_token: "gateway-password", + gateway_host_port: 19123, + status: "error", + }], + }); + + const res = await request(app) + .get(`/agents/agent-1/gateway/embed?token=${encodeURIComponent(token)}`) + .set("Host", "nora.test"); + + expect(res.status).toBe(404); + expect(global.fetch).not.toHaveBeenCalled(); + }); }); diff --git a/backend-api/agentStatus.js b/backend-api/agentStatus.js index 098be84..d9e1b44 100644 --- a/backend-api/agentStatus.js +++ b/backend-api/agentStatus.js @@ -1,3 +1,7 @@ +function isGatewayAvailableStatus(status) { + return ["running", "warning"].includes(status); +} + function reconcileAgentStatus(currentStatus, liveRunning) { if (currentStatus === "queued" || currentStatus === "deploying") { return currentStatus; @@ -16,4 +20,4 @@ function reconcileAgentStatus(currentStatus, liveRunning) { return currentStatus; } -module.exports = { reconcileAgentStatus }; +module.exports = { isGatewayAvailableStatus, reconcileAgentStatus }; diff --git a/backend-api/routes/agents.js b/backend-api/routes/agents.js index 255ed1e..6eb9a10 100644 --- a/backend-api/routes/agents.js +++ b/backend-api/routes/agents.js @@ -5,7 +5,7 @@ const billing = require("../billing"); const scheduler = require("../scheduler"); const containerManager = require("../containerManager"); const monitoring = require("../monitoring"); -const { reconcileAgentStatus } = require("../agentStatus"); +const { isGatewayAvailableStatus, reconcileAgentStatus } = require("../agentStatus"); const { OPENCLAW_GATEWAY_PORT } = require("../../agent-runtime/lib/contracts"); const { asyncHandler } = require("../middleware/errorHandler"); @@ -93,7 +93,7 @@ router.get("/:id/gateway-url", asyncHandler(async (req, res) => { ); const agent = result.rows[0]; if (!agent) return res.status(404).json({ error: "Agent not found" }); - if (!["running", "warning"].includes(agent.status)) { + if (!isGatewayAvailableStatus(agent.status)) { return res.status(409).json({ error: "Agent gateway is only available while running" }); } if (!agent.container_id) return res.status(409).json({ error: "No container" }); diff --git a/backend-api/server.js b/backend-api/server.js index 509fd9e..fe16797 100644 --- a/backend-api/server.js +++ b/backend-api/server.js @@ -14,7 +14,7 @@ const { getBootstrapAdminSeedConfig } = require("./bootstrapAdmin"); const { authenticateToken } = require("./middleware/auth"); const { correlationId, errorHandler } = require("./middleware/errorHandler"); const { createGatewayRouter, attachGatewayWS } = require("./gatewayProxy"); -const { reconcileAgentStatus } = require("./agentStatus"); +const { isGatewayAvailableStatus, reconcileAgentStatus } = require("./agentStatus"); const { OPENCLAW_GATEWAY_PORT } = require("../agent-runtime/lib/contracts"); // ─── JWT Secret ─────────────────────────────────────────────────── @@ -138,10 +138,12 @@ gatewayUIAssetProxy.get("/agents/:agentId/gateway/embed", async (req, res) => { const agentId = req.params.agentId; const result = await db.query( - "SELECT host, gateway_token, gateway_host_port FROM agents WHERE id = $1 AND user_id = $2 AND status IN ('running','warning') AND host IS NOT NULL", + "SELECT host, gateway_token, gateway_host_port, status FROM agents WHERE id = $1 AND user_id = $2 AND host IS NOT NULL", [agentId, payload.id] ); - if (!result.rows[0]) return res.status(404).send("agent not found or not running"); + if (!result.rows[0] || !isGatewayAvailableStatus(result.rows[0].status)) { + return res.status(404).send("agent not found or not running"); + } const gwHost = result.rows[0].gateway_host_port ? (process.env.GATEWAY_HOST || "host.docker.internal") : result.rows[0].host; const gwPort = result.rows[0].gateway_host_port || OPENCLAW_GATEWAY_PORT; @@ -205,10 +207,10 @@ async function proxyGatewayAsset(req, res) { const db = require("./db"); const agentId = req.params.agentId; const result = await db.query( - "SELECT host, gateway_host_port FROM agents WHERE id = $1 AND status IN ('running','warning') AND host IS NOT NULL", + "SELECT host, gateway_host_port, status FROM agents WHERE id = $1 AND host IS NOT NULL", [agentId] ); - if (!result.rows[0]) return res.status(404).end(); + if (!result.rows[0] || !isGatewayAvailableStatus(result.rows[0].status)) return res.status(404).end(); const gwHost = result.rows[0].gateway_host_port ? (process.env.GATEWAY_HOST || "host.docker.internal") : result.rows[0].host; const gwPort = result.rows[0].gateway_host_port || OPENCLAW_GATEWAY_PORT; const gatewayPath = req.path.split("/gateway/")[1] || "";