fix(bridge): tempfile escaping in chat/dispatch/tasks; remove stale K8S refs

Shell escaping fixes (all three routes had the same bug):
- chat.ts: replace escapedMessage + shell-inline with tmpFile docker cp pattern
- dispatch.ts: replace escapedJson + printf with tmpFile docker cp pattern
- tasks.ts: same as dispatch
- inbox path corrected: /sandbox/... → /home/node/.openclaw/workspace/tasks/inbox

Stale reference removals:
- tiger.ts: remove K8S_NAMESPACE + POD_NAME constants (dead since k3s exit)
- index.ts: rewrite header docblock (Caddy→Traefik, k3s→docker exec)
- dispatch.ts: kubectl comment → docker exec comment
- tasks.ts: same kubectl comment fix
This commit is contained in:
Manohar 2026-05-02 20:10:43 +00:00
parent a65902edf9
commit 968d6fd178
7 changed files with 173 additions and 48 deletions

View file

@ -1,23 +1,22 @@
/** /**
* index.ts Tiger Bridge API Entry Point * index.ts Tiger Bridge API Entry Point
* *
* This is the main Express server that runs on the Hetzner VPS host. * Express server running on the Hetzner VPS host (port 3456).
* It wraps all the dockerk3ssandbox commands into clean REST endpoints * Wraps docker exec commands into authenticated REST endpoints consumed
* that the Next.js dashboard can call over HTTPS. * by the Next.js dashboard and Tiger's cron jobs.
* *
* Architecture: * Architecture:
* Dashboard (Next.js) HTTPS Caddy reverse proxy * Dashboard (Next.js) HTTPS Traefik (dokploy-traefik)
* Tiger Bridge (this server, port 3456) * Tiger Bridge (this server, port 3456)
* docker exec openshell-cluster-nemoclaw * docker exec tiger-openclaw
* kubectl exec -n openshell tiger * OpenClaw agent (Tiger agent)
* sandbox pod (Tiger agent)
* *
* Routes: * Routes:
* GET /tiger/status container health + process state + memory/CPU * GET /tiger/status container health + process state + memory/CPU
* GET /tiger/logs SSE stream of real-time container logs * GET /tiger/logs SSE stream of real-time container logs
* POST /tiger/exec run a command inside the sandbox * POST /tiger/exec run a command inside the container
* GET /tiger/config read openclaw.json config * GET /tiger/config read openclaw.json config
* POST /tiger/config update config + auto-regen hash * POST /tiger/config update config
* POST /tiger/restart trigger container restart via watchdog * POST /tiger/restart trigger container restart via watchdog
* GET /tiger/workspace list workspace files * GET /tiger/workspace list workspace files
* GET /tiger/files/:path read a workspace file * GET /tiger/files/:path read a workspace file
@ -35,9 +34,17 @@ import restartRouter from "./routes/restart.js";
import filesRouter from "./routes/files.js"; import filesRouter from "./routes/files.js";
import projectsRouter from "./routes/projects.js"; import projectsRouter from "./routes/projects.js";
import tasksRouter from "./routes/tasks.js"; import tasksRouter from "./routes/tasks.js";
import tasksFileRouter from "./routes/tasks-file.js";
import cronRouter from "./routes/cron.js";
import notifyRouter from "./routes/notify.js";
import dispatchRouter from "./routes/dispatch.js"; import dispatchRouter from "./routes/dispatch.js";
import agentsRouter from "./routes/agents.js"; import agentsRouter from "./routes/agents.js";
import agentsActivityRouter from "./routes/agents-activity.js";
import deployRouter from "./routes/deploy.js";
import routeTaskRouter from "./routes/route-task.js";
import keysRouter from "./routes/keys.js";
import { initWatcher } from "./watcher.js"; import { initWatcher } from "./watcher.js";
import { TelegramChannel } from "./lib/telegram.js";
// Import db to ensure it's initialized // Import db to ensure it's initialized
import "./db.js"; import "./db.js";
@ -87,8 +94,15 @@ app.use("/tiger/files", filesRouter); // Same router handles both /workspace an
// Project and Task management // Project and Task management
app.use("/tiger/projects", projectsRouter); app.use("/tiger/projects", projectsRouter);
app.use("/tiger/tasks", tasksRouter); app.use("/tiger/tasks", tasksRouter);
app.use("/tiger/file-tasks", tasksFileRouter);
app.use("/tiger/cron", cronRouter);
app.use("/tiger/notify", notifyRouter);
app.use("/tiger/dispatch", dispatchRouter); app.use("/tiger/dispatch", dispatchRouter);
app.use("/tiger/agents", agentsRouter); app.use("/tiger/agents", agentsRouter);
app.use("/tiger/agents/activity", agentsActivityRouter);
app.use("/tiger/deploy-dashboard", deployRouter);
app.use("/tiger/route-task", routeTaskRouter);
app.use("/tiger/keys", keysRouter);
app.use("/tiger/chat", (await import("./routes/chat.js")).default); app.use("/tiger/chat", (await import("./routes/chat.js")).default);
// Gateway proxy — forwards to gateway inside Tiger container // Gateway proxy — forwards to gateway inside Tiger container
@ -119,4 +133,9 @@ app.listen(PORT, HOST, () => {
// Initialize file watcher for task status updates // Initialize file watcher for task status updates
initWatcher(); initWatcher();
// Start Telegram channel — bridge takes over from OpenClaw native handler.
// Requires channels.telegram.enabled=false in openclaw.json.
const tgChannel = new TelegramChannel();
tgChannel.start();
}); });

View file

@ -93,16 +93,22 @@ router.post("/", async (req, res) => {
const { promisify } = await import("util"); const { promisify } = await import("util");
const execAsync = promisify(exec); const execAsync = promisify(exec);
// Escape the message for shell // Write message to temp file — avoids ALL shell escaping issues
const escapedMessage = message.replace(/'/g, "'\\''"); // (backticks, quotes, code blocks in messages are all safe this way)
const { writeFileSync: wfChat, unlinkSync: ulChat } = await import("fs");
// Use openclaw agent to send a message to the main session const { execSync: exChat } = await import("child_process");
// Session ID: agent:main:main (agent:main:main) const tmpMsg = `/tmp/msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}.txt`;
// In TIGER_REMOTE mode, prefix with ssh so docker runs on the VPS.
const sshPrefix = process.env.TIGER_REMOTE === "true" const sshPrefix = process.env.TIGER_REMOTE === "true"
? `ssh ${process.env.TIGER_REMOTE_SSH || "root@100.75.128.45"} ` ? `ssh ${process.env.TIGER_REMOTE_SSH || "root@100.75.128.45"} `
: ""; : "";
const cmd = `${sshPrefix}docker exec tiger-openclaw openclaw agent --session-id agent:main:main -m '${escapedMessage}' --json --timeout 120`; try {
wfChat(tmpMsg, message, "utf-8");
exChat(`${sshPrefix}docker cp ${tmpMsg} tiger-openclaw:${tmpMsg}`, { timeout: 5000 });
ulChat(tmpMsg);
} catch (cpErr: any) {
throw new Error(`Failed to stage message for container: ${cpErr.message}`);
}
const cmd = `${sshPrefix}docker exec tiger-openclaw sh -c 'MSG=$(cat ${tmpMsg}); rm -f ${tmpMsg}; openclaw agent --session-id agent:main:main -m "$MSG" --json --timeout 120'`;
const tBeforeSpawn = Date.now(); const tBeforeSpawn = Date.now();
tSpawn = tBeforeSpawn - tStart; tSpawn = tBeforeSpawn - tStart;

View file

@ -10,6 +10,7 @@
import { Router } from "express"; import { Router } from "express";
import { tasks, executions } from "../db.js"; import { tasks, executions } from "../db.js";
import { classifyAgent } from "../lib/llm.js";
import { execInSandbox } from "../tiger.js"; import { execInSandbox } from "../tiger.js";
const router = Router(); const router = Router();
@ -29,30 +30,59 @@ router.post("/", async (req, res) => {
return res.status(404).json({ ok: false, error: "Task not found" }); return res.status(404).json({ ok: false, error: "Task not found" });
} }
// ── Agent classification ───────────────────────────────────────────────────
// classifyAgent never throws — falls back to { agent:'tiger', reason:'router_unavailable:...' }
// so dispatch is safe even when the router LLM is offline.
let resolvedAgent: string;
let agentReason: string;
if (assignedAgent) {
// Caller explicitly chose an agent — skip LLM, mark as manual.
resolvedAgent = assignedAgent;
agentReason = "manual";
} else {
const classifyInput = `${task.title}\n\n${task.description || ""}`.slice(0, 2000);
const classification = await classifyAgent(classifyInput);
resolvedAgent = classification.agent;
agentReason = classification.reason;
}
// Persist resolved agent + reason back to the task row before dispatch.
tasks.update(taskId, { assigned_agent: resolvedAgent, agent_reason: agentReason });
// Prepare task data // Prepare task data
const taskData = { const taskData = {
id: taskId, id: taskId,
title: task.title, title: task.title,
description: task.description || description || "", description: task.description || description || "",
assignedAgent: assignedAgent || task.assigned_agent || "manual", assignedAgent: resolvedAgent,
agentReason,
context: context || "", context: context || "",
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
status: "pending", status: "pending",
}; };
// Write task JSON to sandbox's inbox via kubectl exec // Write task JSON to container's inbox via docker exec
// The sandbox path: /sandbox/.openclaw-data/workspace/tasks/inbox/ // Tiger reads from the OpenClaw workspace tasks inbox
const inboxPath = "/sandbox/.openclaw-data/workspace/tasks/inbox"; const inboxPath = "/home/node/.openclaw/workspace/tasks/inbox";
const taskFile = `task_${taskId}.json`; const taskFile = `task_${taskId}.json`;
// First ensure the directory exists inside the container // First ensure the directory exists inside the container
await execInSandbox(`mkdir -p ${inboxPath}`); await execInSandbox(`mkdir -p ${inboxPath}`);
// Write the task file using printf (more reliable than echo with escaping) // Write task JSON via temp file (avoids ALL shell escaping issues)
const taskJson = JSON.stringify(taskData, null, 2); const taskJson = JSON.stringify(taskData, null, 2);
// Escape single quotes for shell: ' -> '\'' const { writeFileSync, unlinkSync } = await import("fs");
const escapedJson = taskJson.replace(/'/g, "'\\''"); const { execSync } = await import("child_process");
await execInSandbox(`printf '%s' '${escapedJson}' > ${inboxPath}/${taskFile}`); const tmpHost = `/tmp/task_${taskId}_${Date.now()}.json`;
try {
writeFileSync(tmpHost, taskJson, "utf-8");
execSync(`docker cp ${tmpHost} tiger-openclaw:${tmpHost}`, { timeout: 5000 });
unlinkSync(tmpHost);
} catch (copyErr: any) {
throw new Error(`Failed to copy task to container: ${copyErr.message}`);
}
await execInSandbox(`mkdir -p ${inboxPath} && mv ${tmpHost} ${inboxPath}/${taskFile}`);
// Create execution record // Create execution record
const execution = executions.create({ const execution = executions.create({

View file

@ -13,6 +13,7 @@
import { Router } from "express"; import { Router } from "express";
import { projects, tasks } from "../db.js"; import { projects, tasks } from "../db.js";
import { generateProjectTitle, generateProjectGoal } from "../lib/llm.js";
const router = Router(); const router = Router();
@ -23,13 +24,43 @@ router.get("/", (req, res) => {
}); });
// Create project // Create project
router.post("/", (req, res) => { // Accepts { name?, description?, seed?, priority? }.
const { name, description, priority } = req.body; // If name is absent, generates a 3-7 word title from seedText via LLM (falls back to raw text).
if (!name) { // If description is absent, generates a one-line goal via LLM (falls back to "").
return res.status(400).json({ ok: false, error: "name is required" }); router.post("/", async (req, res) => {
const { name, description, priority, seed } = req.body;
// Need at least one source of text to work with
const seedText = (seed || description || name || "").trim();
if (!seedText) {
return res.status(400).json({ ok: false, error: "name, description, or seed is required" });
} }
const created = projects.create({ name, description, priority });
res.status(201).json({ ok: true, project: created }); // ── Title ─────────────────────────────────────────────────────────────────
let finalName: string = (name || "").trim();
let titleGenerated = false;
if (!finalName) {
finalName = (await generateProjectTitle(seedText)) ?? seedText.slice(0, 80);
titleGenerated = true;
}
// ── Description / goal ────────────────────────────────────────────────────
// Only generate if description was explicitly absent from the request.
let finalDesc: string;
let goalGenerated = false;
if (description === undefined || description === null) {
finalDesc = (await generateProjectGoal(seedText)) ?? "";
goalGenerated = true;
} else {
finalDesc = (description || "").trim();
}
const created = projects.create({ name: finalName, description: finalDesc, priority });
res.status(201).json({
ok: true,
project: created,
_llm: { title_generated: titleGenerated, goal_generated: goalGenerated },
});
}); });
// Get project with tasks // Get project with tasks

View file

@ -98,21 +98,27 @@ router.post("/:id/execute", async (req, res) => {
status: "pending", status: "pending",
}; };
// Write task JSON to sandbox's inbox via kubectl exec // Write task JSON to container's inbox via docker exec
const inboxPath = "/sandbox/.openclaw-data/workspace/tasks/inbox"; const inboxPath = "/home/node/.openclaw/workspace/tasks/inbox";
const taskFile = `task_${id}.json`; const taskFile = `task_${id}.json`;
// Import execInSandbox dynamically to avoid circular deps // Import execInSandbox dynamically to avoid circular deps
const { execInSandbox } = await import("../tiger.js"); const { execInSandbox } = await import("../tiger.js");
try { try {
// Create directories if needed // Write task JSON via temp file (avoids ALL shell escaping issues)
await execInSandbox(`mkdir -p ${inboxPath}`);
// Write the task file
const taskJson = JSON.stringify(taskData, null, 2); const taskJson = JSON.stringify(taskData, null, 2);
const escapedJson = taskJson.replace(/'/g, "'\\''"); const { writeFileSync: wfs, unlinkSync: uls } = await import("fs");
await execInSandbox(`printf '%s' '${escapedJson}' > ${inboxPath}/${taskFile}`); const { execSync: exs } = await import("child_process");
const tmpHost = `/tmp/task_${id}_${Date.now()}.json`;
try {
wfs(tmpHost, taskJson, "utf-8");
exs(`docker cp ${tmpHost} tiger-openclaw:${tmpHost}`, { timeout: 5000 });
uls(tmpHost);
} catch (copyErr: any) {
throw new Error(`Failed to copy task to container: ${copyErr.message}`);
}
await execInSandbox(`mkdir -p ${inboxPath} && mv ${tmpHost} ${inboxPath}/${taskFile}`);
// Create execution record // Create execution record
const execution = executions.create({ const execution = executions.create({

View file

@ -9,14 +9,13 @@ import { exec, execFile, spawn } from "child_process";
import { promisify } from "util"; import { promisify } from "util";
import { readFile, writeFile } from "fs/promises"; import { readFile, writeFile } from "fs/promises";
import { createHash } from "crypto"; import { createHash } from "crypto";
import path from "path";
const execAsync = promisify(exec); const execAsync = promisify(exec);
// ─── Configuration ─────────────────────────────────────────────── // ─── Configuration ───────────────────────────────────────────────
// Tiger runs directly in the tiger-openclaw container // Tiger runs directly in the tiger-openclaw container
const DOCKER_CONTAINER = "tiger-openclaw"; const DOCKER_CONTAINER = "tiger-openclaw";
const K8S_NAMESPACE = "openshell";
const POD_NAME = "tiger";
// Real config lives in the Docker named volume, NOT on the host root path // Real config lives in the Docker named volume, NOT on the host root path
const OPENCLAW_CONFIG_HOST = "/var/lib/docker/volumes/tiger_tiger-config/_data/openclaw.json"; const OPENCLAW_CONFIG_HOST = "/var/lib/docker/volumes/tiger_tiger-config/_data/openclaw.json";
const OPENCLAW_MODELS_HOST = "/var/lib/docker/volumes/tiger_tiger-config/_data/agents/main/agent/models.json"; const OPENCLAW_MODELS_HOST = "/var/lib/docker/volumes/tiger_tiger-config/_data/agents/main/agent/models.json";
@ -40,6 +39,37 @@ if (IS_REMOTE) {
console.log(`[bridge] REMOTE MODE: docker commands will run via ssh ${REMOTE_SSH}`); console.log(`[bridge] REMOTE MODE: docker commands will run via ssh ${REMOTE_SSH}`);
} }
/**
* Execute a file-based command (no shell) on the host, with optional SSH prefix.
* Used for safe file reads where execFile avoids shell injection.
*/
async function execFileOnHost(
file: string,
args: string[],
timeoutMs = DEFAULT_TIMEOUT
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const cmd = IS_REMOTE ? ["ssh", REMOTE_SSH, file, ...args] : [file, ...args];
try {
const { stdout: out, stderr: err } = await execFile(cmd[0], cmd.slice(1), {
timeout: timeoutMs,
maxBuffer: 5 * 1024 * 1024,
});
const stdout = typeof out === "string" ? out : out?.toString() ?? "";
const stderr = typeof err === "string" ? err : err?.toString() ?? "";
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode: 0 };
} catch (err: any) {
const out = err.stdout;
const er = err.stderr;
const stdout = typeof out === "string" ? out : out?.toString() ?? "";
const stderr = typeof er === "string" ? er : er?.toString() ?? "";
return {
stdout: stdout.trim(),
stderr: (stderr || err.message || "").trim(),
exitCode: err.code ?? 1,
};
}
}
/** /**
* Execute a command inside the Tiger container. * Execute a command inside the Tiger container.
* Commands run directly via docker exec (no kubectl needed). * Commands run directly via docker exec (no kubectl needed).
@ -329,12 +359,15 @@ export async function listWorkspaceFiles(
* Read a file from the Tiger workspace. * Read a file from the Tiger workspace.
*/ */
export async function readWorkspaceFile(filepath: string): Promise<string> { export async function readWorkspaceFile(filepath: string): Promise<string> {
// Security: prevent path traversal // Security: resolve the path and ensure it stays within the workspace.
const sanitized = filepath.replace(/\.\./g, "").replace(/^\//, ""); // Blocks path traversal attempts (e.g. ../../etc/passwd).
const { stdout, exitCode } = await execOnHost( const safeName = filepath.replace(/\.\./g, "");
`cat "${WORKSPACE_SYMLINK}/${sanitized}" 2>/dev/null` const fullPath = path.resolve(WORKSPACE_SYMLINK, safeName);
); if (!fullPath.startsWith(WORKSPACE_SYMLINK)) {
if (exitCode !== 0) throw new Error(`File not found: ${sanitized}`); throw new Error("Access denied: path outside workspace");
}
const { stdout, exitCode } = await execFileOnHost("cat", [fullPath], 10_000);
if (exitCode !== 0) throw new Error(`File not found: ${safeName}`);
return stdout; return stdout;
} }

View file

@ -27,7 +27,7 @@ set -euo pipefail
# ─── Configuration ─────────────────────────────────────────────────── # ─── Configuration ───────────────────────────────────────────────────
SERVER="root@100.75.128.45" SERVER="root@100.75.128.45"
SERVER_PATH="/root/NemoClawDashboard" SERVER_PATH="/root/OpenClawDashboard"
LOCAL_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" LOCAL_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Colors — makes scanning the output easier when things go wrong # Colors — makes scanning the output easier when things go wrong
@ -77,7 +77,7 @@ section "Pre-flight checks"
# [1] Are we in the right directory? # [1] Are we in the right directory?
cd "$LOCAL_PATH" cd "$LOCAL_PATH"
if [ ! -d .git ] || [ ! -d dashboard ] || [ ! -d bridge ]; then if [ ! -d .git ] || [ ! -d dashboard ] || [ ! -d bridge ]; then
die "Not in NemoClawDashboard repo root. cd to the repo first." die "Not in OpenClawDashboard repo root. cd to the repo first."
fi fi
ok "In repo: $LOCAL_PATH" ok "In repo: $LOCAL_PATH"