diff --git a/README.md b/README.md index e9612f8..75b1e0a 100644 --- a/README.md +++ b/README.md @@ -158,11 +158,12 @@ Configure the port in `.opencode/ensemble.json`: ```json { - "dashboardPort": 4747 + "dashboardPort": 4747, + "dashboardHost": "127.0.0.1" } ``` -Set to `0` to disable. The dashboard starts automatically when OpenCode loads the plugin. +Set `dashboardPort` to `0` to disable. The dashboard starts automatically when OpenCode loads the plugin. `dashboardHost` defaults to loopback; changing it can expose dashboard data to other machines on your network. ## Install @@ -393,6 +394,7 @@ Configure via JSON files, environment variables, or both. Project config overrid "timeoutMs": 1800000, "rateLimitCapacity": 10, "dashboardPort": 4747, + "dashboardHost": "127.0.0.1", "defaultModel": "anthropic/claude-sonnet-4-6", "modelPool": ["anthropic/claude-opus-4-7", "anthropic/claude-sonnet-4-6", "openai/gpt-5.4"], "modelsByAgent": {}, @@ -414,6 +416,7 @@ All fields are optional. Missing fields use defaults. | `timeoutMs` | `1800000` (30 min) | Hard timeout for busy teammates. `0` disables. | | `rateLimitCapacity` | `10` | Token bucket capacity for team tool calls. `0` disables. | | `dashboardPort` | `4747` | Dashboard server port. `0` disables. | +| `dashboardHost` | `127.0.0.1` | Dashboard network interface to bind. Keep the loopback default unless you intend to expose the dashboard on your network. | | `defaultModel` | `""` | Default model for all agents (e.g. `"anthropic/claude-sonnet-4-6"`). Empty = OpenCode's default. | | `modelPool` | `[]` | List of models for rotation/random assignment. | | `modelsByAgent` | `{}` | Map agent type to model (e.g. `{"build": "anthropic/claude-opus-4-7"}`). | diff --git a/src/config.ts b/src/config.ts index 5ef6a75..bce573a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -17,6 +17,8 @@ export interface EnsembleConfig { rateLimitCapacity?: number /** Dashboard server port (default: 4747, 0 to disable) */ dashboardPort?: number + /** Dashboard bind address (default: 127.0.0.1) */ + dashboardHost?: string /** Max peer messages per agent per window before nudge (default: 5, 0 to disable) */ peerMessageLimit?: number /** Time window for peer message rate limiting in ms (default: 300000 = 5 min) */ @@ -42,6 +44,7 @@ export const DEFAULT_CONFIG: Required = { timeoutMs: 30 * 60 * 1000, rateLimitCapacity: 10, dashboardPort: 4747, + dashboardHost: "127.0.0.1", peerMessageLimit: 5, peerMessageWindowMs: 300_000, defaultModel: "", @@ -65,6 +68,7 @@ function readConfigFile(filePath: string): Partial { if (typeof raw.timeoutMs === "number") result.timeoutMs = raw.timeoutMs if (typeof raw.rateLimitCapacity === "number") result.rateLimitCapacity = raw.rateLimitCapacity if (typeof raw.dashboardPort === "number") result.dashboardPort = raw.dashboardPort + if (typeof raw.dashboardHost === "string") result.dashboardHost = raw.dashboardHost if (typeof raw.peerMessageLimit === "number") result.peerMessageLimit = raw.peerMessageLimit if (typeof raw.peerMessageWindowMs === "number") result.peerMessageWindowMs = raw.peerMessageWindowMs if (typeof raw.defaultModel === "string") result.defaultModel = raw.defaultModel diff --git a/src/dashboard.ts b/src/dashboard.ts index 9cf507c..d39c544 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -207,6 +207,8 @@ export interface DashboardServer { /** Optional dependencies for the dashboard server. */ export interface DashboardOptions { + /** Network interface to bind (defaults to loopback). */ + host?: string /** In-memory activity buffer for real-time per-session events. */ activityBuffer?: ActivityBuffer /** SDK client for on-demand session message retrieval. */ @@ -216,7 +218,6 @@ export interface DashboardOptions { function sendJson(res: ServerResponse, data: unknown): void { res.writeHead(200, { "Content-Type": "application/json", - "Access-Control-Allow-Origin": "*", }) res.end(JSON.stringify(data)) } @@ -424,7 +425,7 @@ export async function startDashboard(db: Database, port: number, options?: Dashb resolve(null) }) - server.listen(port, () => { + server.listen(port, options?.host ?? "127.0.0.1", () => { log(`dashboard:started port=${port} url=http://localhost:${port}`) resolve(toDashboardServer(server)) }) diff --git a/src/index.ts b/src/index.ts index 0ab358f..0b413a1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -131,7 +131,7 @@ const plugin: Plugin = async (input) => { // Start dashboard server (main instance only, not worktree instances) if (config.dashboardPort !== 0) { - startDashboard(db, config.dashboardPort, { activityBuffer, client }).catch((err) => { + startDashboard(db, config.dashboardPort, { activityBuffer, client, host: config.dashboardHost }).catch((err) => { log(`init:dashboard:failed err=${err instanceof Error ? err.message : String(err)}`) }) } diff --git a/src/v2-setup.ts b/src/v2-setup.ts index 77285bd..b043cb4 100644 --- a/src/v2-setup.ts +++ b/src/v2-setup.ts @@ -343,7 +343,7 @@ export async function setupEnsemble( let dashboard: DashboardServer | null = null const dashboardPort = options.dashboardPort ?? config.dashboardPort if (dashboardPort !== 0 && !isWorktreeInstance(ctx.location.directory)) { - dashboard = await startDashboard(db, dashboardPort, { activityBuffer, client }).catch((err) => { + dashboard = await startDashboard(db, dashboardPort, { activityBuffer, client, host: config.dashboardHost }).catch((err) => { vlog(`init:dashboard:failed err=${err instanceof Error ? err.message : String(err)}`) return null }) diff --git a/test/config.test.ts b/test/config.test.ts index cffa266..4f6675e 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -63,6 +63,14 @@ describe("config", () => { expect(config.stallThresholdMs).toBe(300_000) // default preserved }) + test("defaults dashboardHost to loopback and loads configured host", () => { + expect(DEFAULT_CONFIG.dashboardHost).toBe("127.0.0.1") + const configDir = path.join(tmpDir, ".opencode") + mkdirSync(configDir, { recursive: true }) + writeFileSync(path.join(configDir, "ensemble.json"), JSON.stringify({ dashboardHost: "0.0.0.0" })) + expect(loadConfig(tmpDir).dashboardHost).toBe("0.0.0.0") + }) + test("invalid JSON logs warning and returns defaults", () => { const configDir = path.join(tmpDir, ".opencode") mkdirSync(configDir, { recursive: true }) diff --git a/test/dashboard.test.ts b/test/dashboard.test.ts index 955c358..825d353 100644 --- a/test/dashboard.test.ts +++ b/test/dashboard.test.ts @@ -49,11 +49,17 @@ describe("dashboard", () => { const res = await fetch(`http://localhost:${port}/api/health`) expect(res.status).toBe(200) expect(res.headers.get("content-type")).toContain("application/json") - expect(res.headers.get("access-control-allow-origin")).toBe("*") + expect(res.headers.get("access-control-allow-origin")).toBeNull() const body = (await res.json()) as HealthResponse expect(body.ensemble).toBe(true) expect(typeof body.pid).toBe("number") }) + + test("binds to the configured interface", async () => { + server = await startDashboard(db, port, { host: "127.0.0.2" }) + const response = await fetch(`http://127.0.0.2:${port}/api/health`) + expect(response.status).toBe(200) + }) }) describe("GET /api/state", () => { @@ -61,7 +67,7 @@ describe("dashboard", () => { server = await startDashboard(db, port) const res = await fetch(`http://localhost:${port}/api/state`) expect(res.status).toBe(200) - expect(res.headers.get("access-control-allow-origin")).toBe("*") + expect(res.headers.get("access-control-allow-origin")).toBeNull() const body = (await res.json()) as StateResponse expect(body).toEqual({ version: 1, projects: [], teams: [] }) })