Compare commits
4 Commits
4b551351e1
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 47c5a8b0b9 | |||
| a6766e807f | |||
| 05fcb7f2f2 | |||
| 65622501fe |
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
@@ -114,18 +114,26 @@ function handleLog(entry) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'log-entry';
|
||||
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');
|
||||
feed.appendChild(div);
|
||||
feed.scrollTop = feed.scrollHeight;
|
||||
}
|
||||
|
||||
try {
|
||||
// state.json on disk is the source of truth; localStorage only covers the server being unreachable
|
||||
fetch('state.json', {cache: 'no-store'})
|
||||
.then(r => r.json())
|
||||
.then(s => { if (Object.keys(s).length) handleState(s); else throw 0; })
|
||||
.catch(() => {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem('workbench-state'));
|
||||
if (saved) handleState(saved);
|
||||
} catch(e) {}
|
||||
|
||||
connect();
|
||||
} catch(e) {}
|
||||
})
|
||||
.finally(connect);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+16
-2
@@ -102,12 +102,26 @@ class WorkbenchServer:
|
||||
async def static_handler(request):
|
||||
proj = request.app["project_name"]
|
||||
path = request.match_info.get("path", "index.html") or "index.html"
|
||||
file_path = project_path(proj, self.workbench_dir) / path
|
||||
if not file_path.exists():
|
||||
root = project_path(proj, self.workbench_dir).resolve()
|
||||
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.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_post("/log", log_handler)
|
||||
app.router.add_get("/{path:.*}", static_handler)
|
||||
app.router.add_get("/", static_handler)
|
||||
|
||||
|
||||
@@ -103,3 +103,40 @@ async def test_reconnect_on_startup(tmp_workbench):
|
||||
assert srv._active["live-proj"]["port"] == 8070
|
||||
assert "dead-proj" not in srv._active
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user