Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
5ad5981
Port: port/pr-16-fix/daemon-bind-loopback (#2)
briansumma May 14, 2026
c080f05
Port: port/pr-17-extend-scanner-lang-support (#3)
briansumma May 14, 2026
296867e
Port: port/pr-27-fix/readjson-deep-merge (#6)
briansumma May 14, 2026
a0d99f0
Port: port/pr-32-add-managedby-tag (#8)
briansumma May 14, 2026
eab47c1
Port: port/pr-24-fix/crlf-parseAnatomy (#4)
briansumma May 14, 2026
cd381fb
Port: port/pr-26-fix/safe-config-access (#5)
briansumma May 15, 2026
7b272f1
Port: port/pr-34-main (#10)
briansumma May 15, 2026
634c399
Port: port/pr-33-fix/copyfile-efs-wsl2 (#9)
briansumma May 15, 2026
f55be98
Port: port/pr-38-fix/update-preserve-config-json (#12)
briansumma May 15, 2026
41d529f
Port: port/pr-40-feat/status-md-handoff (#13)
briansumma May 15, 2026
42a7c16
Add planning config.json
briansumma May 15, 2026
9899036
Fix PR #15 critical issues: error handling and security tests
briansumma May 15, 2026
cb1e0e9
fix: convert security.test.ts to vitest API
briansumma May 15, 2026
02fb9b8
fix: address all critical and important PR review issues
briansumma May 15, 2026
73e0cfd
fix: C5+C6 add error logging to readText and safeCopyFile catch blocks
briansumma May 15, 2026
0a63bed
fix: C4 replace fs.copyFileSync with safeCopyFile for config.json see…
briansumma May 15, 2026
b0f496f
fix: C3 call seedStatus when STATUS.md is first created during upgrade
briansumma May 15, 2026
d24a05b
fix: C2 strip auth token from URL before writing to dashboard.log
briansumma May 15, 2026
d1dee66
fix: C1+I2+I3+I4+I6+S4 harden WebSocket and API auth in wolf-daemon.ts
briansumma May 15, 2026
4d45f03
fix: pass auth token in WebSocket URL for server verifyClient check
briansumma May 15, 2026
f04bf44
fix: I7+S3 add error detail to killPid and daemonLogs catch blocks
briansumma May 15, 2026
29a1f2b
fix: I8+I9 differentiate ENOENT from real errors in readMarkdown and …
briansumma May 15, 2026
fee085c
fix: I11 gitignore REVIEW-FIX.md and FINAL-REVIEW-REPORT.md session a…
briansumma May 15, 2026
150baf0
fix: C7+I10 rewrite security.test.ts to import and exercise productio…
briansumma May 15, 2026
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
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,11 @@ reframe/
openwolf-icon.zip
openwolf-blueprint.md
openwolf-readme-prompt.md

# AI-generated session artifacts (not source)
directives.md
summary.md
REVIEW-FIX.md
FINAL-REVIEW-REPORT.md
*-REVIEW.md
*-REVIEW-FIX.md
3 changes: 3 additions & 0 deletions .planning/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"model_profile": "inherit"
}
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,10 @@ OpenWolf works transparently inside git worktrees (created via `git worktree add
- 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
514 changes: 345 additions & 169 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
allowBuilds:
esbuild: false
4 changes: 2 additions & 2 deletions src/cli/cron-cmd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,11 @@ export async function cronRun(id: string): Promise<void> {
}

// Read dashboard port from config
interface WolfConfig { openwolf: { dashboard: { port: number } } }
interface WolfConfig { openwolf?: { dashboard?: { port?: number } } }
const config = readJSON<WolfConfig>(path.join(wolfDir, "config.json"), {
openwolf: { dashboard: { port: 18791 } },
});
const port = config.openwolf.dashboard.port;
const port = config.openwolf?.dashboard?.port ?? 18791;

// Try calling the daemon's HTTP endpoint first
try {
Expand Down
103 changes: 80 additions & 23 deletions src/cli/daemon-cmd.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { execSync } from "node:child_process";
import { execFileSync } from "node:child_process";
import * as fs from "node:fs";
import * as net from "node:net";
import * as path from "node:path";
Expand All @@ -17,7 +17,7 @@ function getDashboardPort(): number {
path.join(wolfDir, "config.json"),
{ openwolf: { dashboard: { port: 18791 } } }
);
return config.openwolf.dashboard.port;
return config.openwolf?.dashboard?.port ?? 18791;
}

function getPm2Name(): string {
Expand All @@ -27,8 +27,8 @@ function getPm2Name(): string {

function hasPm2(): boolean {
try {
const cmd = isWindows() ? "where pm2" : "which pm2";
execSync(cmd, { stdio: "ignore" });
const cmd = isWindows() ? "where" : "which";
execFileSync(cmd, ["pm2"], { stdio: "ignore" });
return true;
} catch {
return false;
Expand All @@ -37,33 +37,54 @@ function hasPm2(): boolean {

function findPidOnPort(port: number): number | null {
try {
const portStr = String(port);
if (isWindows()) {
const output = execSync(`netstat -ano -p tcp`, { encoding: "utf-8" });
const output = execFileSync("netstat", ["-ano", "-p", "tcp"], { encoding: "utf-8" });
for (const line of output.split("\n")) {
if (line.includes(`:${port}`) && line.includes("LISTENING")) {
if (line.includes(`:${portStr}`) && line.includes("LISTENING")) {
const parts = line.trim().split(/\s+/);
const pid = parseInt(parts[parts.length - 1], 10);
if (pid > 0) return pid;
}
}
} else {
const output = execSync(`lsof -ti :${port}`, { encoding: "utf-8" });
const output = execFileSync("lsof", ["-ti", `:${portStr}`], { encoding: "utf-8" });
const pid = parseInt(output.trim(), 10);
if (pid > 0) return pid;
}
} catch {}
} catch (err) {
// ENOENT = lsof/netstat not installed on this system — expected on some
// minimal environments. Other codes (EACCES, etc.) indicate a real problem.
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
process.stderr.write(
`[openwolf] findPidOnPort(${port}): ${err instanceof Error ? err.message : String(err)}\n`
);
}
}
return null;
}

function killPid(pid: number): boolean {
try {
if (isWindows()) {
execSync(`taskkill /PID ${pid} /F`, { stdio: "ignore" });
execFileSync("taskkill", ["/PID", String(pid), "/F"], { stdio: "ignore" });
} else {
process.kill(pid, "SIGTERM");
}
return true;
} catch {
} catch (err) {
// EPERM: caller lacks privilege to signal this process — tell the user
// explicitly so they know elevated privileges are needed.
const code = (err as NodeJS.ErrnoException).code;
if (code === "EPERM") {
process.stderr.write(
`[openwolf] killPid(${pid}): permission denied — try running with elevated privileges\n`
);
} else {
process.stderr.write(
`[openwolf] killPid(${pid}): ${err instanceof Error ? err.message : String(err)}\n`
);
}
return false;
}
}
Expand All @@ -86,17 +107,28 @@ export function daemonStart(): void {
const daemonScript = path.resolve(__dirname, "..", "daemon", "wolf-daemon.js");

try {
execSync(`pm2 start "${daemonScript}" --name ${name} --cwd "${projectRoot}" -- --env OPENWOLF_PROJECT_ROOT="${projectRoot}"`, {
const pm2Cmd = isWindows() ? "pm2.cmd" : "pm2";
execFileSync(pm2Cmd, [
"start",
daemonScript,
"--name",
name,
"--cwd",
projectRoot,
"--",
"--env",
`OPENWOLF_PROJECT_ROOT=${projectRoot}`
], {
stdio: "inherit",
env: { ...process.env, OPENWOLF_PROJECT_ROOT: projectRoot },
});
execSync("pm2 save", { stdio: "ignore" });
execFileSync(pm2Cmd, ["save"], { stdio: "ignore" });
console.log(`\n ✓ Daemon started: ${name}`);
if (isWindows()) {
console.log(" Tip: Run 'pm2-windows-startup' for boot persistence.");
}
} catch {
console.error("Failed to start daemon.");
} catch (err) {
console.error(` Failed to start daemon: ${err instanceof Error ? err.message : String(err)}`);
}
}

Expand All @@ -113,11 +145,23 @@ export function daemonStop(): void {
if (hasPm2()) {
const name = getPm2Name();
try {
execSync(`pm2 stop ${name}`, { stdio: "ignore" });
const pm2Cmd = isWindows() ? "pm2.cmd" : "pm2";
execFileSync(pm2Cmd, ["stop", name], { stdio: "ignore" });
console.log(` ✓ Daemon stopped (PM2): ${name}`);

const tokenPath = path.join(wolfDir, "daemon-token.tmp");
if (fs.existsSync(tokenPath)) fs.unlinkSync(tokenPath);
return;
} catch {
// PM2 process not found — fall through to port-based stop
} catch (err) {
// PM2 reports "not found" when the named process doesn't exist — expected
// on first stop or after a crash. Warn on other failures (permission, etc.).
const msg = err instanceof Error ? err.message : String(err);
const isNotFound = msg.toLowerCase().includes("not found") ||
msg.toLowerCase().includes("no such process");
if (!isNotFound) {
console.warn(` PM2 stop warning: ${msg}`);
}
// Fall through to port-based stop
}
}

Expand All @@ -127,6 +171,9 @@ export function daemonStop(): void {
if (pid) {
if (killPid(pid)) {
console.log(` ✓ Daemon stopped (PID ${pid} on port ${port})`);
// Clean up token
const tokenPath = path.join(wolfDir, "daemon-token.tmp");
if (fs.existsSync(tokenPath)) fs.unlinkSync(tokenPath);
} else {
console.error(` Failed to kill process ${pid} on port ${port}.`);
}
Expand All @@ -148,11 +195,20 @@ export function daemonRestart(): void {
if (hasPm2()) {
const name = getPm2Name();
try {
execSync(`pm2 restart ${name}`, { stdio: "ignore" });
const pm2Cmd = isWindows() ? "pm2.cmd" : "pm2";
execFileSync(pm2Cmd, ["restart", name], { stdio: "ignore" });
console.log(` ✓ Daemon restarted (PM2): ${name}`);
return;
} catch {
// PM2 process not found — fall through
} catch (err) {
// PM2 reports "not found" when the named process doesn't exist — expected
// before the daemon has been started with PM2. Warn on other failures.
const msg = err instanceof Error ? err.message : String(err);
const isNotFound = msg.toLowerCase().includes("not found") ||
msg.toLowerCase().includes("no such process");
if (!isNotFound) {
console.warn(` PM2 restart warning: ${msg}`);
}
// Fall through to port-based restart
}
}

Expand Down Expand Up @@ -182,8 +238,9 @@ export function daemonLogs(): void {

const name = getPm2Name();
try {
execSync(`pm2 logs ${name} --lines 50 --nostream`, { stdio: "inherit" });
} catch {
console.error("Failed to get daemon logs.");
const pm2Cmd = isWindows() ? "pm2.cmd" : "pm2";
execFileSync(pm2Cmd, ["logs", name, "--lines", "50", "--nostream"], { stdio: "inherit" });
} catch (err) {
console.error(`Failed to get daemon logs: ${err instanceof Error ? err.message : String(err)}`);
}
}
53 changes: 46 additions & 7 deletions src/cli/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@ import { fileURLToPath } from "node:url";
import { fork } from "node:child_process";
import { findProjectRoot } from "../scanner/project-root.js";
import { readJSON } from "../utils/fs-safe.js";
import { Logger } from "../utils/logger.js";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

interface WolfConfig {
openwolf: {
dashboard: { port: number };
openwolf?: {
dashboard?: { port?: number };
};
}

Expand Down Expand Up @@ -44,12 +45,14 @@ export async function dashboardCommand(): Promise<void> {
return;
}

const logger = new Logger(path.join(wolfDir, "dashboard.log"), "info");

const config = readJSON<WolfConfig>(path.join(wolfDir, "config.json"), {
openwolf: { dashboard: { port: 18791 } },
});

const port = config.openwolf.dashboard.port;
const url = `http://localhost:${port}`;
const port = config.openwolf?.dashboard?.port ?? 18791;
let url = `http://localhost:${port}`;

// Check if daemon is already running on that port
const running = await isPortOpen(port);
Expand All @@ -72,6 +75,14 @@ export async function dashboardCommand(): Promise<void> {
detached: true,
stdio: "ignore",
});
child.on("error", (err) => {
console.error(` Daemon process error: ${err.message}`);
});
child.on("exit", (code) => {
if (code !== null && code !== 0) {
console.error(` Daemon exited unexpectedly with code ${code}. Check .wolf/daemon.log for details.`);
}
});
child.unref();

// Wait for the port to open (up to 5 seconds)
Expand All @@ -92,12 +103,40 @@ export async function dashboardCommand(): Promise<void> {
console.log(` ✓ Dashboard server running on port ${port}`);
}

console.log(` Opening ${url}...`);
// Append auth token to URL for initial page load bootstrap.
// The dashboard JS reads the token from the URL param on first load,
// stores it in sessionStorage, and immediately strips it from the URL
// via history.replaceState — so it does not appear in browser history
// entries or outbound Referer headers. Subsequent API calls send the
// token via the X-Api-Token header rather than the URL.
const tokenPath = path.join(wolfDir, "daemon-token.tmp");
if (fs.existsSync(tokenPath)) {
const token = fs.readFileSync(tokenPath, "utf-8").trim();
url += `?token=${token}`;
}

console.log(` Opening http://localhost:${port}...`);

try {
const { default: open } = await import("open");
await open(url);
} catch {
console.log(` Could not open browser. Visit: ${url}`);
} catch (error) {
const errorMessage = error instanceof Error
? error.message
: 'Unknown error';

// Strip the token query param before logging — the URL includes ?token=<hex>
// and writing it to dashboard.log would expose the live auth token to
// anyone who can read the log file.
const safeUrl = url.includes("?") ? url.slice(0, url.indexOf("?")) : url;
logger.error(`Failed to open browser at ${safeUrl}. Error: ${errorMessage}. Hint: Try opening the URL manually in your browser`);

// User-friendly message
console.log(`
🚨 Could not open browser automatically`);
console.log(`URL: ${url}`);
console.log(`Error: ${errorMessage}`);
console.log(`You can manually open this URL in your browser.
`);
}
}
Loading