feat: ttyd proxy, LAN auth, pop-out fix + multi-agent cleanup - #3
Conversation
Kiro: detect stale .studyctl-backup on setup and restore user's
original config before proceeding (crash recovery).
Gemini + OpenCode MCP: extract _mcp_command() helper that prefers
shutil.which("studyctl-mcp") for pip installs, falling back to
uv run --project for dev. Fixes broken _REPO_ROOT path after install.
OpenCode: update frontmatter from deprecated tools: to permission:
format (OpenCode 1.3.x). Remove unnecessary environment: {} key.
parking.py: always run migrations on connect (idempotent) instead of
only when table is missing — fixes schema drift where table exists
but is missing columns from newer migrations.
Tests: 6 new/updated agent_launcher tests, fix stale schema version
assertion (now imports CURRENT_VERSION instead of hardcoding).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Core feature: ttyd exposes the study tmux session over HTTP/WebSocket, enabling terminal access from any device on the LAN (iPad on the bus). Session lifecycle: - start_ttyd_background() spawns ttyd attached to the tmux session - --lan flag on studyctl study binds both web + ttyd to 0.0.0.0 - PID cleanup kills web_pid + ttyd_pid on session end (was missing for web too) - Pre-trust session directory for Claude Code (.claude/settings.local.json) Web dashboard (session.html): - Terminal panel with embedded ttyd iframe below the activity feed - Collapse toggle to hide/show the iframe - Pop-out button opens ttyd in a separate window - Alpine.js component reads ttyd_port from /api/session/state Config additions: - ttyd_port (default 7681), web_port (default 8567) - browser (empty = system default, or chrome/safari/firefox/brave) - skip_permissions (pass --dangerously-skip-permissions to Claude) Doctor: check_system_binaries() detects ttyd installation. Tests: 4 orchestrator unit tests + 8 Playwright E2E tests (5 UI + 3 real ttyd including write-to-terminal-frame verification). Includes record-demo.py script for automated demo recording. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Trust bypass: write hasTrustDialogAccepted to ~/.claude/settings.json for the sessions parent directory (inherits to all child sessions). Replaces the broken .claude/settings.local.json approach which only controls tool permissions, not workspace trust. Removes the skip_permissions config + --dangerously-skip-permissions flag. Browser auto-open: replace sleep(2) with port-polling (up to 10s) before opening the browser. Prevents opening to a not-yet-ready server. LAN URL display: when --lan is used, print the dashboard and terminal URLs with the machine's LAN IP so iPad/remote users know the address. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Browser auto-open: replace daemon thread (killed by os.execvp) with a detached shell subprocess that polls for server readiness then opens the browser. Subprocess survives the tmux attach exec. Split-pane layout: dashboard and terminal in a flexbox container with a draggable divider. Users can resize by dragging, toggle between stacked (vertical) and side-by-side (horizontal) layouts, and swap panel order. All via Alpine.js — no build step or external libraries. Controls: toggle layout (arrows), swap panels (up/down arrows), collapse terminal, pop-out to new window. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Terminal panel: dispatch 'terminal-ready' event AFTER the async fetch completes (not in $nextTick which races the fetch). splitLayout now correctly shows the terminal panel when ttyd_port is in session state. Browser open: replace sh subprocess with os.fork() + webbrowser.open(). The forked child process survives os.execvp(tmux attach) and retains full Python stdlib access for proper browser opening. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Override main's centering (align-items: center, justify-content: center) and session-dashboard's max-width: 700px when the split-pane layout is present. Uses :has() selector — supported in all modern browsers. The terminal panel and dashboard now fill the full browser width, and the ttyd iframe gets enough space for the tmux sidebar to render. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When the terminal is popped out to a separate window, the placeholder now shows a clear button to bring it back inline. Previously the only way back was clicking the small +/- toggle which wasn't discoverable. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Dotted lines: use tmux new-session -t (grouped session) instead of tmux attach -t. Grouped sessions share windows but size independently, so the ttyd client no longer constrains the native terminal to its smaller dimensions. Iframe persistence: replace x-show (display:none causes browser to unload/reload the iframe) with a CSS height:0 + overflow:hidden trick. The iframe stays loaded in the DOM so returning from pop-out reuses the existing ttyd connection instead of creating a new one. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Dotted lines: set window-size largest on the study tmux session so the biggest client (native terminal) determines the window size. Reverts to tmux attach -t (simpler than grouped sessions which multiplied on every ttyd connection). Iframe persistence: iframe is now always rendered (position: absolute, full size). The placeholder overlays it with z-index when popped out. This prevents WebSocket disconnection — the ttyd connection stays alive regardless of pop-out state, so returning to inline reuses it. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The iframe WebSocket drops when popped out, so returning to inline always created a new session. Replace the button with instructions: close the pop-out window, then click + to reopen inline. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
README: add --lan flag, ttyd prerequisites, terminal panel description. CLI reference: add --lan, ttyd_port/web_port/browser config, terminal panel description in web dashboard section. Setup guide: add ttyd to prerequisites, web terminal config section, "Remote Study (iPad on the Bus)" workflow. System overview: add ttyd to architecture diagram and prerequisites. Roadmap: check off ttyd + multi-agent items, add new features. Code fix: settings.py load_settings() now reads ttyd_port, web_port, browser from config YAML (were defined but never parsed). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Terminal proxy: - Reverse proxy ttyd HTTP + WebSocket through FastAPI at /terminal/ - Same-origin prevents iframe WebSocket drops on pop-out/return - Content-Length stripped (httpx decompresses), subprotocol forwarded - X-Frame-Options changed from DENY to SAMEORIGIN for iframe embedding LAN password protection: - HTTP Basic Auth middleware (timing-safe comparison) when --lan active - Password sources: --password CLI flag > lan_password config > auto-generated - LAN info (IP, password, URL) persisted to session state Pop-out persistence fix: - Iframe stays in DOM via CSS visibility (not display:none) to preserve WS - "+" button auto-closes pop-out window before re-embedding inline - Named window prevents duplicate pop-outs Documentation: - README, CLI reference, setup guide, system overview, roadmap all updated - .gitignore protects docs/plans/, docs/local_repo_docs/, docs/architecture/, demos/ Tests: 589 passed (+57 new), 6 skipped - 12 proxy tests (HTTP forwarding, HTML paths, app interface, X-Frame-Options) - 22 auth tests (no-auth, 401, wrong password, correct password, auto-gen, settings) - 2 WebSocket relay tests (keystrokes to tmux, output to client) - 5 Playwright UI tests (panel visibility, collapse, popout, iframe src) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Conflicts resolved: - .gitignore: combined both sets of exclusions - cli-reference.md: keep both --lan/--password and --agent ollama/lmstudio - cleanup.py: keep main's PID-recycling-safe kill (ps check before SIGTERM) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR restores and extends the web terminal (ttyd) integration by proxying ttyd HTTP/WebSocket traffic through the FastAPI web app at /terminal/, adds LAN-mode HTTP Basic Auth, improves the session dashboard terminal UI (split pane + pop-out persistence), and includes several multi-agent cleanup fixes.
Changes:
- Add same-origin
/terminal/reverse proxy (HTTP + WebSocket) and embed it in the session dashboard with split-pane + pop-out behavior. - Add
--lanand--passwordflows for the web dashboard (Basic Auth) plus new settings for web/ttyd ports and browser auto-open. - Add new tests for proxy/auth/UI behaviors, plus agent launcher fixes (Kiro recovery, MCP command selection, OpenCode schema/frontmatter updates).
Reviewed changes
Copilot reviewed 28 out of 30 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| uv.lock | Adds deps for terminal proxy + Playwright-based E2E testing. |
| scripts/record-demo.py | New Playwright script to record a demo session video. |
| README.md | Documents --web, --lan, LAN password, and ttyd installation. |
| packages/studyctl/tests/test_web_terminal.py | Adds Playwright E2E coverage for terminal panel UI + (optional) ttyd integration. |
| packages/studyctl/tests/test_web_app.py | Updates X-Frame-Options expectation to SAMEORIGIN for iframe embedding. |
| packages/studyctl/tests/test_terminal_proxy.py | Adds HTTP proxy + HTML path/interface tests for /terminal/ proxy and app factory args. |
| packages/studyctl/tests/test_session_db_integration.py | Updates migration tests to assert current schema version constant. |
| packages/studyctl/tests/test_orchestrator.py | Adds unit tests for start_ttyd_background() behavior. |
| packages/studyctl/tests/test_lan_auth.py | Adds tests for Basic Auth LAN protection and settings parsing. |
| packages/studyctl/tests/test_agent_launcher.py | Adds tests for Kiro crash recovery and MCP command/schema updates. |
| packages/studyctl/src/studyctl/web/static/style.css | Adds split-pane and terminal panel styling. |
| packages/studyctl/src/studyctl/web/static/session.html | Adds split layout + embedded terminal panel + pop-out persistence UI/logic. |
| packages/studyctl/src/studyctl/web/routes/terminal_proxy.py | New same-origin reverse proxy for ttyd (HTTP + WebSocket relay). |
| packages/studyctl/src/studyctl/web/auth.py | New Basic Auth middleware for LAN protection. |
| packages/studyctl/src/studyctl/web/app.py | Adds ttyd port/password to app factory, registers terminal proxy, updates headers. |
| packages/studyctl/src/studyctl/settings.py | Adds ttyd_port, web_port, browser, lan_password config fields. |
| packages/studyctl/src/studyctl/session/orchestrator.py | Adds Claude trust bypass, tmux window sizing, web auto-open, ttyd background start. |
| packages/studyctl/src/studyctl/session/cleanup.py | Ensures background PIDs (web/ttyd) are terminated on session end. |
| packages/studyctl/src/studyctl/parking.py | Ensures migrations always run; adds drift-recovery fallback creation. |
| packages/studyctl/src/studyctl/doctor/deps.py | Adds doctor check for optional ttyd system binary. |
| packages/studyctl/src/studyctl/cli/_web.py | Adds --password and --ttyd-port, LAN password generation, passes config into app factory. |
| packages/studyctl/src/studyctl/cli/_study.py | Adds --lan/--password, starts web + ttyd, persists LAN info to session state. |
| packages/studyctl/src/studyctl/cli/_doctor.py | Registers new system binary checks. |
| packages/studyctl/src/studyctl/agent_launcher.py | Adds Kiro stale-backup recovery; shared MCP command builder; OpenCode frontmatter/schema updates. |
| packages/studyctl/pyproject.toml | Updates web extra deps, adds e2e marker, adds dev dependency group. |
| docs/system-overview.md | Updates diagrams/sequence to include ttyd + /terminal/ proxy + LAN auth. |
| docs/setup-guide.md | Adds remote study (--lan) guidance + web/terminal config docs. |
| docs/roadmap.md | Marks ttyd/LAN/proxy work as completed. |
| docs/cli-reference.md | Documents new CLI flags and terminal panel behavior/config. |
| .gitignore | Ignores new local docs paths and demos/. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @router.websocket("/terminal/ws") | ||
| async def proxy_terminal_ws(ws: WebSocket) -> None: | ||
| """Proxy WebSocket connections to the local ttyd /ws endpoint. | ||
|
|
||
| ttyd's WebSocket protocol is relayed verbatim (binary + text frames). | ||
| """ | ||
| import websockets | ||
|
|
||
| port: int = getattr(ws.app.state, "ttyd_port", 7681) | ||
| upstream_ws_base = f"ws://127.0.0.1:{port}" | ||
|
|
||
| # Accept the connection, forwarding the subprotocol if present (ttyd uses "tty") | ||
| subprotocol = None | ||
| if ws.headers.get("sec-websocket-protocol"): | ||
| # Pass the first subprotocol the client requests | ||
| subprotocol = ws.headers["sec-websocket-protocol"].split(",")[0].strip() | ||
| await ws.accept(subprotocol=subprotocol) |
There was a problem hiding this comment.
The LAN Basic Auth middleware is implemented as BaseHTTPMiddleware, which only runs for HTTP scopes; WebSocket connections to /terminal/ws will bypass auth and allow unauthenticated terminal access when --lan is used. Add WebSocket auth enforcement (e.g., an ASGI middleware handling both http/websocket scopes, or explicit Authorization checking before ws.accept()), and add a test covering the WS handshake under auth.
| upstream_ws_url = f"{upstream_ws_base}/ws" | ||
| if ws.query_params: | ||
| qs = "&".join(f"{k}={v}" for k, v in ws.query_params.items()) | ||
| upstream_ws_url = f"{upstream_ws_url}?{qs}" |
There was a problem hiding this comment.
The WebSocket proxy builds the upstream query string via string concatenation without URL-encoding. If ttyd query params contain special characters, this can produce an invalid upstream URL. Use urllib.parse.urlencode (or httpx.QueryParams) to construct the query string safely.
| def _open_browser(url: str) -> None: | ||
| """Open URL in the configured browser after polling for server readiness. | ||
|
|
||
| Uses os.fork() to create a child process that survives the parent's | ||
| os.execvp(tmux attach). Daemon threads don't survive exec, but forked | ||
| children do (reparented to PID 1). | ||
| """ | ||
| pid = os.fork() | ||
| if pid != 0: | ||
| return # Parent continues with session startup | ||
|
|
||
| # Child process — poll then open browser | ||
| try: | ||
| import time | ||
| import urllib.request | ||
| import webbrowser | ||
|
|
||
| # Poll until server is ready (up to 10 seconds) | ||
| for _ in range(20): | ||
| try: | ||
| urllib.request.urlopen(url, timeout=1) | ||
| break | ||
| except Exception: | ||
| time.sleep(0.5) | ||
| else: | ||
| os._exit(0) # Server never started | ||
|
|
||
| browser_name = "" | ||
| try: | ||
| from studyctl.settings import load_settings | ||
|
|
||
| browser_name = getattr(load_settings(), "browser", "") | ||
| except Exception: | ||
| pass | ||
|
|
||
| browser_map = { | ||
| "chrome": "Google Chrome", | ||
| "safari": "Safari", | ||
| "firefox": "Firefox", | ||
| "brave": "Brave Browser", | ||
| } | ||
|
|
||
| if browser_name and browser_name.lower() in browser_map: | ||
| app = browser_map[browser_name.lower()] | ||
| # macOS: use open -a for specific browser | ||
| subprocess.Popen( | ||
| ["open", "-a", app, url], | ||
| stdout=subprocess.DEVNULL, | ||
| stderr=subprocess.DEVNULL, | ||
| ) | ||
| else: | ||
| webbrowser.open(url) |
There was a problem hiding this comment.
_open_browser() calls os.fork() unconditionally. This will raise on Windows (no fork) and can break studyctl study --web/--lan entirely. Please guard with hasattr(os, 'fork') / platform checks and fall back to a cross-platform approach (e.g., subprocess.Popen of a detached helper, multiprocessing.Process, or just best-effort webbrowser.open without fork on Windows).
| def _open_browser(url: str) -> None: | |
| """Open URL in the configured browser after polling for server readiness. | |
| Uses os.fork() to create a child process that survives the parent's | |
| os.execvp(tmux attach). Daemon threads don't survive exec, but forked | |
| children do (reparented to PID 1). | |
| """ | |
| pid = os.fork() | |
| if pid != 0: | |
| return # Parent continues with session startup | |
| # Child process — poll then open browser | |
| try: | |
| import time | |
| import urllib.request | |
| import webbrowser | |
| # Poll until server is ready (up to 10 seconds) | |
| for _ in range(20): | |
| try: | |
| urllib.request.urlopen(url, timeout=1) | |
| break | |
| except Exception: | |
| time.sleep(0.5) | |
| else: | |
| os._exit(0) # Server never started | |
| browser_name = "" | |
| try: | |
| from studyctl.settings import load_settings | |
| browser_name = getattr(load_settings(), "browser", "") | |
| except Exception: | |
| pass | |
| browser_map = { | |
| "chrome": "Google Chrome", | |
| "safari": "Safari", | |
| "firefox": "Firefox", | |
| "brave": "Brave Browser", | |
| } | |
| if browser_name and browser_name.lower() in browser_map: | |
| app = browser_map[browser_name.lower()] | |
| # macOS: use open -a for specific browser | |
| subprocess.Popen( | |
| ["open", "-a", app, url], | |
| stdout=subprocess.DEVNULL, | |
| stderr=subprocess.DEVNULL, | |
| ) | |
| else: | |
| webbrowser.open(url) | |
| def _poll_and_open_browser(url: str) -> None: | |
| """Poll for server readiness, then open the URL in a browser.""" | |
| import time | |
| import urllib.request | |
| import webbrowser | |
| # Poll until server is ready (up to 10 seconds) | |
| for _ in range(20): | |
| try: | |
| urllib.request.urlopen(url, timeout=1) | |
| break | |
| except Exception: | |
| time.sleep(0.5) | |
| else: | |
| return # Server never started | |
| browser_name = "" | |
| try: | |
| from studyctl.settings import load_settings | |
| browser_name = getattr(load_settings(), "browser", "") | |
| except Exception: | |
| pass | |
| browser_map = { | |
| "chrome": "Google Chrome", | |
| "safari": "Safari", | |
| "firefox": "Firefox", | |
| "brave": "Brave Browser", | |
| } | |
| if browser_name and browser_name.lower() in browser_map: | |
| app = browser_map[browser_name.lower()] | |
| # macOS: use open -a for specific browser | |
| subprocess.Popen( | |
| ["open", "-a", app, url], | |
| stdout=subprocess.DEVNULL, | |
| stderr=subprocess.DEVNULL, | |
| ) | |
| else: | |
| webbrowser.open(url) | |
| def _open_browser(url: str) -> None: | |
| """Open URL in the configured browser after polling for server readiness. | |
| On platforms with os.fork(), use a child process so browser opening | |
| survives the parent's os.execvp(tmux attach). On platforms without | |
| fork support (for example Windows), fall back to a best-effort direct | |
| call rather than raising. | |
| """ | |
| if not hasattr(os, "fork"): | |
| try: | |
| _poll_and_open_browser(url) | |
| except Exception: | |
| pass | |
| return | |
| pid = os.fork() | |
| if pid != 0: | |
| return # Parent continues with session startup | |
| # Child process — poll then open browser | |
| try: | |
| _poll_and_open_browser(url) |
| def _ensure_claude_trust(directory: Path) -> None: | ||
| """Add a directory to Claude Code's trusted projects in ~/.claude/settings.json. | ||
|
|
||
| Trust is checked by walking up the directory tree, so trusting the | ||
| sessions parent dir covers all future session directories. | ||
| """ | ||
| import json | ||
| from pathlib import Path as _Path | ||
|
|
||
| claude_settings = _Path.home() / ".claude" / "settings.json" | ||
| if not claude_settings.exists(): | ||
| return # No Claude Code installed | ||
|
|
||
| try: | ||
| data = json.loads(claude_settings.read_text()) | ||
| except (json.JSONDecodeError, OSError): | ||
| return | ||
|
|
||
| projects = data.setdefault("projects", {}) | ||
| dir_key = str(directory) | ||
|
|
||
| if projects.get(dir_key, {}).get("hasTrustDialogAccepted"): | ||
| return # Already trusted | ||
|
|
||
| projects.setdefault(dir_key, {})["hasTrustDialogAccepted"] = True | ||
|
|
||
| # Atomic write via temp file | ||
| import tempfile | ||
|
|
||
| tmp_fd, tmp_path = tempfile.mkstemp(dir=str(claude_settings.parent), suffix=".json") | ||
| try: | ||
| with os.fdopen(tmp_fd, "w") as f: | ||
| json.dump(data, f, indent=2) | ||
| os.replace(tmp_path, claude_settings) | ||
| except Exception: | ||
| with __import__("contextlib").suppress(OSError): | ||
| os.unlink(tmp_path) |
There was a problem hiding this comment.
_ensure_claude_trust() silently modifies ~/.claude/settings.json to bypass Claude Code’s trust prompt. This is a surprising side effect with security implications (it effectively auto-trusts a directory without explicit user consent). Consider making this opt-in (CLI flag/config/env), and/or at minimum print a clear message the first time it writes and restrict it to only the exact session directory rather than the parent.
| this.$el.addEventListener('toggle-layout', () => { | ||
| this.layout = this.layout === 'stack' ? 'side' : 'stack'; | ||
| this.splitPercent = this.layout === 'side' ? 50 : 40; | ||
| }); | ||
| this.$el.addEventListener('swap-panels', () => { | ||
| this.swapped = !this.swapped; | ||
| this.$el.style.flexDirection = this.swapped | ||
| ? (this.layout === 'side' ? 'row-reverse' : 'column-reverse') | ||
| : ''; | ||
| }); |
There was a problem hiding this comment.
swap-panels sets an inline flexDirection based on the current layout, but toggle-layout doesn’t update that inline style when swapped is already true. This can leave the panel order wrong after toggling between stacked/side-by-side while swapped. Update the toggle-layout handler to re-apply the correct flexDirection when swapped is true (or compute direction from state instead of mutating style imperatively).
| "lan_ip": lan_ip, | ||
| "lan_password": lan_password, | ||
| "lan_url": f"http://{lan_ip}:{web_port}/session", | ||
| } | ||
| ) |
There was a problem hiding this comment.
start_ttyd_background() is started unconditionally, even when the user didn’t request --web/--lan. That spawns an extra background process and exposes a local HTTP terminal unnecessarily. Consider starting ttyd only when the web dashboard (or a dedicated --terminal/config flag) is enabled.
| # Auto-generated password strength | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
|
|
||
| class TestAutoGeneratedPassword: | ||
| """The auto-generated password must be sufficiently strong.""" | ||
|
|
||
| def test_generated_password_is_16_or_more_chars(self) -> None: | ||
| import secrets | ||
|
|
||
| pwd = secrets.token_urlsafe(16) | ||
| assert len(pwd) >= 16 | ||
|
|
||
| def test_generated_password_is_unique(self) -> None: | ||
| import secrets | ||
|
|
||
| pwd1 = secrets.token_urlsafe(16) | ||
| pwd2 = secrets.token_urlsafe(16) | ||
| assert pwd1 != pwd2 | ||
|
|
||
|
|
There was a problem hiding this comment.
The “auto-generated password” tests here only assert properties of secrets.token_urlsafe(16) itself; they don’t exercise the actual code paths that generate/print/store the LAN password (in studyctl web or studyctl study --lan). These tests would still pass even if the application stopped generating sufficiently long passwords. Consider refactoring password generation into a helper that can be unit-tested, or integration-testing the CLI behavior/output/state update.
| # Auto-generated password strength | |
| # --------------------------------------------------------------------------- | |
| class TestAutoGeneratedPassword: | |
| """The auto-generated password must be sufficiently strong.""" | |
| def test_generated_password_is_16_or_more_chars(self) -> None: | |
| import secrets | |
| pwd = secrets.token_urlsafe(16) | |
| assert len(pwd) >= 16 | |
| def test_generated_password_is_unique(self) -> None: | |
| import secrets | |
| pwd1 = secrets.token_urlsafe(16) | |
| pwd2 = secrets.token_urlsafe(16) | |
| assert pwd1 != pwd2 | |
| # Settings-backed LAN password integration | |
| # --------------------------------------------------------------------------- | |
| class TestLanPasswordIntegration: | |
| """A LAN password loaded from settings must be enforced by the app.""" | |
| def test_loaded_lan_password_requires_auth(self, tmp_path) -> None: | |
| """A stored lan_password should make unauthenticated requests fail.""" | |
| from studyctl.settings import load_settings | |
| config = tmp_path / "config.yaml" | |
| config.write_text("lan_password: mylanpass\n") | |
| with pytest.MonkeyPatch().context() as mp: | |
| mp.setenv("STUDYCTL_CONFIG", str(config)) | |
| import studyctl.settings as settings_mod | |
| original = settings_mod._CONFIG_PATH | |
| settings_mod._CONFIG_PATH = config | |
| try: | |
| s = load_settings() | |
| app = create_app(password=s.lan_password) | |
| client = TestClient(app) | |
| resp = client.get("/") | |
| assert resp.status_code == 401 | |
| finally: | |
| settings_mod._CONFIG_PATH = original | |
| def test_loaded_lan_password_allows_authenticated_access(self, tmp_path) -> None: | |
| """The password loaded from settings should successfully authenticate.""" | |
| from studyctl.settings import load_settings | |
| config = tmp_path / "config.yaml" | |
| config.write_text("lan_password: mylanpass\n") | |
| with pytest.MonkeyPatch().context() as mp: | |
| mp.setenv("STUDYCTL_CONFIG", str(config)) | |
| import studyctl.settings as settings_mod | |
| original = settings_mod._CONFIG_PATH | |
| settings_mod._CONFIG_PATH = config | |
| try: | |
| s = load_settings() | |
| app = create_app(password=s.lan_password) | |
| client = TestClient(app) | |
| resp = client.get("/", headers=_basic_auth_header("user", s.lan_password)) | |
| assert resp.status_code == 200 | |
| finally: | |
| settings_mod._CONFIG_PATH = original |
| """Tests for the same-origin terminal proxy (Task 1). | ||
|
|
||
| The proxy reverse-proxies ttyd through FastAPI so all traffic is same-origin, | ||
| fixing iframe WebSocket drops when popping out the terminal. | ||
|
|
||
| Tests: | ||
| - GET /terminal/ proxies to upstream ttyd | ||
| - WebSocket /terminal/ws relays messages | ||
| - session.html uses /terminal/ path (same-origin), not http://hostname:port | ||
| - X-Frame-Options is SAMEORIGIN (not DENY) | ||
| - Security headers preserved on proxied routes | ||
| """ |
There was a problem hiding this comment.
This test module’s docstring and comments claim a WebSocket relay test for /terminal/ws, but no such test is implemented in the file. Either add a WS relay test (and auth coverage if applicable) or update the docstring so it matches the actual coverage.
Completes the original feature: a user who selects a provider with no stored
key can now enter it in the UI. Previously the backend secrets routes existed
and were tested, but the frontend never called them — there was no way to add
a key through the web app.
- index.html: inline key-entry row, shown only when the selected provider is
keyed (adapter != bedrock) AND not yet available. Password input + 'Test &
save' -> POST /api/content/secrets; on success clears the raw key from
memory, re-fetches /providers so the option flips to enabled, shows a
'verified & stored encrypted' confirmation; on 400/422 shows the provider's
rejection message. needsKey / selectedProvider getters; keyEntry state reset
on provider change.
- style.css: minimal feedback styles (key-error/key-ok/key-hint) using existing
palette vars; password/text inputs styled to match selects.
- test_web_key_entry_e2e.py: 4 real-browser e2e (port 18582) — form shows for
unavailable keyed provider, hidden for available one, save POSTs correct
{provider,key} body + shows success, rejection shows error.
Gate: 4/4 new e2e + 10/10 existing content-gen e2e green. With #1/#3 (4f1425c)
the feature now works end-to-end: select provider -> enter missing key ->
tested + stored encrypted -> consumed at generation.
…omplete Final overnight handoff + the autonomous workflow's per-provider report. Outcome: - Issue #1 (decks invisible to panels) FIXED & PROVEN — was 2 bugs (read-root unconfigured + 1-level-only discovery), not the hypothesised single 3-level one. - Autonomous gen+judge+validate+report workflow built, launched, completed: Bedrock APPROVED 9/9, Ollama APPROVED 8/7, MiniMax generates cleanly (2 adapter bugs fixed) but quiz quality is below the Opus judge bar (model limitation). - Playwright validated all 3 provider courses render in the panels. - Issue #2 (6 e2e selector failures) fixed. Issue #3 (model dropdown) deferred with rationale. Open product decision for the user: MiniMax rotation (flashcards-only / both / drop-for-quizzes). No further code needed for MiniMax to function.
…tatus The Generate-panel model dropdown is already wired to discovered models, so that half of Issue #3 is done; the Settings-panel picker + model:<slug> persistence is deferred (persistence-schema decision for the user). Flagged a real correctness finding: the curated Bedrock model IDs in provider_profiles.py are invalid for this account (both dated foundation-model IDs fail ValidationException; even us.anthropic.claude-haiku-4-5 fails). Only us.anthropic.claude-sonnet-4-6 was empirically proven to work. IDs are account/region-specific and not guessable, so left for the user to update from `aws bedrock list-inference-profiles` (SSO token had expired overnight).
Completes the original feature: a user who selects a provider with no stored
key can now enter it in the UI. Previously the backend secrets routes existed
and were tested, but the frontend never called them — there was no way to add
a key through the web app.
- index.html: inline key-entry row, shown only when the selected provider is
keyed (adapter != bedrock) AND not yet available. Password input + 'Test &
save' -> POST /api/content/secrets; on success clears the raw key from
memory, re-fetches /providers so the option flips to enabled, shows a
'verified & stored encrypted' confirmation; on 400/422 shows the provider's
rejection message. needsKey / selectedProvider getters; keyEntry state reset
on provider change.
- style.css: minimal feedback styles (key-error/key-ok/key-hint) using existing
palette vars; password/text inputs styled to match selects.
- test_web_key_entry_e2e.py: 4 real-browser e2e (port 18582) — form shows for
unavailable keyed provider, hidden for available one, save POSTs correct
{provider,key} body + shows success, rejection shows error.
Gate: 4/4 new e2e + 10/10 existing content-gen e2e green. With #1/#3 (4f1425c)
the feature now works end-to-end: select provider -> enter missing key ->
tested + stored encrypted -> consumed at generation.
Completes the original feature: a user who selects a provider with no stored
key can now enter it in the UI. Previously the backend secrets routes existed
and were tested, but the frontend never called them — there was no way to add
a key through the web app.
- index.html: inline key-entry row, shown only when the selected provider is
keyed (adapter != bedrock) AND not yet available. Password input + 'Test &
save' -> POST /api/content/secrets; on success clears the raw key from
memory, re-fetches /providers so the option flips to enabled, shows a
'verified & stored encrypted' confirmation; on 400/422 shows the provider's
rejection message. needsKey / selectedProvider getters; keyEntry state reset
on provider change.
- style.css: minimal feedback styles (key-error/key-ok/key-hint) using existing
palette vars; password/text inputs styled to match selects.
- test_web_key_entry_e2e.py: 4 real-browser e2e (port 18582) — form shows for
unavailable keyed provider, hidden for available one, save POSTs correct
{provider,key} body + shows success, rejection shows error.
Gate: 4/4 new e2e + 10/10 existing content-gen e2e green. With #1/#3 (4f1425c)
the feature now works end-to-end: select provider -> enter missing key ->
tested + stored encrypted -> consumed at generation.
Completes the original feature: a user who selects a provider with no stored
key can now enter it in the UI. Previously the backend secrets routes existed
and were tested, but the frontend never called them — there was no way to add
a key through the web app.
- index.html: inline key-entry row, shown only when the selected provider is
keyed (adapter != bedrock) AND not yet available. Password input + 'Test &
save' -> POST /api/content/secrets; on success clears the raw key from
memory, re-fetches /providers so the option flips to enabled, shows a
'verified & stored encrypted' confirmation; on 400/422 shows the provider's
rejection message. needsKey / selectedProvider getters; keyEntry state reset
on provider change.
- style.css: minimal feedback styles (key-error/key-ok/key-hint) using existing
palette vars; password/text inputs styled to match selects.
- test_web_key_entry_e2e.py: 4 real-browser e2e (port 18582) — form shows for
unavailable keyed provider, hidden for available one, save POSTs correct
{provider,key} body + shows success, rejection shows error.
Gate: 4/4 new e2e + 10/10 existing content-gen e2e green. With #1/#3 (4f1425c)
the feature now works end-to-end: select provider -> enter missing key ->
tested + stored encrypted -> consumed at generation.
Completes the original feature: a user who selects a provider with no stored
key can now enter it in the UI. Previously the backend secrets routes existed
and were tested, but the frontend never called them — there was no way to add
a key through the web app.
- index.html: inline key-entry row, shown only when the selected provider is
keyed (adapter != bedrock) AND not yet available. Password input + 'Test &
save' -> POST /api/content/secrets; on success clears the raw key from
memory, re-fetches /providers so the option flips to enabled, shows a
'verified & stored encrypted' confirmation; on 400/422 shows the provider's
rejection message. needsKey / selectedProvider getters; keyEntry state reset
on provider change.
- style.css: minimal feedback styles (key-error/key-ok/key-hint) using existing
palette vars; password/text inputs styled to match selects.
- test_web_key_entry_e2e.py: 4 real-browser e2e (port 18582) — form shows for
unavailable keyed provider, hidden for available one, save POSTs correct
{provider,key} body + shows success, rejection shows error.
Gate: 4/4 new e2e + 10/10 existing content-gen e2e green. With #1/#3 (4f1425c)
the feature now works end-to-end: select provider -> enter missing key ->
tested + stored encrypted -> consumed at generation.
Completes the original feature: a user who selects a provider with no stored
key can now enter it in the UI. Previously the backend secrets routes existed
and were tested, but the frontend never called them — there was no way to add
a key through the web app.
- index.html: inline key-entry row, shown only when the selected provider is
keyed (adapter != bedrock) AND not yet available. Password input + 'Test &
save' -> POST /api/content/secrets; on success clears the raw key from
memory, re-fetches /providers so the option flips to enabled, shows a
'verified & stored encrypted' confirmation; on 400/422 shows the provider's
rejection message. needsKey / selectedProvider getters; keyEntry state reset
on provider change.
- style.css: minimal feedback styles (key-error/key-ok/key-hint) using existing
palette vars; password/text inputs styled to match selects.
- test_web_key_entry_e2e.py: 4 real-browser e2e (port 18582) — form shows for
unavailable keyed provider, hidden for available one, save POSTs correct
{provider,key} body + shows success, rejection shows error.
Gate: 4/4 new e2e + 10/10 existing content-gen e2e green. With #1/#3 (4f1425c)
the feature now works end-to-end: select provider -> enter missing key ->
tested + stored encrypted -> consumed at generation.
…ements feat: ttyd proxy, LAN auth, pop-out fix + multi-agent cleanup
Completes the original feature: a user who selects a provider with no stored
key can now enter it in the UI. Previously the backend secrets routes existed
and were tested, but the frontend never called them — there was no way to add
a key through the web app.
- index.html: inline key-entry row, shown only when the selected provider is
keyed (adapter != bedrock) AND not yet available. Password input + 'Test &
save' -> POST /api/content/secrets; on success clears the raw key from
memory, re-fetches /providers so the option flips to enabled, shows a
'verified & stored encrypted' confirmation; on 400/422 shows the provider's
rejection message. needsKey / selectedProvider getters; keyEntry state reset
on provider change.
- style.css: minimal feedback styles (key-error/key-ok/key-hint) using existing
palette vars; password/text inputs styled to match selects.
- test_web_key_entry_e2e.py: 4 real-browser e2e (port 18582) — form shows for
unavailable keyed provider, hidden for available one, save POSTs correct
{provider,key} body + shows success, rejection shows error.
Gate: 4/4 new e2e + 10/10 existing content-gen e2e green. With #1/#3 (4f1425c)
the feature now works end-to-end: select provider -> enter missing key ->
tested + stored encrypted -> consumed at generation.
Freeze the SessionWeaver Phase 2 retrofit's spec and design ahead of any Phase B code, per EXECUTION-ERRATA.md correction #3 and council ruling R2 ("acceptance is not spec-check alone"). The design document normatively fixes the cross-machine standing order for replicated concept lifecycle events and its two-copy test matrix, the v48/v49 migration contracts and their rollback strategy, the non-fatal ontology refresh-failure seam, seed sanitization, the byte-for-byte memory_recall contract, the fresh-install scope diagnostic, and the ConceptService compatibility seam -- so B1-B6 can implement against a reviewed contract instead of inventing one under implementation pressure. Delta specs add Gherkin-scenario requirements to six existing capabilities (harness-session-memory, data-store-and-sync, mcp-server, session-export, health-and-diagnostics, configuration-and-secrets); no capability is newly created. openspec validate --specs --all: 25 passed, 0 failed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Summary
/terminal/, fixing iframe WebSocket drops on pop-out/return--passwordflag,lan_passwordconfig, or auto-generatedTest plan
🤖 Generated with Claude Code