diff --git a/agent-runtime/lib/server.ts b/agent-runtime/lib/server.ts index 310cf19..4b8adc4 100644 --- a/agent-runtime/lib/server.ts +++ b/agent-runtime/lib/server.ts @@ -9,7 +9,7 @@ const http = require("http"); const os = require("os"); const fs = require("fs"); const path = require("path"); -const { execSync, spawn } = require("child_process"); +const { execSync, execFileSync, spawn } = require("child_process"); const { AGENT_RUNTIME_PORT, OPENCLAW_GATEWAY_PORT } = require("./contracts"); const { NORA_INTEGRATIONS_CONTEXT_FILE, @@ -51,10 +51,7 @@ const startTime = Date.now(); const GATEWAY_PORT = parseInt(process.env.OPENCLAW_GATEWAY_PORT || String(OPENCLAW_GATEWAY_PORT)); const GATEWAY_TOKEN = process.env.OPENCLAW_GATEWAY_TOKEN || ""; -const AGENT_TEMPLATE_ROOTS = [ - OPENCLAW_LEGACY_AGENT_TEMPLATE_ROOT, - OPENCLAW_WORKSPACE_ROOT, -]; +const AGENT_TEMPLATE_ROOTS = [OPENCLAW_LEGACY_AGENT_TEMPLATE_ROOT, OPENCLAW_WORKSPACE_ROOT]; const GENERATED_RUNTIME_FILE_NAMES = new Set([ "auth-profiles.json", NORA_INTEGRATIONS_CONTEXT_FILE, @@ -140,31 +137,21 @@ function buildIntegrationContextMarkdown(integrations = []) { for (const integration of syncedIntegrations) { const providerLabel = integration.name || integration.provider || "Integration"; const category = integration.category || "unknown"; - const capabilities = Array.isArray(integration.capabilities) - ? integration.capabilities - : []; - const toolSpecs = Array.isArray(integration.toolSpecs) - ? integration.toolSpecs - : []; - const usageHints = Array.isArray(integration.usageHints) - ? integration.usageHints - : []; + const capabilities = Array.isArray(integration.capabilities) ? integration.capabilities : []; + const toolSpecs = Array.isArray(integration.toolSpecs) ? integration.toolSpecs : []; + const usageHints = Array.isArray(integration.usageHints) ? integration.usageHints : []; const redactedConfig = integration.redactedConfig && typeof integration.redactedConfig === "object" ? integration.redactedConfig : {}; const visibleConfigEntries = Object.entries(redactedConfig).filter( - ([, value]) => value != null && value !== "" && value !== "[REDACTED]" + ([, value]) => value != null && value !== "" && value !== "[REDACTED]", ); const secretConfigKeys = Object.entries(redactedConfig) .filter(([, value]) => value === "[REDACTED]") .map(([key]) => key); - const api = integration.api && typeof integration.api === "object" - ? integration.api - : null; - const mcp = integration.mcp && typeof integration.mcp === "object" - ? integration.mcp - : null; + const api = integration.api && typeof integration.api === "object" ? integration.api : null; + const mcp = integration.mcp && typeof integration.mcp === "object" ? integration.mcp : null; lines.push(`## ${providerLabel}`); lines.push(""); @@ -179,9 +166,7 @@ function buildIntegrationContextMarkdown(integrations = []) { } if (api) { - const apiSummary = [api.type || "api", api.baseUrl || ""] - .filter(Boolean) - .join(" "); + const apiSummary = [api.type || "api", api.baseUrl || ""].filter(Boolean).join(" "); lines.push(`- API: ${apiSummary || "declared"}`); if (api.docsUrl) lines.push(`- API docs: ${api.docsUrl}`); if (api.authEnv) lines.push(`- API auth env: ${api.authEnv}`); @@ -218,7 +203,7 @@ function buildIntegrationContextMarkdown(integrations = []) { lines.push( execution.executable ? ` - Execution: available via \`${execution.invokeCommand}\`` - : " - Execution: discovery only" + : " - Execution: discovery only", ); } } @@ -251,11 +236,11 @@ function writeGeneratedRuntimeFile(relativePath, content) { function writeIntegrationContextFiles(integrations = []) { writeGeneratedRuntimeFile( NORA_INTEGRATIONS_CONTEXT_FILE, - buildIntegrationContextMarkdown(integrations) + buildIntegrationContextMarkdown(integrations), ); writeGeneratedRuntimeFile( NORA_INTEGRATIONS_SKILL_FILE, - buildIntegrationSkillMarkdown(integrations) + buildIntegrationSkillMarkdown(integrations), ); } @@ -276,7 +261,7 @@ async function forwardToGatewayAndReply(body) { method: "POST", headers: { "Content-Type": "application/json", - ...(GATEWAY_TOKEN ? { "Authorization": `Bearer ${GATEWAY_TOKEN}` } : {}), + ...(GATEWAY_TOKEN ? { Authorization: `Bearer ${GATEWAY_TOKEN}` } : {}), }, body: JSON.stringify({ messages: [{ role: "user", content }], @@ -292,16 +277,20 @@ async function forwardToGatewayAndReply(body) { const chatData = await chatRes.json(); // OpenAI-compatible response format - responseText = chatData.choices?.[0]?.message?.content - || chatData.content - || chatData.response - || JSON.stringify(chatData); + responseText = + chatData.choices?.[0]?.message?.content || + chatData.content || + chatData.response || + JSON.stringify(chatData); } catch (e) { - // If gateway HTTP endpoint isn't available, try the exec-based fallback + // If gateway HTTP endpoint isn't available, try the exec-based fallback. + // Use execFileSync with an argv array so neither OPENCLAW_CLI nor `content` + // is interpreted by a shell — content comes from a request body. try { - const result = execSync( - `${JSON.stringify(OPENCLAW_CLI)} chat --message ${JSON.stringify(content)} --no-interactive 2>/dev/null`, - { encoding: "utf8", timeout: 120000 } + const result = execFileSync( + OPENCLAW_CLI, + ["chat", "--message", String(content), "--no-interactive"], + { encoding: "utf8", timeout: 120000, stdio: ["ignore", "pipe", "ignore"] }, ); responseText = result.trim(); } catch { @@ -313,22 +302,33 @@ async function forwardToGatewayAndReply(body) { // Log the response const logLine = `${new Date().toISOString()} [CHANNEL] Response to ${channelType}: ${responseText.slice(0, 200)}`; - try { fs.appendFileSync(LOG_FILE, logLine + "\n"); } catch { /* ignore */ } + try { + fs.appendFileSync(LOG_FILE, logLine + "\n"); + } catch { + /* ignore */ + } // Send response back through the channel via backend API const apiUrl = process.env.BACKEND_API_URL || "http://backend-api:4000"; try { - await fetch(`${apiUrl}/agents/${process.env.AGENT_ID}/channels/${channelId}/send`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - content: responseText, - metadata: { inReplyTo: sender, channelType }, - }), - }); + await fetch( + `${apiUrl}/agents/${encodeURIComponent(process.env.AGENT_ID || "")}/channels/${encodeURIComponent(channelId)}/send`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + content: responseText, + metadata: { inReplyTo: sender, channelType }, + }), + }, + ); } catch (e) { const errLine = `${new Date().toISOString()} [CHANNEL] Failed to send reply: ${e.message}`; - try { fs.appendFileSync(LOG_FILE, errLine + "\n"); } catch { /* ignore */ } + try { + fs.appendFileSync(LOG_FILE, errLine + "\n"); + } catch { + /* ignore */ + } } } @@ -376,6 +376,12 @@ const server = http.createServer(async (req, res) => { } // ── POST /exec ──────────────────────────────────────── + // NOTE: the /exec endpoint is the designed terminal/command surface of the + // agent runtime. Authenticated callers pass an arbitrary shell command and + // it is executed inside the agent's container. The container sandbox is + // the isolation boundary here, not this endpoint. CodeQL's command-line + // injection rule is acknowledged and intentional — any change that + // sanitises `cmd` would break the feature. if (req.method === "POST" && path === "/exec") { const body = await parseBody(req); const cmd = body.command || body.cmd || "echo 'no command'"; @@ -414,11 +420,14 @@ const server = http.createServer(async (req, res) => { // Forward to the backend API for actual delivery const apiUrl = process.env.BACKEND_API_URL || "http://backend-api:4000"; try { - const response = await fetch(`${apiUrl}/agents/${process.env.AGENT_ID}/channels/${body.channelId}/send`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ content: body.content, metadata: body.metadata }), - }); + const response = await fetch( + `${apiUrl}/agents/${encodeURIComponent(process.env.AGENT_ID || "")}/channels/${encodeURIComponent(String(body.channelId || ""))}/send`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content: body.content, metadata: body.metadata }), + }, + ); const result = await response.json(); return json(res, response.status, result); } catch (e) { @@ -430,7 +439,11 @@ const server = http.createServer(async (req, res) => { if (req.method === "POST" && path === "/channels/receive") { const body = await parseBody(req); const line = `${new Date().toISOString()} [CHANNEL] Inbound from ${body.channelType}: ${body.content}`; - try { fs.appendFileSync(LOG_FILE, line + "\n"); } catch { /* ignore */ } + try { + fs.appendFileSync(LOG_FILE, line + "\n"); + } catch { + /* ignore */ + } // Respond immediately so the webhook caller isn't blocked json(res, 200, { received: true }); @@ -438,7 +451,11 @@ const server = http.createServer(async (req, res) => { // Asynchronously forward to the local OpenClaw gateway and send the response back forwardToGatewayAndReply(body).catch((e) => { const errLine = `${new Date().toISOString()} [CHANNEL] Gateway forward error: ${e.message}`; - try { fs.appendFileSync(LOG_FILE, errLine + "\n"); } catch { /* ignore */ } + try { + fs.appendFileSync(LOG_FILE, errLine + "\n"); + } catch { + /* ignore */ + } }); return; } @@ -453,7 +470,10 @@ const server = http.createServer(async (req, res) => { : []; try { fs.mkdirSync("/opt/openclaw", { recursive: true }); - fs.writeFileSync("/opt/openclaw/integrations.json", JSON.stringify(syncedIntegrations, null, 2)); + fs.writeFileSync( + "/opt/openclaw/integrations.json", + JSON.stringify(syncedIntegrations, null, 2), + ); writeIntegrationContextFiles(syncedIntegrations); return json(res, 200, { synced: true, count: syncedIntegrations.length }); } catch (e) { @@ -508,7 +528,7 @@ const server = http.createServer(async (req, res) => { ? entry.path.slice(prefix.length + 1) : entry.path; return !templatePathSet.has(relativePath); - }) + }), ) : []; @@ -529,7 +549,11 @@ const server = http.createServer(async (req, res) => { try { const policyPath = "/opt/openclaw/policy.yaml"; let policy = null; - try { policy = JSON.parse(fs.readFileSync(policyPath, "utf-8")); } catch { /* no policy file */ } + try { + policy = JSON.parse(fs.readFileSync(policyPath, "utf-8")); + } catch { + /* no policy file */ + } const model = process.env.NEMOCLAW_MODEL || "unknown"; const hasNvidia = !!process.env.NVIDIA_API_KEY; @@ -567,7 +591,10 @@ const server = http.createServer(async (req, res) => { // Attempt hot-reload via openshell CLI if available try { - execSync("openshell policy set /opt/openclaw/policy.yaml", { timeout: 5000, stdio: "ignore" }); + execSync("openshell policy set /opt/openclaw/policy.yaml", { + timeout: 5000, + stdio: "ignore", + }); } catch { // openshell CLI may not be present in all sandbox images — policy file is still updated } @@ -583,7 +610,11 @@ const server = http.createServer(async (req, res) => { try { const approvalsPath = "/opt/openclaw/pending-approvals.json"; let approvals = []; - try { approvals = JSON.parse(fs.readFileSync(approvalsPath, "utf-8")); } catch { /* no pending */ } + try { + approvals = JSON.parse(fs.readFileSync(approvalsPath, "utf-8")); + } catch { + /* no pending */ + } return json(res, 200, { approvals }); } catch (e) { return json(res, 500, { error: e.message }); @@ -597,7 +628,11 @@ const server = http.createServer(async (req, res) => { try { const approvalsPath = "/opt/openclaw/pending-approvals.json"; let approvals = []; - try { approvals = JSON.parse(fs.readFileSync(approvalsPath, "utf-8")); } catch { /* empty */ } + try { + approvals = JSON.parse(fs.readFileSync(approvalsPath, "utf-8")); + } catch { + /* empty */ + } const idx = approvals.findIndex((a) => a.id === rid); if (idx === -1) return json(res, 404, { error: "Approval request not found" }); @@ -620,9 +655,18 @@ const server = http.createServer(async (req, res) => { approved: true, }); fs.writeFileSync(policyPath, JSON.stringify(policy, null, 2)); - try { execSync("openshell policy set /opt/openclaw/policy.yaml", { timeout: 5000, stdio: "ignore" }); } catch { /* best effort */ } + try { + execSync("openshell policy set /opt/openclaw/policy.yaml", { + timeout: 5000, + stdio: "ignore", + }); + } catch { + /* best effort */ + } } - } catch { /* policy update best-effort */ } + } catch { + /* policy update best-effort */ + } } // Remove decided entries, keep only pending diff --git a/backend-api/agentMigrations.ts b/backend-api/agentMigrations.ts index b3a6ce5..dd33d8d 100644 --- a/backend-api/agentMigrations.ts +++ b/backend-api/agentMigrations.ts @@ -30,9 +30,7 @@ const { OPENCLAW_LEGACY_AGENT_TEMPLATE_ROOT, OPENCLAW_WORKSPACE_ROOT, } = require("../agent-runtime/lib/runtimeBootstrap"); -const { - NORA_INTEGRATIONS_SKILL_FILE, -} = require("../agent-runtime/lib/integrationTools"); +const { NORA_INTEGRATIONS_SKILL_FILE } = require("../agent-runtime/lib/integrationTools"); const { HERMES_CHANNEL_DEFINITIONS, HERMES_CHANNEL_TYPES, @@ -89,12 +87,8 @@ function normalizeManifestWarnings(warnings = []) { function summarizeManagedState(managed = {}) { return { - llmProviderCount: Array.isArray(managed.llmProviders) - ? managed.llmProviders.length - : 0, - integrationCount: Array.isArray(managed.integrations) - ? managed.integrations.length - : 0, + llmProviderCount: Array.isArray(managed.llmProviders) ? managed.llmProviders.length : 0, + integrationCount: Array.isArray(managed.integrations) ? managed.integrations.length : 0, channelCount: Array.isArray(managed.channels) ? managed.channels.length : 0, agentSecretCount: Array.isArray(managed.agentSecretOverrides) ? managed.agentSecretOverrides.length @@ -104,15 +98,15 @@ function summarizeManagedState(managed = {}) { function summarizeManifest(manifest = {}) { const templatePayload = normalizeTemplatePayload(manifest.templatePayload || {}); - const hermesFiles = Array.isArray(manifest?.hermesSeed?.files) - ? manifest.hermesSeed.files - : []; + const hermesFiles = Array.isArray(manifest?.hermesSeed?.files) ? manifest.hermesSeed.files : []; const managedSummary = summarizeManagedState(manifest.managed || {}); const warnings = normalizeManifestWarnings(manifest.warnings); return { runtimeFamily: - String(manifest.runtimeFamily || "").trim().toLowerCase() || "openclaw", + String(manifest.runtimeFamily || "") + .trim() + .toLowerCase() || "openclaw", fileCount: templatePayload.files.length, memoryFileCount: templatePayload.memoryFiles.length, hermesFileCount: hermesFiles.length, @@ -135,7 +129,9 @@ function buildDraftPreview(manifest = {}) { id: manifest.id || null, name: manifest.name || "Imported Agent", runtimeFamily: - String(manifest.runtimeFamily || "").trim().toLowerCase() || "openclaw", + String(manifest.runtimeFamily || "") + .trim() + .toLowerCase() || "openclaw", source: manifest.source || {}, summary: summarizeManifest(manifest), warnings, @@ -153,20 +149,16 @@ function buildDraftPreview(manifest = {}) { name: entry.name, enabled: entry.enabled !== false, })), - agentSecretOverrides: (manifest?.managed?.agentSecretOverrides || []).map( - (entry) => ({ - key: entry.key, - }) - ), + agentSecretOverrides: (manifest?.managed?.agentSecretOverrides || []).map((entry) => ({ + key: entry.key, + })), }, openclaw: { fileCount: templatePayload.files.length, memoryFileCount: templatePayload.memoryFiles.length, }, hermes: { - fileCount: Array.isArray(manifest?.hermesSeed?.files) - ? manifest.hermesSeed.files.length - : 0, + fileCount: Array.isArray(manifest?.hermesSeed?.files) ? manifest.hermesSeed.files.length : 0, modelConfig: manifest?.hermesSeed?.modelConfig || null, channels: hermesChannels.map((entry) => ({ type: entry.type, @@ -192,7 +184,7 @@ async function packMigrationBundle(manifest = {}) { if (error) return reject(error); bundle.finalize(); resolve(); - } + }, ); }); @@ -249,10 +241,7 @@ function normalizeMigrationManifest(rawManifest = {}) { version: 1, runtimeFamily, name: String(rawManifest.name || "Imported Agent").trim() || "Imported Agent", - source: - rawManifest.source && typeof rawManifest.source === "object" - ? rawManifest.source - : {}, + source: rawManifest.source && typeof rawManifest.source === "object" ? rawManifest.source : {}, templatePayload, hermesSeed: runtimeFamily === "hermes" && @@ -260,9 +249,7 @@ function normalizeMigrationManifest(rawManifest = {}) { typeof rawManifest.hermesSeed === "object" ? { version: 1, - files: Array.isArray(rawManifest.hermesSeed.files) - ? rawManifest.hermesSeed.files - : [], + files: Array.isArray(rawManifest.hermesSeed.files) ? rawManifest.hermesSeed.files : [], modelConfig: rawManifest.hermesSeed.modelConfig && typeof rawManifest.hermesSeed.modelConfig === "object" @@ -285,9 +272,7 @@ function normalizeMigrationManifest(rawManifest = {}) { channels: Array.isArray(rawManifest.managed.channels) ? rawManifest.managed.channels : [], - agentSecretOverrides: Array.isArray( - rawManifest.managed.agentSecretOverrides - ) + agentSecretOverrides: Array.isArray(rawManifest.managed.agentSecretOverrides) ? rawManifest.managed.agentSecretOverrides : [], } @@ -350,7 +335,16 @@ async function parseUploadedMigrationBuffer(buffer, filename = "") { async function readTarBufferFiles(buffer, { stripBaseName = "" } = {}) { const extract = tar.extract(); const files = []; - const normalizedBaseName = String(stripBaseName || "").replace(/^\/+|\/+$/g, ""); + let normalizedBaseName = String(stripBaseName || ""); + let startIndex = 0; + let endIndex = normalizedBaseName.length; + while (startIndex < endIndex && normalizedBaseName.charCodeAt(startIndex) === 0x2f) { + startIndex += 1; + } + while (endIndex > startIndex && normalizedBaseName.charCodeAt(endIndex - 1) === 0x2f) { + endIndex -= 1; + } + normalizedBaseName = normalizedBaseName.slice(startIndex, endIndex); const extractPromise = new Promise((resolve, reject) => { extract.on("entry", (header, stream, next) => { @@ -496,7 +490,7 @@ async function execSsh(source = {}, command, { timeout = 120000, binary = false const os = require("os"); keyPath = path.join( os.tmpdir(), - `nora-ssh-${Date.now()}-${Math.random().toString(16).slice(2)}.pem` + `nora-ssh-${Date.now()}-${Math.random().toString(16).slice(2)}.pem`, ); fs.writeFileSync(keyPath, String(source.privateKey), { mode: 0o600 }); args.push("-i", keyPath); @@ -525,8 +519,8 @@ async function execSsh(source = {}, command, { timeout = 120000, binary = false async function getSshArchiveFiles(source = {}, absolutePath) { const command = `sh -lc ${JSON.stringify( `if [ -d ${shellSingleQuote(absolutePath)} ]; then tar -C ${shellSingleQuote( - absolutePath - )} -cf - .; fi` + absolutePath, + )} -cf - .; fi`, )}`; const buffer = await execSsh(source, command, { timeout: 120000, @@ -539,9 +533,7 @@ async function getSshArchiveFiles(source = {}, absolutePath) { async function readSshText(source = {}, absolutePath) { const command = `sh -lc ${JSON.stringify( - `if [ -f ${shellSingleQuote(absolutePath)} ]; then cat ${shellSingleQuote( - absolutePath - )}; fi` + `if [ -f ${shellSingleQuote(absolutePath)} ]; then cat ${shellSingleQuote(absolutePath)}; fi`, )}`; return execSsh(source, command, { timeout: 30000, binary: false }).catch(() => ""); } @@ -624,14 +616,10 @@ async function readHermesSnapshotFromDocker(container) { } async function readHermesSnapshotFromSsh(source = {}) { - const output = await execSsh( - source, - buildHermesSnapshotCommand(), - { - timeout: 30000, - binary: false, - } - ); + const output = await execSsh(source, buildHermesSnapshotCommand(), { + timeout: 30000, + binary: false, + }); return JSON.parse(String(output || "{}").trim() || "{}"); } @@ -655,7 +643,7 @@ function manifestFromOpenClawSource({ { name, sourceType: "community", - } + }, ); return normalizeMigrationManifest({ @@ -745,12 +733,7 @@ function hermesChannelsFromSnapshot(snapshot = {}) { return { channels: channelsPayload, warnings }; } -function manifestFromHermesSource({ - name, - workspaceFiles = [], - snapshot = {}, - source = {}, -}) { +function manifestFromHermesSource({ name, workspaceFiles = [], snapshot = {}, source = {} }) { const { channels: hermesChannels, warnings } = hermesChannelsFromSnapshot(snapshot); return normalizeMigrationManifest({ @@ -778,7 +761,9 @@ async function buildLiveMigrationManifest(input = {}) { String(input.runtime_family || input.runtimeFamily || "") .trim() .toLowerCase() || "openclaw"; - const transport = String(input.transport || "").trim().toLowerCase(); + const transport = String(input.transport || "") + .trim() + .toLowerCase(); if (!["docker", "ssh"].includes(transport)) { throw new Error("Unsupported live migration transport"); @@ -789,32 +774,28 @@ async function buildLiveMigrationManifest(input = {}) { const containerRef = String(input.container_id || input.container || "").trim(); if (!containerRef) throw new Error("Docker live migration requires a container id or name"); const container = docker.getContainer(containerRef); - const [agentFiles, workspaceFiles, sessionFiles, authProfilesBuffer] = - await Promise.all([ - getDockerArchiveFiles(container, OPENCLAW_LEGACY_AGENT_TEMPLATE_ROOT), - getDockerArchiveFiles(container, OPENCLAW_WORKSPACE_ROOT), - getDockerArchiveFiles(container, "/root/.openclaw/agents/main/sessions"), - getDockerArchiveBuffer( - container, - `${OPENCLAW_LEGACY_AGENT_TEMPLATE_ROOT}/auth-profiles.json` - ), - ]); + const [agentFiles, workspaceFiles, sessionFiles, authProfilesBuffer] = await Promise.all([ + getDockerArchiveFiles(container, OPENCLAW_LEGACY_AGENT_TEMPLATE_ROOT), + getDockerArchiveFiles(container, OPENCLAW_WORKSPACE_ROOT), + getDockerArchiveFiles(container, "/root/.openclaw/agents/main/sessions"), + getDockerArchiveBuffer( + container, + `${OPENCLAW_LEGACY_AGENT_TEMPLATE_ROOT}/auth-profiles.json`, + ), + ]); const authFiles = authProfilesBuffer.length ? await readTarBufferFiles(authProfilesBuffer) : []; - const authProfileEntry = authFiles.find( - (entry) => entry.path === "auth-profiles.json" - ); + const authProfileEntry = authFiles.find((entry) => entry.path === "auth-profiles.json"); return manifestFromOpenClawSource({ - name: - String(input.name || "").trim() || `Imported OpenClaw ${containerRef.slice(0, 12)}`, + name: String(input.name || "").trim() || `Imported OpenClaw ${containerRef.slice(0, 12)}`, files: [...agentFiles, ...workspaceFiles].filter( (entry) => entry.path !== "auth-profiles.json" && entry.path !== NORA_INTEGRATIONS_CONTEXT_FILE && - entry.path !== NORA_INTEGRATIONS_SKILL_FILE + entry.path !== NORA_INTEGRATIONS_SKILL_FILE, ), memoryFiles: sessionFiles.map((entry) => ({ ...entry, @@ -823,7 +804,7 @@ async function buildLiveMigrationManifest(input = {}) { llmProviderEntries: llmProvidersFromAuthProfiles( authProfileEntry ? Buffer.from(authProfileEntry.contentBase64, "base64").toString("utf8") - : "" + : "", ), source: { kind: "docker", @@ -835,19 +816,19 @@ async function buildLiveMigrationManifest(input = {}) { const workspaceFiles = await getSshArchiveFiles( input, - input.workspace_root || OPENCLAW_WORKSPACE_ROOT + input.workspace_root || OPENCLAW_WORKSPACE_ROOT, ); const agentFiles = await getSshArchiveFiles( input, - input.agent_root || OPENCLAW_LEGACY_AGENT_TEMPLATE_ROOT + input.agent_root || OPENCLAW_LEGACY_AGENT_TEMPLATE_ROOT, ); const sessionFiles = await getSshArchiveFiles( input, - input.session_root || "/root/.openclaw/agents/main/sessions" + input.session_root || "/root/.openclaw/agents/main/sessions", ); const authProfilesText = await readSshText( input, - `${input.agent_root || OPENCLAW_LEGACY_AGENT_TEMPLATE_ROOT}/auth-profiles.json` + `${input.agent_root || OPENCLAW_LEGACY_AGENT_TEMPLATE_ROOT}/auth-profiles.json`, ); return manifestFromOpenClawSource({ @@ -856,7 +837,7 @@ async function buildLiveMigrationManifest(input = {}) { (entry) => entry.path !== "auth-profiles.json" && entry.path !== NORA_INTEGRATIONS_CONTEXT_FILE && - entry.path !== NORA_INTEGRATIONS_SKILL_FILE + entry.path !== NORA_INTEGRATIONS_SKILL_FILE, ), memoryFiles: sessionFiles.map((entry) => ({ ...entry, @@ -881,8 +862,7 @@ async function buildLiveMigrationManifest(input = {}) { ]); return manifestFromHermesSource({ - name: - String(input.name || "").trim() || `Imported Hermes ${containerRef.slice(0, 12)}`, + name: String(input.name || "").trim() || `Imported Hermes ${containerRef.slice(0, 12)}`, workspaceFiles, snapshot, source: { @@ -916,17 +896,14 @@ async function listUserRawLlmProviders(userId) { FROM llm_providers WHERE user_id = $1 ORDER BY created_at ASC`, - [userId] + [userId], ); return result.rows.map((row) => ({ provider: row.provider, apiKey: decrypt(row.api_key), model: row.model || null, - config: - typeof row.config === "string" - ? JSON.parse(row.config || "{}") - : row.config || {}, + config: typeof row.config === "string" ? JSON.parse(row.config || "{}") : row.config || {}, isDefault: row.is_default === true, })); } @@ -937,7 +914,7 @@ async function listAgentIntegrationSecrets(agentId) { FROM integrations WHERE agent_id = $1 ORDER BY created_at ASC`, - [agentId] + [agentId], ); return result.rows.map((row) => ({ @@ -955,7 +932,7 @@ async function listAgentChannelSecrets(agentId) { FROM channels WHERE agent_id = $1 ORDER BY created_at ASC`, - [agentId] + [agentId], ); return result.rows.map((row) => ({ @@ -968,7 +945,9 @@ async function listAgentChannelSecrets(agentId) { async function buildMigrationManifestFromAgent(agent, { userId }) { const runtimeFamily = - String(agent?.runtime_family || "").trim().toLowerCase() || "openclaw"; + String(agent?.runtime_family || "") + .trim() + .toLowerCase() || "openclaw"; if (runtimeFamily === "openclaw") { const [templatePayload, providerEntries, integrationEntries, channelEntries, overrideMap] = @@ -1016,9 +995,7 @@ async function buildMigrationManifestFromAgent(agent, { userId }) { getPersistedHermesState(agent.id), ]); - const state = liveSnapshot - ? snapshotToPersistedHermesState(liveSnapshot) - : persistedState; + const state = liveSnapshot ? snapshotToPersistedHermesState(liveSnapshot) : persistedState; return normalizeMigrationManifest({ name: agent.name || "Hermes Agent", @@ -1079,7 +1056,7 @@ async function createMigrationDraft({ JSON.stringify(summarizeManifest(normalizedManifest)), JSON.stringify(normalizeManifestWarnings(normalizedManifest.warnings)), encodeStoredManifest(normalizedManifest), - ] + ], ); const row = result.rows[0]; @@ -1101,7 +1078,7 @@ async function getOwnedMigrationDraft(draftId, userId) { summary, warnings, encrypted_manifest, created_at, expires_at, deployed_agent_id FROM agent_migrations WHERE id = $1 AND user_id = $2`, - [draftId, userId] + [draftId, userId], ); const row = result.rows[0]; if (!row) return null; @@ -1126,7 +1103,7 @@ async function getMigrationManifestForAgent(agentId) { WHERE deployed_agent_id = $1 ORDER BY created_at DESC LIMIT 1`, - [agentId] + [agentId], ); if (!result.rows[0]) return null; return decodeStoredManifest(result.rows[0].encrypted_manifest); @@ -1135,7 +1112,7 @@ async function getMigrationManifestForAgent(agentId) { async function deleteOwnedMigrationDraft(draftId, userId) { const result = await db.query( "DELETE FROM agent_migrations WHERE id = $1 AND user_id = $2 RETURNING id", - [draftId, userId] + [draftId, userId], ); return Boolean(result.rows[0]); } @@ -1146,7 +1123,7 @@ async function attachDraftToAgent(draftId, agentId) { SET deployed_agent_id = $2, expires_at = NULL WHERE id = $1`, - [draftId, agentId] + [draftId, agentId], ); } @@ -1157,7 +1134,7 @@ async function seedImportedLlmProviders(userId, providerEntries = []) { `SELECT provider FROM llm_providers WHERE user_id = $1`, - [userId] + [userId], ); const existingProviders = new Set(existing.rows.map((row) => row.provider)); @@ -1170,7 +1147,7 @@ async function seedImportedLlmProviders(userId, providerEntries = []) { provider, apiKey, entry?.model || null, - entry?.config || {} + entry?.config || {}, ); existingProviders.add(provider); } @@ -1185,14 +1162,14 @@ async function materializeManagedMigrationState(userId, agentId, manifest = {}) agentId, integrationEntry.provider, integrationEntry.token || "", - integrationEntry.config || {} + integrationEntry.config || {}, ); if (integrationEntry.status && integrationEntry.status !== "active") { await db.query( `UPDATE integrations SET status = $3 WHERE agent_id = $1 AND provider = $2`, - [agentId, integrationEntry.provider, integrationEntry.status] + [agentId, integrationEntry.provider, integrationEntry.status], ); } } @@ -1202,30 +1179,25 @@ async function materializeManagedMigrationState(userId, agentId, manifest = {}) agentId, channelEntry.type, channelEntry.name || channelEntry.type, - channelEntry.config || {} + channelEntry.config || {}, ); if (channelEntry.enabled === false && created?.id) { - await db.query( - "UPDATE channels SET enabled = false WHERE id = $1 AND agent_id = $2", - [created.id, agentId] - ); + await db.query("UPDATE channels SET enabled = false WHERE id = $1 AND agent_id = $2", [ + created.id, + agentId, + ]); } } const overrideMap = Object.fromEntries( - (managed.agentSecretOverrides || []).map((entry) => [ - entry.key, - entry.value, - ]) + (managed.agentSecretOverrides || []).map((entry) => [entry.key, entry.value]), ); await replaceAgentSecretOverrides(agentId, overrideMap); if (manifest.runtimeFamily === "hermes") { await replacePersistedHermesState(agentId, { modelConfig: manifest?.hermesSeed?.modelConfig || {}, - channels: Array.isArray(manifest?.hermesSeed?.channels) - ? manifest.hermesSeed.channels - : [], + channels: Array.isArray(manifest?.hermesSeed?.channels) ? manifest.hermesSeed.channels : [], }); } } @@ -1267,7 +1239,7 @@ async function buildHermesSeedArchive(manifest = {}) { (error) => { if (error) return reject(error); resolve(); - } + }, ); }); } diff --git a/backend-api/routes/auth.ts b/backend-api/routes/auth.ts index 5bc0002..0ab95de 100644 --- a/backend-api/routes/auth.ts +++ b/backend-api/routes/auth.ts @@ -25,8 +25,9 @@ const authLimiter = rateLimit({ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; function validateEmail(email) { if (!email || typeof email !== "string") return "Email is required"; - if (!EMAIL_RE.test(email)) return "Invalid email format"; + // Length check BEFORE regex so unbounded inputs can't drive backtracking cost. if (email.length > 255) return "Email too long"; + if (!EMAIL_RE.test(email)) return "Invalid email format"; return null; } function validatePassword(pw) { @@ -77,7 +78,7 @@ router.post("/signup", authLimiter, async (req, res) => { const role = await nextRegisteredUserRole(client); const result = await client.query( "INSERT INTO users(email, password_hash, role) VALUES($1, $2, $3) RETURNING id, email, role", - [normalizedEmail, hash, role] + [normalizedEmail, hash, role], ); return result.rows[0]; }); @@ -90,20 +91,23 @@ router.post("/signup", authLimiter, async (req, res) => { router.post("/login", authLimiter, async (req, res) => { const { email, password } = req.body; const normalizedEmail = normalizeEmail(email); - if (!normalizedEmail || !password) return res.status(400).json({ error: "Email and password required" }); + if (!normalizedEmail || !password) + return res.status(400).json({ error: "Email and password required" }); try { const result = await db.query("SELECT * FROM users WHERE email=$1", [normalizedEmail]); const user = result.rows[0]; if (!user) return res.status(401).json({ error: "Invalid email or password" }); if (!user.password_hash) { - return res.status(401).json({ error: `This account uses ${user.provider || "OAuth"} login. Please sign in with ${user.provider || "your OAuth provider"} instead.` }); + return res.status(401).json({ + error: `This account uses ${user.provider || "OAuth"} login. Please sign in with ${user.provider || "your OAuth provider"} instead.`, + }); } const ok = await bcrypt.compare(password, user.password_hash); if (!ok) return res.status(401).json({ error: "Invalid email or password" }); const token = jwt.sign( { id: user.id, email: user.email, role: user.role }, process.env.JWT_SECRET, - { expiresIn: "7d" } + { expiresIn: "7d" }, ); res.json({ token }); } catch (e) { @@ -113,17 +117,12 @@ router.post("/login", authLimiter, async (req, res) => { router.post("/oauth-login", authLimiter, async (req, res) => { if (!isOAuthLoginEnabled()) { - return res.status(403).json({ error: "OAuth login is disabled until server-side provider verification is implemented" }); + return res.status(403).json({ + error: "OAuth login is disabled until server-side provider verification is implemented", + }); } - const { - email, - name, - provider, - providerId, - oauthAccessToken, - oauthIdToken, - } = req.body || {}; + const { email, name, provider, providerId, oauthAccessToken, oauthIdToken } = req.body || {}; const normalizedProvider = normalizeProvider(provider); if (!normalizedProvider) return res.status(400).json({ error: "provider required" }); @@ -144,15 +143,12 @@ router.post("/oauth-login", authLimiter, async (req, res) => { const user = await withUserCreationLock(async (client) => { const linkedResult = await client.query( "SELECT id, email, role, name, provider, provider_id, password_hash FROM users WHERE provider = $1 AND provider_id = $2", - [normalizedProvider, verified.providerId] + [normalizedProvider, verified.providerId], ); const linkedUser = linkedResult.rows[0]; - if ( - linkedUser && - normalizeEmail(linkedUser.email) !== normalizedVerifiedEmail - ) { + if (linkedUser && normalizeEmail(linkedUser.email) !== normalizedVerifiedEmail) { const error = new Error( - `This ${normalizedProvider} account is already linked to another Nora user email.` + `This ${normalizedProvider} account is already linked to another Nora user email.`, ); error.statusCode = 409; throw error; @@ -160,20 +156,20 @@ router.post("/oauth-login", authLimiter, async (req, res) => { const existingResult = await client.query( "SELECT id, email, role, name, provider, provider_id, password_hash FROM users WHERE email = $1", - [normalizedVerifiedEmail] + [normalizedVerifiedEmail], ); const existingUser = existingResult.rows[0]; if (existingUser?.password_hash && !existingUser.provider) { const error = new Error( - "This email already uses password login. Sign in with password until account linking exists." + "This email already uses password login. Sign in with password until account linking exists.", ); error.statusCode = 409; throw error; } if (existingUser?.provider && existingUser.provider !== normalizedProvider) { const error = new Error( - `This account is already linked to ${existingUser.provider} login.` + `This account is already linked to ${existingUser.provider} login.`, ); error.statusCode = 409; throw error; @@ -183,13 +179,13 @@ router.post("/oauth-login", authLimiter, async (req, res) => { String(existingUser.provider_id) !== String(verified.providerId) ) { const error = new Error( - `This ${normalizedProvider} account is linked to a different Nora user.` + `This ${normalizedProvider} account is linked to a different Nora user.`, ); error.statusCode = 409; throw error; } - const role = existingUser?.role || await nextRegisteredUserRole(client); + const role = existingUser?.role || (await nextRegisteredUserRole(client)); const result = await client.query( `INSERT INTO users(email, name, provider, provider_id, role) VALUES($1, $2, $3, $4, $5) @@ -204,7 +200,7 @@ router.post("/oauth-login", authLimiter, async (req, res) => { normalizedProvider, verified.providerId, role, - ] + ], ); return result.rows[0]; }); @@ -212,7 +208,7 @@ router.post("/oauth-login", authLimiter, async (req, res) => { const token = jwt.sign( { id: user.id, email: user.email, role: user.role }, process.env.JWT_SECRET, - { expiresIn: "7d" } + { expiresIn: "7d" }, ); res.json({ token, user }); } catch (e) { @@ -222,7 +218,11 @@ router.post("/oauth-login", authLimiter, async (req, res) => { if (e.statusCode === 409) { return res.status(409).json({ error: e.message }); } - if (/verification failed|audience mismatch|email is not verified|email is missing or unverified|did not match|required/i.test(e.message)) { + if ( + /verification failed|audience mismatch|email is not verified|email is missing or unverified|did not match|required/i.test( + e.message, + ) + ) { return res.status(401).json({ error: e.message }); } res.status(500).json({ error: e.message }); @@ -234,12 +234,14 @@ router.post("/oauth-login", authLimiter, async (req, res) => { router.patch("/password", authenticateToken, async (req, res) => { try { const { currentPassword, newPassword } = req.body; - if (!currentPassword || !newPassword) return res.status(400).json({ error: "Both passwords required" }); + if (!currentPassword || !newPassword) + return res.status(400).json({ error: "Both passwords required" }); const pwErr = validatePassword(newPassword); if (pwErr) return res.status(400).json({ error: pwErr }); const user = (await db.query("SELECT * FROM users WHERE id = $1", [req.user.id])).rows[0]; if (!user) return res.status(404).json({ error: "User not found" }); - if (!user.password_hash) return res.status(400).json({ error: "OAuth user — no password to change" }); + if (!user.password_hash) + return res.status(400).json({ error: "OAuth user — no password to change" }); const ok = await bcrypt.compare(currentPassword, user.password_hash); if (!ok) return res.status(401).json({ error: "Current password is incorrect" }); const hash = await bcrypt.hash(newPassword, 10); @@ -254,7 +256,7 @@ router.get("/me", authenticateToken, async (req, res) => { try { const result = await db.query( "SELECT id, email, name, role, provider, avatar, created_at FROM users WHERE id = $1", - [req.user.id] + [req.user.id], ); if (!result.rows[0]) return res.status(404).json({ error: "User not found" }); res.json(result.rows[0]); @@ -302,7 +304,7 @@ router.patch("/profile", authenticateToken, async (req, res) => { values.push(req.user.id); const result = await db.query( `UPDATE users SET ${updates.join(", ")} WHERE id = $${idx} RETURNING name, avatar`, - values + values, ); res.json(result.rows[0]); } catch (e) { diff --git a/backend-api/routes/integrations.ts b/backend-api/routes/integrations.ts index 8cc81bc..7dedf74 100644 --- a/backend-api/routes/integrations.ts +++ b/backend-api/routes/integrations.ts @@ -19,7 +19,7 @@ async function getAgentIntegrationRuntimeTarget(agentId) { gateway_host_port, gateway_host, gateway_port, backend_type, runtime_family, deploy_target, sandbox_profile, user_id FROM agents WHERE id = $1`, - [agentId] + [agentId], ); return agentResult.rows[0] || null; } @@ -31,14 +31,12 @@ async function syncIntegrationsToAgent(agentId, { strictHermes = false } = {}) { if (resolveAgentRuntimeFamily(agent) === "hermes") { const syncResults = await syncAuthToUserAgents(agent.user_id, agent.id); const failedResult = Array.isArray(syncResults) - ? syncResults.find( - (entry) => entry?.agentId === agent.id && entry?.status === "failed" - ) + ? syncResults.find((entry) => entry?.agentId === agent.id && entry?.status === "failed") : null; if (strictHermes && failedResult) { const error = new Error( - failedResult.error || "Failed to sync Hermes integrations to runtime" + failedResult.error || "Failed to sync Hermes integrations to runtime", ); error.statusCode = 502; throw error; @@ -62,20 +60,26 @@ async function syncIntegrationsToAgent(agentId, { strictHermes = false } = {}) { body: JSON.stringify({ integrations: syncData }), }); } catch (e) { - console.warn(`[sync-integrations] Runtime sync failed for agent ${agentId} on port ${AGENT_RUNTIME_PORT}:`, e.message); + console.warn( + `[sync-integrations] Runtime sync failed for agent ${agentId} on port ${AGENT_RUNTIME_PORT}: ${String(e?.message || e)}`, + ); } // 2. Push decrypted tokens into the live gateway env via RPC - if (agent.status === 'running') { + if (agent.status === "running") { try { const envVars = await integrations.getIntegrationEnvVars(agentId); const count = Object.keys(envVars).length; if (count > 0) { - await rpcCall(agent, 'config.set', { env: envVars }); - console.log(`[sync-integrations] Pushed ${count} integration env var(s) to agent ${agentId} gateway`); + await rpcCall(agent, "config.set", { env: envVars }); + console.log( + `[sync-integrations] Pushed ${count} integration env var(s) to agent ${agentId} gateway`, + ); } } catch (e) { - console.warn(`[sync-integrations] Gateway env push failed for agent ${agentId}:`, e.message); + console.warn( + `[sync-integrations] Gateway env push failed for agent ${agentId}: ${String(e?.message || e)}`, + ); } } @@ -202,7 +206,9 @@ router.post("/agents/:id/integrations/tools/invoke", async (req, res) => { const input = req.body.input && typeof req.body.input === "object" && !Array.isArray(req.body.input) ? req.body.input - : req.body.arguments && typeof req.body.arguments === "object" && !Array.isArray(req.body.arguments) + : req.body.arguments && + typeof req.body.arguments === "object" && + !Array.isArray(req.body.arguments) ? req.body.arguments : {}; const result = await invokeAgentIntegrationTool(req.params.id, { diff --git a/workers/provisioner/backends/proxmox.ts b/workers/provisioner/backends/proxmox.ts index d2556ee..11fe7fa 100644 --- a/workers/provisioner/backends/proxmox.ts +++ b/workers/provisioner/backends/proxmox.ts @@ -10,9 +10,7 @@ const { roundMetric, toFiniteInteger, } = require("./telemetry"); -const { - PROXMOX_RELEASE_BLOCKER_ISSUE, -} = require("../../../agent-runtime/lib/backendCatalog"); +const { PROXMOX_RELEASE_BLOCKER_ISSUE } = require("../../../agent-runtime/lib/backendCatalog"); class ProxmoxBackend extends ProvisionerBackend { constructor() { @@ -21,7 +19,8 @@ class ProxmoxBackend extends ProvisionerBackend { this.tokenId = process.env.PROXMOX_TOKEN_ID; // e.g. root@pam!openclaw this.tokenSecret = process.env.PROXMOX_TOKEN_SECRET; this.node = process.env.PROXMOX_NODE || "pve"; - this.template = process.env.PROXMOX_TEMPLATE || "local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst"; + this.template = + process.env.PROXMOX_TEMPLATE || "local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst"; this.timeoutMs = 60000; } @@ -42,13 +41,17 @@ class ProxmoxBackend extends ProvisionerBackend { headers["Content-Length"] = Buffer.byteLength(body); } + // Proxmox self-signed certs are the norm on-prem; certificate verification + // is opt-in via PROXMOX_VERIFY_TLS=true (set it once CA trust is wired up). + const verifyTls = process.env.PROXMOX_VERIFY_TLS === "true"; + return new Promise((resolve, reject) => { const req = https.request( url, { method, headers, - rejectUnauthorized: false, + rejectUnauthorized: verifyTls, timeout: this.timeoutMs, }, (res) => { @@ -71,17 +74,16 @@ class ProxmoxBackend extends ProvisionerBackend { const statusCode = res.statusCode || 500; if (statusCode < 200 || statusCode >= 300) { - const detail = - parsed?.errors - ? JSON.stringify(parsed.errors) - : parsed?.message || raw || `HTTP ${statusCode}`; + const detail = parsed?.errors + ? JSON.stringify(parsed.errors) + : parsed?.message || raw || `HTTP ${statusCode}`; reject(new Error(detail)); return; } resolve(parsed); }); - } + }, ); req.on("timeout", () => { @@ -127,10 +129,7 @@ class ProxmoxBackend extends ProvisionerBackend { async status(containerId) { const vmid = containerId; try { - const data = await this._requestData( - "GET", - `/nodes/${this.node}/lxc/${vmid}/status/current` - ); + const data = await this._requestData("GET", `/nodes/${this.node}/lxc/${vmid}/status/current`); return { running: data.status === "running", uptime: data.uptime || 0, @@ -146,13 +145,9 @@ class ProxmoxBackend extends ProvisionerBackend { const vmid = containerId; try { - const data = await this._requestData( - "GET", - `/nodes/${this.node}/lxc/${vmid}/status/current` - ); + const data = await this._requestData("GET", `/nodes/${this.node}/lxc/${vmid}/status/current`); - const cpuPercent = - typeof data?.cpu === "number" ? roundMetric(data.cpu * 100) : null; + const cpuPercent = typeof data?.cpu === "number" ? roundMetric(data.cpu * 100) : null; const memoryUsageMb = bytesToMegabytes(data?.mem, 0); const memoryLimitMb = bytesToMegabytes(data?.maxmem, 0); const memoryPercent = @@ -179,8 +174,7 @@ class ProxmoxBackend extends ProvisionerBackend { current: { recorded_at: new Date().toISOString(), running: data?.status === "running", - uptime_seconds: - data?.status === "running" ? toFiniteInteger(data?.uptime) ?? 0 : 0, + uptime_seconds: data?.status === "running" ? (toFiniteInteger(data?.uptime) ?? 0) : 0, cpu_percent: cpuPercent, memory_usage_mb: memoryUsageMb, memory_limit_mb: memoryLimitMb, @@ -204,7 +198,9 @@ class ProxmoxBackend extends ProvisionerBackend { async stop(containerId) { const vmid = containerId; console.log(`[proxmox] Stopping LXC ${vmid}`); - await this._requestData("POST", `/nodes/${this.node}/lxc/${vmid}/status/shutdown`, { timeout: 30 }); + await this._requestData("POST", `/nodes/${this.node}/lxc/${vmid}/status/shutdown`, { + timeout: 30, + }); // Wait for graceful shutdown, then force-stop if needed for (let i = 0; i < 10; i++) { await new Promise((r) => setTimeout(r, 3000));