Compare commits

...

4 Commits

Author SHA1 Message Date
Mortdecai 47c5a8b0b9 docs: session handoff — traversal fix, write-back, MCP registration
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:35:59 -04:00
Mortdecai a6766e807f docs: document page write-back via POST /log
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:32:37 -04:00
Mortdecai 05fcb7f2f2 feat: POST /log from the page + load state.json on page open
- Browser can record entries (e.g. meter readings) into session.md/jsonl;
  broadcast to other open clients. Log feed renders as text, not HTML.
- Page loads state.json on open instead of only localStorage, so a new
  device (or one that missed the WS push) shows current content.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:28:36 -04:00
Mortdecai 65622501fe fix: block path traversal in static handler
The LAN-bound HTTP server joined the raw URL path onto the project dir,
so /../../.ssh/id_ed25519 (and %2e%2e variants) served any file the
user could read. Resolve and require the path stay inside the project.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:28:36 -04:00
5 changed files with 198 additions and 9 deletions
@@ -0,0 +1,128 @@
# Handoff: workbench-server — path traversal fixed, page write-back added, MCP registered
## Session Metadata
- Created: 2026-09-28 16:34:42
- Project: /home/claude/bin/workbench-server
- Branch: master (pushed to git.sethpc.xyz/Seth/workbench-server)
- Session duration: ~1.5 h (shared with IO-102 work)
### Recent Commits (for context)
- a6766e8 docs: document page write-back via POST /log
- 05fcb7f feat: POST /log from the page + load state.json on page open
- 6562250 fix: block path traversal in static handler
- 4b55135 docs: add open source subheading
- c97adfc docs: README and INSTALL.md — AI-guided setup with SSH ControlMaster
## Handoff Chain
- **Continues from**: None (first handoff; earlier history lives in `~/bin/claude_analog/SESSION.md`, "Workbench MCP Server" section)
- **Supersedes**: None
## Current State Summary
The workbench MCP server was already built but had never been used live. This session gave it a debug run: tests passed and scaffold/HTTP/WS/log/stop worked, but the MCP never loaded because it was only listed under `mcpServers` in `~/.claude/settings.json`, which Claude Code ignores. It is now registered at user scope (`claude mcp add --scope user workbench -- /home/claude/.local/bin/workbench mcp`) and shows ✔ Connected. Along the way I found and fixed a LAN-exposed path traversal (any file readable by `claude` was servable). I also added `POST /log` so pages can record data, and made the scaffold load `state.json` when a page opens. 18/18 tests pass. First real project: `~/workbench/io102` (see `~/bin/claude_analog/.claude/handoffs/`).
## Codebase Understanding
### Architecture Overview
- `src/workbench/server.py`: `WorkbenchServer` (testable core) + `create_mcp_server` (FastMCP wrapper, 6 tools). Each project gets its own aiohttp app on a port from 8070 up: `/ws`, `POST /log`, static files under the project dir.
- `src/workbench/project.py`: on-disk layout under `~/workbench/<name>/`: `index.html` (from scaffold), `state.json`, `session.md`/`.jsonl`, `cost-log.jsonl`, `.server.json` (pid/port).
- `src/workbench/scaffold.html`: WS client. State keys are `template` (HTML), `styles`, `script` (run via `new Function`). On load: fetch `state.json`, fall back to localStorage.
- Editable pip install (`~/.local/bin/workbench` → this source tree), so source edits apply on the next MCP start.
### Critical Files
| File | Purpose | Relevance |
|------|---------|-----------|
| `src/workbench/server.py` | HTTP/WS/MCP | Static handler now resolves paths and requires `is_relative_to(root)` |
| `src/workbench/scaffold.html` | Browser client | Log feed renders as text (browsers can now POST entries) |
| `tests/test_server.py` | Includes live-server tests for traversal and POST /log | |
### Key Patterns Discovered
- Tests use a `tmp_workbench` fixture; live-server tests call `server._start_http_server` directly and use aiohttp's ClientSession.
## Work Completed
### Tasks Finished
- [x] Debug run: pytest, a live smoke test, and an MCP stdio handshake (all 6 tools listed)
- [x] Registered the MCP at user scope (`~/.claude.json` backed up to `~/.claude/.backup-claude.json.*` / `~/.claude/.backup/`)
- [x] Fixed path traversal (commit 6562250), with a test
- [x] Added POST /log + load-from-state.json + text-only log feed (05fcb7f), with a test
- [x] README note (a6766e8)
### Files Modified
| File | Changes | Rationale |
|------|---------|-----------|
| `src/workbench/server.py` | traversal guard; `log_handler` | security; page write-back |
| `src/workbench/scaffold.html` | fetch state.json on load; text-only log entries | new devices see content; no script injection via /log |
| `tests/test_server.py` | 2 tests | |
| `README.md` | write-back paragraph | |
Backups: `src/workbench/.backup/`, `tests/.backup/`, `.backup/README.md.*`
### Decisions Made
| Decision | Options Considered | Rationale |
|----------|-------------------|-----------|
| Browser write-back via `POST /log` reusing `append_log` | A separate readings store | One log for humans and the AI; `workbench_read_log` already covers resume |
| Server for io102 was run as a session-scoped background task, not detached | nohup/setsid daemon | A detached server would block live pushes from a later MCP session (see gotcha) |
## Pending Work
### Immediate Next Steps
1. Nothing blocking. Next real use is the IO-102 readings session (driven from `~/bin/claude_analog`).
2. Optional: fix reattach so live pushes work across processes (see gotcha). Simplest option: when `workbench_scaffold` finds a live server owned by another pid, have `workbench_state` POST the state to that server (e.g. a new `POST /state` endpoint) instead of broadcasting to an empty local client set.
### Blockers/Open Questions
- [ ] `rival-llms` MCP is also only in `settings.json`'s `mcpServers`, so it doesn't load either. Not touched; ask Seth whether he wants it.
### Deferred Items
- The FastMCP `serverInfo` version reports 1.26.0 (the SDK's version), not 0.2.0. Cosmetic.
- `workbench serve <name>` passes the name as the title, which overwrites index.html's title. Cosmetic.
- No favicon (a 404 in the browser console). Cosmetic.
## Context for Resuming Agent
### Important Context
- The README claim "reattach… browser stays connected" is only half true. The reattaching process has an empty ws_clients set, so `workbench_state` writes state.json but browsers see it only after a refresh.
- `POST /log` has no auth. It's LAN-only, like the rest of the server. Anyone on the LAN can append log lines, but can't read files outside the project or inject script into the feed.
### Assumptions Made
- LAN trust model (no auth) is acceptable for Seth's homelab.
### Potential Gotchas
- Playwright's bundled browsers are not installed; use `chromium.launch(executable_path="/usr/bin/chromium")`.
- MCP tools only appear in Claude Code sessions started after registration.
## Environment State
### Tools/Services Used
- `workbench` MCP (user scope), `gitea push`
### Active Processes
- None persistent. The io102 server from 2026-09-28 was session-scoped.
### Environment Variables
- None
## Related Resources
- `~/bin/claude_analog/.claude/handoffs/2026-09-28-163442-io102-focus-ready-for-readings.md`
- `docs/superpowers/plans/2026-03-30-workbench-v2.md`
---
**Security Reminder**: Before finalizing, run `validate_handoff.py` to check for accidental secret exposure.
+2
View File
@@ -54,6 +54,8 @@ Workbench is an [MCP server](https://modelcontextprotocol.io). Once configured,
The AI generates all content — HTML, CSS, JavaScript. Workbench is just the plumbing. The AI generates all content — HTML, CSS, JavaScript. Workbench is just the plumbing.
Pages can write back too: `fetch('log', {method: 'POST', body: JSON.stringify({entry, data})})` appends to the session log (e.g. meter readings typed on a phone), and `session.jsonl` / `state.json` are readable from the page, so any device opens to the same state.
### Server Persistence ### Server Persistence
Workbench servers survive AI CLI restarts. If you restart your AI and it calls `workbench_scaffold` again, it detects the existing running server and reattaches — no duplicates, no lost connections. The browser stays connected throughout. Workbench servers survive AI CLI restarts. If you restart your AI and it calls `workbench_scaffold` again, it detects the existing running server and reattaches — no duplicates, no lost connections. The browser stays connected throughout.
+15 -7
View File
@@ -114,18 +114,26 @@ function handleLog(entry) {
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'log-entry'; div.className = 'log-entry';
const time = new Date().toLocaleTimeString(); const time = new Date().toLocaleTimeString();
div.innerHTML = `<span class="log-time">${time}</span>${entry}`; const t = document.createElement('span');
t.className = 'log-time';
t.textContent = time;
div.append(t, entry); // text, not HTML — browsers can POST entries now
const feed = document.getElementById('log-entries'); const feed = document.getElementById('log-entries');
feed.appendChild(div); feed.appendChild(div);
feed.scrollTop = feed.scrollHeight; feed.scrollTop = feed.scrollHeight;
} }
try { // state.json on disk is the source of truth; localStorage only covers the server being unreachable
const saved = JSON.parse(localStorage.getItem('workbench-state')); fetch('state.json', {cache: 'no-store'})
if (saved) handleState(saved); .then(r => r.json())
} catch(e) {} .then(s => { if (Object.keys(s).length) handleState(s); else throw 0; })
.catch(() => {
connect(); try {
const saved = JSON.parse(localStorage.getItem('workbench-state'));
if (saved) handleState(saved);
} catch(e) {}
})
.finally(connect);
</script> </script>
</body> </body>
</html> </html>
+16 -2
View File
@@ -102,12 +102,26 @@ class WorkbenchServer:
async def static_handler(request): async def static_handler(request):
proj = request.app["project_name"] proj = request.app["project_name"]
path = request.match_info.get("path", "index.html") or "index.html" path = request.match_info.get("path", "index.html") or "index.html"
file_path = project_path(proj, self.workbench_dir) / path root = project_path(proj, self.workbench_dir).resolve()
if not file_path.exists(): file_path = (root / path).resolve()
# served on the LAN — never let "../" escape the project dir
if not file_path.is_relative_to(root) or not file_path.is_file():
return web.Response(status=404, text="Not found") return web.Response(status=404, text="Not found")
return web.FileResponse(file_path) return web.FileResponse(file_path)
async def log_handler(request):
# lets the page record readings, so they outlive the browser
body = await request.json()
entry = str(body.get("entry", "")).strip()
if not entry:
return web.json_response({"error": "entry required"}, status=400)
data = body.get("data") if isinstance(body.get("data"), dict) else None
append_log(request.app["project_name"], entry, data=data, workbench_dir=self.workbench_dir)
await self._broadcast_ws(request.app["project_name"], {"type": "log", "entry": entry})
return web.json_response({"ok": True})
app.router.add_get("/ws", ws_handler) app.router.add_get("/ws", ws_handler)
app.router.add_post("/log", log_handler)
app.router.add_get("/{path:.*}", static_handler) app.router.add_get("/{path:.*}", static_handler)
app.router.add_get("/", static_handler) app.router.add_get("/", static_handler)
+37
View File
@@ -103,3 +103,40 @@ async def test_reconnect_on_startup(tmp_workbench):
assert srv._active["live-proj"]["port"] == 8070 assert srv._active["live-proj"]["port"] == 8070
assert "dead-proj" not in srv._active assert "dead-proj" not in srv._active
assert not (tmp_workbench / "dead-proj" / ".server.json").exists() assert not (tmp_workbench / "dead-proj" / ".server.json").exists()
async def _live(server, name="p"):
import aiohttp
from workbench.project import create_project
create_project(name, "T", workbench_dir=server.workbench_dir)
port = await server._start_http_server(name)
return aiohttp.ClientSession(), f"http://127.0.0.1:{port}"
@pytest.mark.asyncio
async def test_static_blocks_path_traversal(server, tmp_workbench):
(tmp_workbench / "secret.txt").write_text("nope")
session, base = await _live(server)
async with session:
for evil in ["/../secret.txt", "/%2e%2e/secret.txt", "/..%2fsecret.txt"]:
async with session.get(base + evil, allow_redirects=False) as r:
assert r.status == 404, evil
async with session.get(base + "/") as r:
assert r.status == 200
await server.workbench_stop("p")
@pytest.mark.asyncio
async def test_browser_post_log_persists_and_broadcasts(server, tmp_workbench):
from workbench.project import read_log
session, base = await _live(server)
async with session:
ws = await session.ws_connect(base + "/ws")
async with session.post(base + "/log", json={"entry": "R303 3.31M", "data": {"part": "R303"}}) as r:
assert r.status == 200
msg = await asyncio.wait_for(ws.receive_json(), 3)
assert msg == {"type": "log", "entry": "R303 3.31M"}
await ws.close()
entries = read_log("p", workbench_dir=tmp_workbench)
assert entries[-1]["entry"] == "R303 3.31M" and entries[-1]["part"] == "R303"
await server.workbench_stop("p")