Skip to content

feat: ttyd proxy, LAN auth, pop-out fix + multi-agent cleanup - #3

Merged
NetDevAutomate merged 13 commits into
mainfrom
fix/ttyd-restore-and-improvements
Apr 5, 2026
Merged

feat: ttyd proxy, LAN auth, pop-out fix + multi-agent cleanup#3
NetDevAutomate merged 13 commits into
mainfrom
fix/ttyd-restore-and-improvements

Conversation

@NetDevAutomate

Copy link
Copy Markdown
Owner

Summary

  • Restore ttyd integration — 11 commits lost from reflog, cherry-picked and extended
  • Same-origin reverse proxy — ttyd HTTP + WebSocket proxied through FastAPI at /terminal/, fixing iframe WebSocket drops on pop-out/return
  • LAN password protection — HTTP Basic Auth middleware with timing-safe comparison. Password via --password flag, lan_password config, or auto-generated
  • Pop-out persistence — iframe stays in DOM (CSS visibility), pop-out auto-closes on return to inline
  • Multi-agent cleanup — Kiro crash recovery, MCP path fixes, OpenCode frontmatter update, schema drift fix

Test plan

  • 589 tests passed, 6 skipped (pre-existing optional dep skips)
  • 12 proxy tests — HTTP forwarding, HTML paths, app interface, X-Frame-Options
  • 22 auth tests — no-auth passthrough, 401 enforcement, wrong/correct password, auto-gen, settings
  • 2 WebSocket relay tests — keystrokes reach tmux through proxy, output flows back
  • 5 Playwright UI tests — panel visibility, collapse toggle, popout button, iframe src path
  • All pre-commit hooks pass (ruff, pyright, detect-secrets)
  • Manual verification: pop-out → close → "+" returns terminal inline

🤖 Generated with Claude Code

NetDevAutomate and others added 12 commits April 5, 2026 00:10
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>
Copilot AI review requested due to automatic review settings April 5, 2026 00:22
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>
@NetDevAutomate
NetDevAutomate merged commit 4ad808e into main Apr 5, 2026
4 checks passed

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 --lan and --password flows 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.

Comment on lines +102 to +118
@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)

Copilot AI Apr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +120 to +123
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}"

Copilot AI Apr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +261 to +312
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)

Copilot AI Apr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_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).

Suggested change
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)

Copilot uses AI. Check for mistakes.
Comment on lines +24 to +60
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)

Copilot AI Apr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_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.

Copilot uses AI. Check for mistakes.
Comment on lines +338 to +347
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')
: '';
});

Copilot AI Apr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +442 to +446
"lan_ip": lan_ip,
"lan_password": lan_password,
"lan_url": f"http://{lan_ip}:{web_port}/session",
}
)

Copilot AI Apr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +169 to +189
# 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


Copilot AI Apr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
# 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

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +12
"""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
"""

Copilot AI Apr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@NetDevAutomate
NetDevAutomate deleted the fix/ttyd-restore-and-improvements branch May 4, 2026 22:05
NetDevAutomate pushed a commit that referenced this pull request Sep 3, 2026
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.
NetDevAutomate pushed a commit that referenced this pull request Sep 3, 2026
…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.
NetDevAutomate pushed a commit that referenced this pull request Sep 3, 2026
…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).
NetDevAutomate pushed a commit that referenced this pull request Sep 3, 2026
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.
NetDevAutomate pushed a commit that referenced this pull request Sep 3, 2026
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.
NetDevAutomate pushed a commit that referenced this pull request Sep 3, 2026
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.
NetDevAutomate added a commit that referenced this pull request Sep 3, 2026
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.
NetDevAutomate added a commit that referenced this pull request Sep 3, 2026
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.
NetDevAutomate added a commit that referenced this pull request Sep 5, 2026
…ements

feat: ttyd proxy, LAN auth, pop-out fix + multi-agent cleanup
NetDevAutomate added a commit that referenced this pull request Sep 5, 2026
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.
NetDevAutomate added a commit that referenced this pull request Sep 7, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants