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
*
* This is the main Express server that runs on the Hetzner VPS host.
* It wraps all the dockerk3ssandbox commands into clean REST endpoints
* that the Next.js dashboard can call over HTTPS.
* Express server running on the Hetzner VPS host (port 3456).
* Wraps docker exec commands into authenticated REST endpoints consumed
* by the Next.js dashboard and Tiger's cron jobs.
*
* Architecture:
* Dashboard (Next.js) HTTPS Caddy reverse proxy
* Dashboard (Next.js) HTTPS Traefik (dokploy-traefik)
* Tiger Bridge (this server, port 3456)
* docker exec openshell-cluster-nemoclaw
* kubectl exec -n openshell tiger
* sandbox pod (Tiger agent)
* docker exec tiger-openclaw
* OpenClaw agent (Tiger agent)
*
* Routes:
* GET /tiger/status container health + process state + memory/CPU
* 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
* POST /tiger/config update config + auto-regen hash
* POST /tiger/config update config
* POST /tiger/restart trigger container restart via watchdog
* GET /tiger/workspace list workspace files
* 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 projectsRouter from "./routes/projects.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 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 { TelegramChannel } from "./lib/telegram.js";
// Import db to ensure it's initialized
import "./db.js";
@ -87,8 +94,15 @@ app.use("/tiger/files", filesRouter); // Same router handles both /workspace an
// Project and Task management
app.use("/tiger/projects", projectsRouter);
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/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);
// Gateway proxy — forwards to gateway inside Tiger container
@ -119,4 +133,9 @@ app.listen(PORT, HOST, () => {
// Initialize file watcher for task status updates
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 execAsync = promisify(exec);
// Escape the message for shell
const escapedMessage = message.replace(/'/g, "'\\''");
// Use openclaw agent to send a message to the main session
// Session ID: agent:main:main (agent:main:main)
// In TIGER_REMOTE mode, prefix with ssh so docker runs on the VPS.
// Write message to temp file — avoids ALL shell escaping issues
// (backticks, quotes, code blocks in messages are all safe this way)
const { writeFileSync: wfChat, unlinkSync: ulChat } = await import("fs");
const { execSync: exChat } = await import("child_process");
const tmpMsg = `/tmp/msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}.txt`;
const sshPrefix = process.env.TIGER_REMOTE === "true"
? `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();
tSpawn = tBeforeSpawn - tStart;

View file

@ -10,6 +10,7 @@
import { Router } from "express";
import { tasks, executions } from "../db.js";
import { classifyAgent } from "../lib/llm.js";
import { execInSandbox } from "../tiger.js";
const router = Router();
@ -29,30 +30,59 @@ router.post("/", async (req, res) => {
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
const taskData = {
id: taskId,
title: task.title,
description: task.description || description || "",
assignedAgent: assignedAgent || task.assigned_agent || "manual",
assignedAgent: resolvedAgent,
agentReason,
context: context || "",
createdAt: new Date().toISOString(),
status: "pending",
};
// Write task JSON to sandbox's inbox via kubectl exec
// The sandbox path: /sandbox/.openclaw-data/workspace/tasks/inbox/
const inboxPath = "/sandbox/.openclaw-data/workspace/tasks/inbox";
// Write task JSON to container's inbox via docker exec
// Tiger reads from the OpenClaw workspace tasks inbox
const inboxPath = "/home/node/.openclaw/workspace/tasks/inbox";
const taskFile = `task_${taskId}.json`;
// First ensure the directory exists inside the container
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);
// Escape single quotes for shell: ' -> '\''
const escapedJson = taskJson.replace(/'/g, "'\\''");
await execInSandbox(`printf '%s' '${escapedJson}' > ${inboxPath}/${taskFile}`);
const { writeFileSync, unlinkSync } = await import("fs");
const { execSync } = await import("child_process");
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
const execution = executions.create({

View file

@ -13,6 +13,7 @@
import { Router } from "express";
import { projects, tasks } from "../db.js";
import { generateProjectTitle, generateProjectGoal } from "../lib/llm.js";
const router = Router();
@ -23,13 +24,43 @@ router.get("/", (req, res) => {
});
// Create project
router.post("/", (req, res) => {
const { name, description, priority } = req.body;
if (!name) {
return res.status(400).json({ ok: false, error: "name is required" });
// Accepts { name?, description?, seed?, priority? }.
// If name is absent, generates a 3-7 word title from seedText via LLM (falls back to raw text).
// If description is absent, generates a one-line goal via LLM (falls back to "").
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

View file

@ -98,21 +98,27 @@ router.post("/:id/execute", async (req, res) => {
status: "pending",
};
// Write task JSON to sandbox's inbox via kubectl exec
const inboxPath = "/sandbox/.openclaw-data/workspace/tasks/inbox";
// Write task JSON to container's inbox via docker exec
const inboxPath = "/home/node/.openclaw/workspace/tasks/inbox";
const taskFile = `task_${id}.json`;
// Import execInSandbox dynamically to avoid circular deps
const { execInSandbox } = await import("../tiger.js");
try {
// Create directories if needed
await execInSandbox(`mkdir -p ${inboxPath}`);
// Write the task file
// Write task JSON via temp file (avoids ALL shell escaping issues)
const taskJson = JSON.stringify(taskData, null, 2);
const escapedJson = taskJson.replace(/'/g, "'\\''");
await execInSandbox(`printf '%s' '${escapedJson}' > ${inboxPath}/${taskFile}`);
const { writeFileSync: wfs, unlinkSync: uls } = await import("fs");
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
const execution = executions.create({

View file

@ -9,14 +9,13 @@ import { exec, execFile, spawn } from "child_process";
import { promisify } from "util";
import { readFile, writeFile } from "fs/promises";
import { createHash } from "crypto";
import path from "path";
const execAsync = promisify(exec);
// ─── Configuration ───────────────────────────────────────────────
// Tiger runs directly in the tiger-openclaw container
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
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";
@ -40,6 +39,37 @@ if (IS_REMOTE) {
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.
* Commands run directly via docker exec (no kubectl needed).
@ -329,12 +359,15 @@ export async function listWorkspaceFiles(
* Read a file from the Tiger workspace.
*/
export async function readWorkspaceFile(filepath: string): Promise<string> {
// Security: prevent path traversal
const sanitized = filepath.replace(/\.\./g, "").replace(/^\//, "");
const { stdout, exitCode } = await execOnHost(
`cat "${WORKSPACE_SYMLINK}/${sanitized}" 2>/dev/null`
);
if (exitCode !== 0) throw new Error(`File not found: ${sanitized}`);
// Security: resolve the path and ensure it stays within the workspace.
// Blocks path traversal attempts (e.g. ../../etc/passwd).
const safeName = filepath.replace(/\.\./g, "");
const fullPath = path.resolve(WORKSPACE_SYMLINK, safeName);
if (!fullPath.startsWith(WORKSPACE_SYMLINK)) {
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;
}

View file

@ -27,7 +27,7 @@ set -euo pipefail
# ─── Configuration ───────────────────────────────────────────────────
SERVER="root@100.75.128.45"
SERVER_PATH="/root/NemoClawDashboard"
SERVER_PATH="/root/OpenClawDashboard"
LOCAL_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# 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?
cd "$LOCAL_PATH"
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
ok "In repo: $LOCAL_PATH"