Skip to content
Merged
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
24 changes: 23 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.4.1] - 2026-10-16

Hardening-only patch. No new features or dependency changes.

### Security

- **Strict CSP on bridge HTML.** Dashboard shell responses send `Content-Security-Policy` with
`script-src 'self'` and `style-src 'self' 'unsafe-inline'` so inline styles from JS/SVG still work.
- **Electron navigation lockdown.** `setWindowOpenHandler` denies popups; `will-navigate` allows only
the app’s loopback bridge URL.
- **Port 3847 identity.** `/api/status` exposes `bridgeId`; Electron verifies it before loading when
reusing an existing listener, and the bridge refuses foreign `ok` responses without the id.
- **Atomic event-log replace.** Prune and clear write a temp file in the log directory and rename over
the JSONL instead of truncating in place.
- **Status class escaping.** `formatStatusHtml` escapes wrapper class names via `SafeHtml`.

### Changed

- **CSV TOTAL label.** The TOTAL row notes that explorer and researcher lines are excluded because their
tokens are already counted in Haiku (numbers unchanged).

## [1.4.0] - 2026-10-16

Correctness pass on the Live path. The bridge was reading a transcript shape Cursor does not
Expand Down Expand Up @@ -262,7 +283,8 @@ audio fix; see Fixed.

- Token and session cost figures are illustrative estimates, not official Cursor billing.

[Unreleased]: https://github.com/thymeed/agent-workshop/compare/v1.4.0...HEAD
[Unreleased]: https://github.com/thymeed/agent-workshop/compare/v1.4.1...HEAD
[1.4.1]: https://github.com/thymeed/agent-workshop/compare/v1.4.0...v1.4.1
[1.4.0]: https://github.com/thymeed/agent-workshop/compare/v1.3.1...v1.4.0
[1.3.1]: https://github.com/thymeed/agent-workshop/compare/v1.3.0...v1.3.1
[1.3.0]: https://github.com/thymeed/agent-workshop/compare/v1.2.0...v1.3.0
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

[![CI](https://github.com/thymeed/agent-workshop/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/thymeed/agent-workshop/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![Version](https://img.shields.io/badge/version-1.4.0-informational)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.4.1-informational)](CHANGELOG.md)

Desktop dashboard that shows how a Cursor Agents session routes work across Opus (parent), Fable (advisor), Worker (Sonnet), and Explorer / Researcher (Haiku).

Expand Down
2 changes: 1 addition & 1 deletion dashboard/bill-math.js
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@
});
lines.push(
[
"TOTAL",
"TOTAL (excl. explorer/researcher; in Haiku)",
summary.totals.inputTokens,
summary.totals.outputTokens,
summary.totals.estimatedCostUsd
Expand Down
2 changes: 1 addition & 1 deletion dashboard/cards.js
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@
}
var plain = prettyStatusText(inner.replace(/<[^>]+>/g, " "));
var safe = escapeHtmlText(plain);
if (cls) return '<span class="' + cls + '">' + safe + "</span>";
if (cls) return '<span class="' + escapeHtmlText(cls) + '">' + safe + "</span>";
return safe;
}

Expand Down
72 changes: 70 additions & 2 deletions electron/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@
*/
"use strict";

const { app, BrowserWindow, Notification } = require("electron");
const http = require("http");
const { app, BrowserWindow, Notification, dialog } = require("electron");
const path = require("path");
const { startBridge, stopBridge } = require(path.join(__dirname, "..", "live-bridge.js"));
const { startBridge, stopBridge, BRIDGE_ID } = require(
path.join(__dirname, "..", "live-bridge.js")
);

const PORT = Number(process.env.CURSOR_BRIDGE_PORT || 3847);
const WINDOW_TITLE = "Cursor Agents · Model Routing";
Expand Down Expand Up @@ -50,6 +53,51 @@ function showAgentAlert(alert) {
}
}

function isAllowedNavigationUrl(urlString, port) {
try {
const u = new URL(urlString);
if (u.protocol !== "http:") return false;
const host = u.hostname.replace(/^\[|\]$/g, "");
if (host !== "127.0.0.1" && host !== "localhost" && host !== "::1") return false;
const urlPort = u.port || "80";
return urlPort === String(port);
} catch (_e) {
return false;
}
}

