Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,10 @@ OpenWolf is not an AI wrapper. It is 6 hook scripts and a `.wolf/` directory. It
- Optional: PM2 for persistent background tasks
- Optional: `puppeteer-core` for Design QC screenshots

## Dashboard network exposure

The dashboard server binds to `127.0.0.1` by default. Its HTTP and WebSocket endpoints are not authenticated, so loopback-only is the safe default — they hand out the contents of `.wolf/` and can trigger cron tasks (including `ai_task` actions that shell out to `claude -p`). If you actually need to reach the dashboard from another machine, set `openwolf.dashboard.bind` in `.wolf/config.json` to `"0.0.0.0"` (or a specific interface) and put it behind your own authenticated reverse proxy.

## Limitations

- Claude Code hooks are a relatively new feature. OpenWolf falls back to `CLAUDE.md` instructions when hooks don't fire.
Expand Down
45 changes: 40 additions & 5 deletions src/daemon/wolf-daemon.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import type { IncomingMessage } from "node:http";
import express from "express";
import { WebSocketServer, WebSocket } from "ws";
import { findProjectRoot } from "../scanner/project-root.js";
Expand All @@ -19,19 +20,24 @@ const wolfDir = path.join(projectRoot, ".wolf");
interface WolfConfig {
openwolf: {
daemon: { port: number; log_level: string };
dashboard: { enabled: boolean; port: number };
dashboard: { enabled: boolean; port: number; bind?: string };
cron: { enabled: boolean; heartbeat_interval_minutes: number };
};
}

const config = readJSON<WolfConfig>(path.join(wolfDir, "config.json"), {
openwolf: {
daemon: { port: 18790, log_level: "info" },
dashboard: { enabled: true, port: 18791 },
dashboard: { enabled: true, port: 18791, bind: "127.0.0.1" },
cron: { enabled: true, heartbeat_interval_minutes: 30 },
},
});

// Dashboard bind address. Defaults to loopback so the unauthenticated API
// and WebSocket endpoints are not exposed to the LAN. Set to "0.0.0.0" in
// .wolf/config.json only if you explicitly need network access.
const bind = config.openwolf.dashboard.bind ?? "127.0.0.1";

const logger = new Logger(
path.join(wolfDir, "daemon.log"),
config.openwolf.daemon.log_level as "debug" | "info" | "warn" | "error"
Expand Down Expand Up @@ -187,12 +193,41 @@ app.get("/{*path}", (_req, res) => {

// Start HTTP server
const port = config.openwolf.dashboard.port;
const server = app.listen(port, () => {
logger.info(`Dashboard server listening on port ${port}`);
const server = app.listen(port, bind, () => {
logger.info(`Dashboard server listening on ${bind}:${port}`);
if (bind !== "127.0.0.1" && bind !== "localhost" && bind !== "::1") {
logger.warn(
`Dashboard bound to ${bind} — HTTP and WebSocket endpoints are reachable from the network. ` +
`None of these endpoints require authentication.`
);
}
});

// Allow same-origin WebSocket connections (dashboard loaded from
// http://<bind>:<port>) and non-browser clients (no Origin header). Reject
// any other Origin to prevent a visited webpage from driving the daemon.
function isAllowedOrigin(origin: string | undefined): boolean {
if (!origin) return true; // non-browser clients don't send Origin
const allowed = new Set<string>([
`http://127.0.0.1:${port}`,
`http://localhost:${port}`,
`http://[::1]:${port}`,
]);
if (bind !== "127.0.0.1" && bind !== "localhost" && bind !== "::1") {
allowed.add(`http://${bind}:${port}`);
}
return allowed.has(origin);
}

// WebSocket server
const wss = new WebSocketServer({ server });
const wss = new WebSocketServer({
server,
verifyClient: (info: { origin: string; req: IncomingMessage; secure: boolean }) => {
if (isAllowedOrigin(info.origin || undefined)) return true;
logger.warn(`Rejected WebSocket upgrade: origin=${info.origin}`);
return false;
},
});

wss.on("connection", (ws) => {
wsClients.add(ws);
Expand Down
3 changes: 2 additions & 1 deletion src/templates/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@
},
"dashboard": {
"enabled": true,
"port": 18791
"port": 18791,
"bind": "127.0.0.1"
},
"designqc": {
"enabled": true,
Expand Down