function probeBridgeIdentity(port) {
return new Promise((resolve) => {
const req = http.get(
{
host: "127.0.0.1",
port,
path: "/api/status",
timeout: 2000
},
(res) => {
let body = "";
res.on("data", (c) => {
body += c;
});
res.on("end", () => {
try {
const j = JSON.parse(body);
resolve(j && j.ok === true && j.bridgeId === BRIDGE_ID ? j : null);
} catch (_e) {
resolve(null);
}
});
}
);
req.on("timeout", () => {
req.destroy();
resolve(null);
});
req.on("error", () => resolve(null));
});
}

async function bootBridge() {
// Start the bridge in-process: quiet, no console chatter, no spawned cmd/node.
const result = await startBridge({
Expand All @@ -64,6 +112,18 @@ async function bootBridge() {

async function createWindow() {
const bridge = await bootBridge();
const identity = await probeBridgeIdentity(bridge.port);
if (!identity) {
dialog.showErrorBox(
WINDOW_TITLE,
`Could not verify the model-routing bridge on port ${bridge.port}. ` +
"Another app may be using that port, or the bridge failed to start. " +
"Close other listeners on this port and restart the app."
);
await shutdown();
app.quit();
return;
}

mainWindow = new BrowserWindow({
width: 1180,
Expand All @@ -82,6 +142,14 @@ async function createWindow() {
});

mainWindow.setMenuBarVisibility(false);

mainWindow.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
mainWindow.webContents.on("will-navigate", (event, url) => {
if (!isAllowedNavigationUrl(url, bridge.port)) {
event.preventDefault();
}
});

mainWindow.on("page-title-updated", (e) => {
e.preventDefault();
mainWindow.setTitle(WINDOW_TITLE);
Expand Down
16 changes: 15 additions & 1 deletion lib/bridge-http.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ const path = require("path");
const DASHBOARD = path.join(__dirname, "..", "model-routing-dashboard.html");
const STATIC_ROOT = path.join(__dirname, "..", "dashboard");

/** Tight CSP for the dashboard shell (external scripts; inline styles from JS/SVG). */
const DASHBOARD_CSP =
"default-src 'self'; " +
"script-src 'self'; " +
"style-src 'self' 'unsafe-inline'; " +
"connect-src 'self'; " +
"img-src 'self' data:; " +
"font-src 'self'; " +
"object-src 'none'; " +
"base-uri 'self'; " +
"frame-ancestors 'none'";

const MIME = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
Expand Down Expand Up @@ -140,7 +152,8 @@ function serveDashboard(res) {
}
res.writeHead(200, {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-store"
"Cache-Control": "no-store",
"Content-Security-Policy": DASHBOARD_CSP
});
res.end(data);
});
Expand Down Expand Up @@ -198,6 +211,7 @@ function serveStatic(urlPath, res) {
module.exports = {
DASHBOARD,
STATIC_ROOT,
DASHBOARD_CSP,
MIME,
sendJson,
readRequestBody,
Expand Down
39 changes: 31 additions & 8 deletions lib/event-log.js
Original file line number Diff line number Diff line change
Expand Up @@ -204,20 +204,43 @@ function secureLogFile(filePath) {
}
}

function replaceLogFile(filePath, data) {
ensureParentDir(filePath);
const dir = path.dirname(filePath);
const tmpPath = path.join(
dir,
"." + path.basename(filePath) + "." + process.pid + "." + Date.now() + ".tmp"
);
try {
if (usePosixLogPermissions()) {
fs.writeFileSync(tmpPath, data, { encoding: "utf8", mode: LOG_FILE_MODE });
} else {
fs.writeFileSync(tmpPath, data, "utf8");
}
fs.renameSync(tmpPath, filePath);
secureLogFile(filePath);
} catch (e) {
try {
if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
} catch (_unlink) {
/* ignore */
}
throw e;
}
}

function writeLogBytes(filePath, data, append) {
ensureParentDir(filePath);
if (usePosixLogPermissions()) {
if (append) {
if (append) {
if (usePosixLogPermissions()) {
fs.appendFileSync(filePath, data, { encoding: "utf8", mode: LOG_FILE_MODE });
secureLogFile(filePath);
} else {
fs.writeFileSync(filePath, data, { encoding: "utf8", mode: LOG_FILE_MODE });
fs.appendFileSync(filePath, data, "utf8");
}
secureLogFile(filePath);
} else if (append) {
fs.appendFileSync(filePath, data, "utf8");
} else {
fs.writeFileSync(filePath, data, "utf8");
return;
}
replaceLogFile(filePath, data);
}

/**
Expand Down
6 changes: 5 additions & 1 deletion live-bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ let currentIngestFile = "";
let currentIngestLine = 0;

let PORT = 3847;
/** Identity token for /api/status; Electron refuses foreign listeners on the bridge port. */
const BRIDGE_ID = "cursor-agents-model-routing-v1";
/** @type {string} */
let TRANSCRIPTS = emptyTranscripts();
/** @type {string|null} */
Expand Down Expand Up @@ -597,6 +599,7 @@ function selectProject(id) {
function statusPayload() {
return {
ok: true,
bridgeId: BRIDGE_ID,
port: PORT,
transcripts: TRANSCRIPTS,
projectId: currentProjectId,
Expand Down Expand Up @@ -1554,7 +1557,7 @@ function probeExistingBridge(port) {
res.on("end", () => {
try {
const j = JSON.parse(body);
resolve(j && j.ok === true ? j : null);
resolve(j && j.ok === true && j.bridgeId === BRIDGE_ID ? j : null);
} catch (_) {
resolve(null);
}
Expand Down Expand Up @@ -1781,6 +1784,7 @@ function clearHistory() {
}

module.exports = {
BRIDGE_ID,
startBridge,
stopBridge,
discoverProjects,
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "agent-workshop",
"version": "1.4.0",
"version": "1.4.1",
"description": "Cursor Agents · Model Routing. Electron desktop dashboard with in-process live bridge",
"main": "electron/main.js",
"author": {
Expand Down
13 changes: 13 additions & 0 deletions test/bridge-guards.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,19 @@ async function testStaticAndCors(port) {
const home = await request(port, { path: "/" });
assert.strictEqual(home.status, 200, `GET /: expected 200, got ${home.status}`);
assert.ok(/text\/html/.test(home.headers["content-type"]), "GET / must be text/html");
const csp = home.headers["content-security-policy"] || "";
assert.ok(
csp.includes("script-src 'self'"),
"GET / must send a strict CSP with script-src 'self'"
);
assert.ok(
csp.includes("style-src 'self' 'unsafe-inline'"),
"GET / CSP must allow inline styles for the dashboard"
);

const status = await request(port, { path: "/api/status" });
const statusJson = JSON.parse(status.body);
assert.ok(statusJson.bridgeId, "status must include bridgeId for port identity checks");

const css = await request(port, { path: "/dashboard/styles.css" });
assert.strictEqual(css.status, 200, `GET stylesheet: expected 200, got ${css.status}`);
Expand Down
2 changes: 1 addition & 1 deletion test/dashboard-units.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ async function main() {
"CSV header must be stable"
);
assert.ok(
csvLines.some((l) => l.startsWith("TOTAL,")),
csvLines.some((l) => l.startsWith("TOTAL (excl. explorer/researcher; in Haiku),")),
"CSV must carry a TOTAL row"
);
assert.strictEqual(csvLines[1].split(",").length, 4, "CSV data rows keep four columns");
Expand Down
30 changes: 30 additions & 0 deletions test/event-log.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,35 @@ function testContractShape() {
if (errors.length) throw new Error("contract failed:\n- " + errors.join("\n- "));
}

function testReplacePreservesLogOnWriteFailure() {
const dir = tmpDir("atomic-fail");
const log = createEventLog({ logDir: dir });
log.append(sampleEvent({ agentId: "keep-me" }));
const p = log.getLogPath();
const before = fs.readFileSync(p, "utf8");
assert(before.includes("keep-me"), "seed event present");

const origWrite = fs.writeFileSync;
fs.writeFileSync = function (file, ...args) {
if (String(file).includes(".tmp")) {
throw new Error("simulated atomic write failure");
}
return origWrite.call(fs, file, ...args);
};
try {
let threw = false;
try {
log.clear();
} catch (_e) {
threw = true;
}
assert(threw, "clear must fail when the temp write fails");
assert(fs.readFileSync(p, "utf8") === before, "original log must survive a failed replace");
} finally {
fs.writeFileSync = origWrite;
}
}

function testRetentionAndClear() {
const dir = tmpDir("retain");
const log = createEventLog({ logDir: dir, retentionMs: 1000 });
Expand Down Expand Up @@ -474,6 +503,7 @@ async function main() {
testRedactionSignedUrls();
testPemFullBlockRedaction();
testContractShape();
testReplacePreservesLogOnWriteFailure();
testRetentionAndClear();
testExistingLogDirChmodOnStart();
testFilePermissions();
Expand Down
Loading
Loading