Skip to content

feat: merge study command + E2E experience tests + bridge CLI - #2

Merged
NetDevAutomate merged 39 commits into
mainfrom
feat/study-command-e2e
Apr 1, 2026
Merged

feat: merge study command + E2E experience tests + bridge CLI#2
NetDevAutomate merged 39 commits into
mainfrom
feat/study-command-e2e

Conversation

@NetDevAutomate

Copy link
Copy Markdown
Owner

Summary

  • Merge feat+live-session-dashboard worktree — 37 commits adding studyctl study (tmux orchestrator + Textual sidebar + session state + agent launcher + parking lot)
  • Re-register bridge CLI command — dropped during compaction but DB functions preserved; CLI entry point restored
  • 4 new E2E experience verification tests — verify what users actually see, not just plumbing:
    1. Sidebar pane renders topics (not just IPC file check)
    2. Cleanup notes flow through DB into resume persona (full chain)
    3. Strict directory reuse assertion on resume (no OR fallback)
    4. Sidebar displays elapsed time
  • Deliberate CLI compaction — removes 9 unused commands (schedule, progress-map, teachback, etc.) per the compact-to-core plan

Test plan

  • Full test suite: 708 passed, 6 skipped (up from 650)
  • E2E integration: 31 tests pass (27 original + 4 new experience tests)
  • studyctl study --help works
  • studyctl bridge --help shows add/list
  • studyctl session --help shows start/end/status
  • Removed commands absent from studyctl --help
  • All pre-commit hooks pass (ruff, pyright, secrets, pytest)

Post-Deploy Monitoring & Validation

No additional operational monitoring required: CLI tool distributed via PyPI — users upgrade at their own pace.

🤖 Generated with Claude Code

taylaand and others added 30 commits March 23, 2026 09:53
…or tests

- Delete TUI dashboard, scheduler, calendar, state CLI
- Prune CLI: remove wins/streaks/resume/progress-map/bridges/teachback/schedule
- Archive Crush, Amp, and 5 extra Claude agent definitions
- Fix doctor integration tests: block network in autouse fixture
- Restore state.py (needed for NotebookLM SyncState, not push/pull CLI)
- Rewrite README: 426 → 162 lines, focused on 4 core use cases
- All tests green: 612 passed (247 studyctl + 357 session-tools + 8 doctor)
- cli-reference.md: rewritten to reflect 13 commands (removed TUI, schedule, state, wins, streaks, bridges, teachback)
- setup-guide.md: removed TUI section, replaced state push/pull with session-sync
- roadmap.md: added compaction notice at top
- TODO.md: rewritten with archived features section
- session-protocol.md: replaced studyctl wins/schedule-blocks references
- audhd-framework.md: replaced studyctl wins reference
- audhd-learning-philosophy.md: replaced studyctl bridge add reference
…CLI, IPC protocol

Phase 1 of the live study session dashboard. Closes the critical gap
where parking lot (AuDHD rabbit-hole prevention) had zero DB backing
despite being documented in 4 agent framework files.

New modules:
- parking.py: 6 CRUD functions for parked_topics table (park, get,
  schedule, resolve, dismiss)
- session_state.py: IPC file read/write/parse for dashboard viewports
  (session-state.json, session-topics.md, session-parking.md)
- cli/_session.py: session start/end/status and park commands

Changes:
- Migration v14: parked_topics table with status lifecycle
  (pending → scheduled → resolved/dismissed)
- Re-added resume, streaks, wins, progress commands from main
- Agent protocols updated with Session File Protocol instructions
  (Claude, Gemini, OpenCode, shared session-protocol.md)

Tests: 26 new tests (9 parking, 12 session_state, 5 CLI session)
Full suite: 638 passed, 5 skipped

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
cmux MCP server (cmuxlayer) added to Claude, Gemini, and OpenCode agent
configs. New "cmux Dashboard Protocol" section in session-protocol.md
with complete MCP tool call sequences for:

- Session start: split pane, sidebar status/progress, tab rename
- During session: topic updates, counter increments, timer progression,
  break notifications
- Session end: summary view, progress complete marker

The cmux viewport is additive — file-IPC writes continue unconditionally.
If cmux MCP tools are unavailable (non-macOS, not in cmux terminal),
the agent skips cmux calls silently and falls back to file-only IPC.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Design decisions and rationale for the live study session dashboard:
agent-initiated sessions (no start question), combined activity pane
with color+shape accessibility, energy-adaptive timer from break-science.md,
file-IPC protocol, 3-viewport architecture (cmux, web PWA, TUI).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
SSE-powered live session dashboard at /session with HTMX + Alpine.js.
The agent writes to IPC files during a study session; the web app polls
them via SSE and pushes HTML fragments with OOB swaps for real-time
updates without page reloads.

New:
- web/routes/session.py: SSE stream (/api/session/stream) + JSON state
  endpoint (/api/session/state), 5 HTML rendering helpers with XSS-safe
  escaping, shapes+colors for accessibility (✓ ★ ◆ ▲ ○)
- web/static/session.html: Dashboard page with HTMX SSE extension,
  Alpine.js energy-adaptive timer (thresholds from break-science.md),
  activity feed, counter bar (WINS|PARKED|REVIEW), summary view
- Session indicator banner on courses page when a session is active
- Service worker v2: skip caching SSE streams and HTMX fragments

Tests: 14 new tests (page serving, API state, SSE format, render
functions for activity feed, counters, summary, XSS escaping)
Full suite: 677 passed, 3 skipped

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Two fixes from live testing:

1. parking.py: _connect() now checks if parked_topics table exists and
   runs agent-session-tools migrations automatically if missing. Previously
   park_topic() would fail with "no such table" on databases that hadn't
   been migrated to v14.

2. session.html: Added pause/resume and reset buttons to the session
   timer. Pause shifts startTime forward on resume so elapsed time only
   counts active study time. Reset starts fresh with threshold messages
   cleared. "Paused" label shown in amber when timer is paused.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Architecture decisions for the next evolution of studyctl:
- tmux as session runtime, all viewports read from it
- `studyctl study` as universal entry point (CLI, /studyctl, @studyctl)
- One layout all modes (study, co-study), mode changes agent behaviour
- Two-port model: dashboard (read-only, safe) + ttyd terminal (auth'd)
- Always-on server: start on Mac Mini, continue from any device
- Timer: elapsed + pomodoro, both pausable, energy-adaptive thresholds
- Speech-to-text recommendations (Warp, Handy, Wispr Flow)
- UX: session resume, quick capture, "I'm stuck" buttons, energy tracking

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Documents 5 false assumptions caught by 9 research agents before any
code was written:

1. ttyd TTYD_CREDENTIAL env var does not exist — use nginx proxy
2. age CLI requires TTY — use pyrage + macOS Keychain instead
3. Only Claude supports --system-prompt-file — others need config files
4. tmux -f is server-level, ignored on running server — use source-file
5. Distributed timer needs reconciliation — stateless computation simpler

Includes prevention protocol: treat every CLI capability in a plan as
an untested hypothesis, verify against actual binaries before coding.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- SSE mtime optimization: replace JSON hash with 3 stat() calls for O(1)
  change detection in session stream endpoint
- Parking dedup: migration v15 adds unique index on (study_session_id,
  question), INSERT OR IGNORE prevents session_end re-inserting entries
- IPC file permissions: 0700 on session dir, 0600 on all IPC files via
  os.open() with explicit mode bits
- CORS wildcard removed from stdlib web server
- Docs: README architecture diagram, CLI reference, and roadmap updated
  for live session dashboard features (session CLI, SSE, parking lot)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
New `studyctl study` command creates a complete tmux-based study
environment with one command: agent pane + Textual sidebar + optional
web dashboard.

New modules:
- tmux.py: thin wrapper with -P -F pane IDs, source-file config,
  os.execvp attach, file lock for creation, version detection
- agent_launcher.py: detect installed agents, build persona files
  with mkstemp 0600, Claude-only initially (dict makes adding trivial)
- tui/sidebar.py: Textual app with TimerWidget (energy-adaptive
  colour phases from break-science.md), ActivityFeed, CounterBar,
  IPC polling via worker thread, session-oneline.txt for tmux status bar
- cli/_study.py: orchestrator — session start, tmux layout, agent
  launch, sidebar launch, --resume, --end, --web, --mode co-study

Also:
- Agent persona files (study.md, co-study.md) in agents/shared/personas/
- 39 new tests (test_tmux, test_agent_launcher, test_study)
- 359 studyctl tests pass, 3 skipped

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…mmand

- README: replace session start/end with studyctl study as primary entry
- CLI reference: add full studyctl study section with modes, flags, and
  architecture description; demote session commands to "low-level"
- Roadmap: mark Phase 1 Unified Session as complete

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Three fixes from live testing:

- Auto-switch when already in tmux: use switch-client instead of
  printing a hint. Covers both study start and --resume paths.
- Sidebar launch: use sys.executable + python -m studyctl.tui.sidebar
  instead of uv run (which needs a project dir). Added __main__.py
  entry points for both studyctl.tui and studyctl.tui.sidebar.
- Split pane: use percentage (25%) not fixed columns (35) so the
  sidebar adapts to terminal width.

Also:
- Bundled catppuccin-inspired tmux config at data/tmux-studyctl.conf
  (loaded via source-file, user override at ~/.config/studyctl/)
- Added switch_client() to tmux.py wrapper
- Quick park popup: prefix+p opens display-popup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…imensions

Root cause: create_session used -x 200 -y 50, so the 25% split was
calculated at 200 columns. When the client switched/attached, tmux
resized but the split ratio was distorted (sidebar became a sliver).

Fix:
- Remove -x/-y from create_session — let session use default size
- Switch client BEFORE splitting when already in tmux, so the split
  percentage is calculated against the actual terminal width
- Update test to match new code path (no console output in tmux path)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
`from textual.worker import work` fails on Textual 8.x. The correct
import is `from textual import work` (re-exported at package level).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Two improvements from live testing:

- Pass command= to create_session and split_pane instead of send_keys.
  Commands run directly in the pane — no shell prompt, no visible
  command text in scrollback. Main pane shows only Claude Code output.
- Trim tmux-studyctl.conf to minimal overlay: only status-right
  (oneline readout) and prefix+p (quick park popup). Does not override
  user's prefix, keybindings, theme, colours, or mouse settings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The bundled tmux-studyctl.conf overrides status-right (clobbering
catppuccin's generated output) and binds prefix+p (conflicting with
floax). Only load a user's explicit override at
~/.config/studyctl/tmux-studyctl.conf. The bundled file remains as
a reference template in data/.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Non-technical users shouldn't need to know about tmux or IPC files.
When the agent process exits (user quits Claude, /exit, or Ctrl+C),
a wrapper command automatically:
- Ends the DB session
- Cleans up persona + IPC files
- Switches tmux client back to the previous session
- Kills the study tmux session

Also:
- Sidebar Q (uppercase) triggers full session end from the sidebar
- studyctl study --end now switches back to previous tmux session
- _cleanup_session() is the shared cleanup function for all exit paths

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Each study session now runs in its own directory at
~/.config/studyctl/sessions/{session-name}/. The AI agent's
conversation history (.claude/, .kiro/, etc.) is preserved there.

This enables true conversation resume: studyctl study --resume
detects the existing session directory, passes -r to the agent
(e.g. claude -r), and the AI picks up where it left off — not
just tmux reattachment but actual conversation continuity.

Changes:
- tmux.py: create_session accepts cwd parameter
- agent_launcher.py: get_launch_command accepts resume=True,
  uses agent's resume command template (claude -r)
- _study.py: creates session dir, detects existing .claude/ for
  auto-resume, stores session_dir + agent in state
- _handle_resume: two scenarios — live tmux (reattach) or dead
  tmux with preserved dir (rebuild + resume conversation)
- Session dirs persist after --end (conversation history kept)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
agent_launcher.py traverses from src/studyctl/ to the repo root
to find agents/shared/personas/. Was off by one parent level,
landing in packages/ instead of the repo root. Persona files
(study.md, co-study.md) were silently falling back to inline
defaults instead of using the richer persona templates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Same class of issue as the sidebar launch — uv run depends on
being in a project directory. Use shutil.which("studyctl") first
(installed entry point), fall back to python -m.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Document the full session lifecycle: persistent session directories,
conversation history resume with -r, auto-cleanup on agent exit,
sidebar Q to end session. Update roadmap Phase 1 description.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comprehensive document showing how all components connect:
- Content pipeline (Obsidian → pandoc → NotebookLM → artefacts)
- Study sessions (tmux + agent + sidebar + web dashboard)
- Review system (SM-2 spaced repetition via Web PWA)
- Session intelligence (export, search, cross-machine sync)
- Agent protocol (shared Socratic engine, personas, AuDHD framework)
- End-to-end workflow example from materials to mastery
- Data stores, prerequisites, and install options

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
TimerWidget now has two modes:
- elapsed: counts up with energy-adaptive colour phases (unchanged)
- pomodoro: 25/5/25/5/25/5/25/15 cycle with countdown display

Sidebar shows: "12:34 FOCUS 2/4" during work blocks, "04:12 BREAK"
during short breaks, "08:30 LONG BREAK" during long breaks. Colours:
green=focus, cyan=short break, magenta=long break.

The tmux status bar oneline also reflects pomodoro state:
"Topic | 12:34 F2 | E:7 | W:3 P:1 R:2"

Start a pomodoro session with:
  studyctl study "topic" --timer pomodoro
  studyctl study "topic" --mode co-study  (defaults to pomodoro)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Bring the single source of truth current with all work from the
feat+live-session-dashboard branch: studyctl study, tmux, sidebar,
pomodoro, auto-cleanup, session directories, resume. Reorganise
next steps into v2.2 Polish, Phase 3 Devices, Phase 6 CI/CD.
Add key file references for new modules.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Two issues preventing --resume from working:

1. is_session_active() only checked study_session_id, not mode.
   After cleanup writes mode="ended", the session appeared active.
   Fix: return False when mode == "ended".

2. _handle_resume didn't clear stale IPC files before calling
   _handle_start, so _handle_start saw "already active".
   Fix: call clear_session_files() before rebuilding.

Also fixed mode="ended" leaking into the rebuilt session (was
passing "ended" as the session mode instead of "study").

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Two fixes for --resume:

1. Resume now passes the existing session_name and session_dir to
   _handle_start, so the rebuilt tmux session lands in the same
   directory with the preserved .claude/ conversation history.
   Previously it created a fresh session dir (new study_id).

2. Both _cleanup_session and _handle_end now capture session context
   (topics covered, wins, struggles, parked questions) as notes in
   the study_sessions DB record before ending. This gives the agent
   context about where the conversation was when it resumes.

Also fixed is_session_active() to return False when mode=="ended",
preventing the "already active" block on resume.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude Code stores conversation history in ~/.claude/projects/{path}/,
not .claude/ in the working directory. The resume detection was checking
(session_dir / ".claude").exists() which was always False.

Now checks ~/.claude/projects/{mangled-session-path}/ which is where
Claude actually writes its history. Also moved Path import to cover
both branches.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When ending via sidebar Q, send C-c to the agent pane first so
Claude Code exits gracefully and writes its conversation history.
Wait 2 seconds for the write, then run cleanup. Without this,
killing the tmux session terminates Claude abruptly and the
conversation history may not be saved for --resume.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Three fixes for sidebar activity and resume quality:

1. New `studyctl topic` CLI command — agents call this to log topics
   to the sidebar activity feed. Replaces unreliable raw file writes.
   Example: studyctl topic "Closures" --status win --note "understood"

2. Personas rewritten to use CLI commands (studyctl topic, studyctl park)
   instead of raw file writes. Clear instructions on WHEN to log
   (after every significant exchange).

3. Resume injects previous session notes into the persona file.
   The agent gets context like "Wins: Decorators. Struggling: Metaclasses.
   Parked: How does asyncio compare?" and is told to pick up where
   the student left off.

Also: sidebar Q simplified — sends C-c to agent pane and exits.
The wrapper command handles cleanup (no competing cleanup calls).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
NetDevAutomate added a commit that referenced this pull request Sep 3, 2026
… stage 3/7)

Closes R-05. Before this stage, an unrecognised value for `transport` —
including the retired `ttyd` — silently resolved to `pty`, both in the
request body (_resolve_transport fell through to a default) and via the
STUDYLOOP_TRANSPORT env-var kill switch. A caller asking for a transport
that no longer exists got a different one with no error, which is worse
than a clean rejection: it hides the fact that the thing asked for is gone.

`StartSessionRequest.transport` is now `Literal["pty", "acp"] | None`, so
Pydantic itself 422s an unrecognised body value before the handler runs.
`_resolve_transport()` raises the new `UnsupportedTransportError` for any
non-empty `STUDYLOOP_TRANSPORT` other than "pty"; `start_session()` catches
it and returns 422. Both paths are fixed in this one commit, per
REMEDIATION-PLAN's Gate 1 amendment #2, with a mandatory test for each:
`{"transport": "ttyd"}` -> 422, and `STUDYLOOP_TRANSPORT=ttyd` -> 422 (not
silently pty).

Deletes `_start_ttyd_session()` and `_ttyd_credentials()` (_start.py) — the
dispatcher's fallback branch that reached the legacy tmux+ttyd path is gone
along with them. `app.state.lan_username`/`lan_password` (web/app.py) are
removed too: `_ttyd_credentials()` was their only reader, and the plan's own
instruction is to leave no false authority markers rather than keep unread
state whose documented purpose no longer applies. Real LAN Basic-Auth is
unaffected — `create_app()` still wires `BasicAuthMiddleware` directly from
its username/password parameters, which never went through app.state.

Test reconciliation, beyond the manifest's literal line numbers where its
granularity didn't reach: TestLanCredentialsOnAppState (test_lan_auth.py,
5 tests) is deleted as one unit — its whole premise (app.state as the
ttyd/app auth divergence guard) is gone; test_start_rejects_no_tmux
(test_web_session.py) is deleted since no surviving transport touches tmux,
so its 503-on-no-multiplexer assertion has no home. Full ledger and
rationale for each in evidence/M1/stage-3/04-manifest.md. Pass count 3875 ->
3870, exactly at the strict-manifest floor (3877 - 4 - 3 = 3870).

Docs describing the ttyd server transport as still available to maintainers
(system-overview.md, architecture/current.md:149, cli-reference.md,
troubleshooting.md) are now stale and deliberately left for stage 7 per
PLAN-retire-ttyd.md's cut-point rule ("not shippable in doc terms mid-flight
... never cut 0.1.0 mid-flight").

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
NetDevAutomate pushed a commit that referenced this pull request Sep 3, 2026
The encrypted secrets store (secrets.py, commit 5b07afe) was a sealed vault:
nothing read from it at generation time and the provider dropdown ignored it.
A key added via the web store had zero effect. This wires both ends.

- anthropic_compat / openai_compat _read_api_key: call secrets.get_secret(slug)
  (resolves store -> env -> None) instead of os.environ only. A key stored via
  the Generate panel is now actually consumed. Error message points users at
  the panel as well as the env var. Dropped the now-unused os import.
- web /content/providers: 'available' flag is now get_secret(slug) OR env var,
  so a provider with a stored-but-not-exported key shows as enabled, not greyed
  out. Bedrock's separate boto3-credential path is untouched.

TDD: 3 new tests (stored-key consumed at construction; missing-everywhere error
names both paths; provider available from store). Red confirmed before fix.
36/36 adapter+providers suites green; 66 secrets/route/content-gen regression
tests pass.

Remaining for the full feature: the Generate-panel key-entry UI (#2) — backend
secrets routes exist + tested, frontend does not call them yet.
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
The panel works live; the tests' locators were wrong. Each provider row renders
ALL THREE auth-kind control divs (api_key / bedrock_bearer / local_keyless),
x-show-hidden but present in the DOM, and every row has its own .key-ok /
.key-error status spans. So:
- unscoped input[type=password] / "Test & save" matched a visible control AND a
  hidden one -> strict-mode violation;
- page.wait_for_selector(".provider-row .key-ok") matched all 3 rows and waited
  on the first (openai's), which never becomes visible -> timeout.

Fix (selectors only, panel untouched, per handoff):
- scope inputs/buttons with :visible;
- assert "no VISIBLE password input" for Ollama (hidden ones legitimately exist);
- wait on the acted-on row's own .key-ok/.key-error via row.locator(...).wait_for.

All 8 settings-panel e2e tests pass (was 6 failed / 2 passed).
NetDevAutomate pushed a commit that referenced this pull request Sep 3, 2026
…-side ACP guard (P0)

Audit P0 #1 + #2. The user-controlled topic became a session-dir path
segment via a naive .replace(' ','-') slug in FOUR places, none of which
stripped '/', '\\' or '..' — and the dir is later rmtree'd on failure,
so a topic like '../../x' was a real escape+delete vector.

- New slug_session_dir() collapses everything outside [a-z0-9] to '-' and
  falls back to 'session' when empty; session_dir_name() routes through it.
- Replace the three inline duplicate slugs (web PTY/ttyd _start.py, CLI
  session/start.py) with the shared helper — root-cause fix, one segment.
- Add server-side ACP capability guard in _start_acp_session: a PTY-only
  agent (Claude Code, Codex) requesting transport=acp now gets a 400 with
  cause + repair BEFORE any spawn, instead of an opaque failure.
- ACP_CAPABLE_AGENTS is the single source of truth; _options.py's picker
  flags now reference it instead of a duplicated {kiro,gemini,grok} literal.

Tests: parametrized traversal cases + empty-slug fallback + ACP-guard
rejection for claude/codex + capable-set lock. Held-out gate: the traversal
tests fail on the pre-fix slug (verified by revert), pass on the fix.
NetDevAutomate added a commit that referenced this pull request Sep 3, 2026
… stage 3/7)

Closes R-05. Before this stage, an unrecognised value for `transport` —
including the retired `ttyd` — silently resolved to `pty`, both in the
request body (_resolve_transport fell through to a default) and via the
STUDYLOOP_TRANSPORT env-var kill switch. A caller asking for a transport
that no longer exists got a different one with no error, which is worse
than a clean rejection: it hides the fact that the thing asked for is gone.

`StartSessionRequest.transport` is now `Literal["pty", "acp"] | None`, so
Pydantic itself 422s an unrecognised body value before the handler runs.
`_resolve_transport()` raises the new `UnsupportedTransportError` for any
non-empty `STUDYLOOP_TRANSPORT` other than "pty"; `start_session()` catches
it and returns 422. Both paths are fixed in this one commit, per
REMEDIATION-PLAN's Gate 1 amendment #2, with a mandatory test for each:
`{"transport": "ttyd"}` -> 422, and `STUDYLOOP_TRANSPORT=ttyd` -> 422 (not
silently pty).

Deletes `_start_ttyd_session()` and `_ttyd_credentials()` (_start.py) — the
dispatcher's fallback branch that reached the legacy tmux+ttyd path is gone
along with them. `app.state.lan_username`/`lan_password` (web/app.py) are
removed too: `_ttyd_credentials()` was their only reader, and the plan's own
instruction is to leave no false authority markers rather than keep unread
state whose documented purpose no longer applies. Real LAN Basic-Auth is
unaffected — `create_app()` still wires `BasicAuthMiddleware` directly from
its username/password parameters, which never went through app.state.

Test reconciliation, beyond the manifest's literal line numbers where its
granularity didn't reach: TestLanCredentialsOnAppState (test_lan_auth.py,
5 tests) is deleted as one unit — its whole premise (app.state as the
ttyd/app auth divergence guard) is gone; test_start_rejects_no_tmux
(test_web_session.py) is deleted since no surviving transport touches tmux,
so its 503-on-no-multiplexer assertion has no home. Full ledger and
rationale for each in evidence/M1/stage-3/04-manifest.md. Pass count 3875 ->
3870, exactly at the strict-manifest floor (3877 - 4 - 3 = 3870).

Docs describing the ttyd server transport as still available to maintainers
(system-overview.md, architecture/current.md:149, cli-reference.md,
troubleshooting.md) are now stale and deliberately left for stage 7 per
PLAN-retire-ttyd.md's cut-point rule ("not shippable in doc terms mid-flight
... never cut 0.1.0 mid-flight").

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
NetDevAutomate pushed a commit that referenced this pull request Sep 3, 2026
The panel works live; the tests' locators were wrong. Each provider row renders
ALL THREE auth-kind control divs (api_key / bedrock_bearer / local_keyless),
x-show-hidden but present in the DOM, and every row has its own .key-ok /
.key-error status spans. So:
- unscoped input[type=password] / "Test & save" matched a visible control AND a
  hidden one -> strict-mode violation;
- page.wait_for_selector(".provider-row .key-ok") matched all 3 rows and waited
  on the first (openai's), which never becomes visible -> timeout.

Fix (selectors only, panel untouched, per handoff):
- scope inputs/buttons with :visible;
- assert "no VISIBLE password input" for Ollama (hidden ones legitimately exist);
- wait on the acted-on row's own .key-ok/.key-error via row.locator(...).wait_for.

All 8 settings-panel e2e tests pass (was 6 failed / 2 passed).
NetDevAutomate pushed a commit that referenced this pull request Sep 3, 2026
…-side ACP guard (P0)

Audit P0 #1 + #2. The user-controlled topic became a session-dir path
segment via a naive .replace(' ','-') slug in FOUR places, none of which
stripped '/', '\\' or '..' — and the dir is later rmtree'd on failure,
so a topic like '../../x' was a real escape+delete vector.

- New slug_session_dir() collapses everything outside [a-z0-9] to '-' and
  falls back to 'session' when empty; session_dir_name() routes through it.
- Replace the three inline duplicate slugs (web PTY/ttyd _start.py, CLI
  session/start.py) with the shared helper — root-cause fix, one segment.
- Add server-side ACP capability guard in _start_acp_session: a PTY-only
  agent (Claude Code, Codex) requesting transport=acp now gets a 400 with
  cause + repair BEFORE any spawn, instead of an opaque failure.
- ACP_CAPABLE_AGENTS is the single source of truth; _options.py's picker
  flags now reference it instead of a duplicated {kiro,gemini,grok} literal.

Tests: parametrized traversal cases + empty-slug fallback + ACP-guard
rejection for claude/codex + capable-set lock. Held-out gate: the traversal
tests fail on the pre-fix slug (verified by revert), pass on the fix.
NetDevAutomate added a commit that referenced this pull request Sep 3, 2026
… stage 3/7)

Closes R-05. Before this stage, an unrecognised value for `transport` —
including the retired `ttyd` — silently resolved to `pty`, both in the
request body (_resolve_transport fell through to a default) and via the
STUDYLOOP_TRANSPORT env-var kill switch. A caller asking for a transport
that no longer exists got a different one with no error, which is worse
than a clean rejection: it hides the fact that the thing asked for is gone.

`StartSessionRequest.transport` is now `Literal["pty", "acp"] | None`, so
Pydantic itself 422s an unrecognised body value before the handler runs.
`_resolve_transport()` raises the new `UnsupportedTransportError` for any
non-empty `STUDYLOOP_TRANSPORT` other than "pty"; `start_session()` catches
it and returns 422. Both paths are fixed in this one commit, per
REMEDIATION-PLAN's Gate 1 amendment #2, with a mandatory test for each:
`{"transport": "ttyd"}` -> 422, and `STUDYLOOP_TRANSPORT=ttyd` -> 422 (not
silently pty).

Deletes `_start_ttyd_session()` and `_ttyd_credentials()` (_start.py) — the
dispatcher's fallback branch that reached the legacy tmux+ttyd path is gone
along with them. `app.state.lan_username`/`lan_password` (web/app.py) are
removed too: `_ttyd_credentials()` was their only reader, and the plan's own
instruction is to leave no false authority markers rather than keep unread
state whose documented purpose no longer applies. Real LAN Basic-Auth is
unaffected — `create_app()` still wires `BasicAuthMiddleware` directly from
its username/password parameters, which never went through app.state.

Test reconciliation, beyond the manifest's literal line numbers where its
granularity didn't reach: TestLanCredentialsOnAppState (test_lan_auth.py,
5 tests) is deleted as one unit — its whole premise (app.state as the
ttyd/app auth divergence guard) is gone; test_start_rejects_no_tmux
(test_web_session.py) is deleted since no surviving transport touches tmux,
so its 503-on-no-multiplexer assertion has no home. Full ledger and
rationale for each in evidence/M1/stage-3/04-manifest.md. Pass count 3875 ->
3870, exactly at the strict-manifest floor (3877 - 4 - 3 = 3870).

Docs describing the ttyd server transport as still available to maintainers
(system-overview.md, architecture/current.md:149, cli-reference.md,
troubleshooting.md) are now stale and deliberately left for stage 7 per
PLAN-retire-ttyd.md's cut-point rule ("not shippable in doc terms mid-flight
... never cut 0.1.0 mid-flight").

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
NetDevAutomate pushed a commit that referenced this pull request Sep 3, 2026
The encrypted secrets store (secrets.py, commit 7147ca6) was a sealed vault:
nothing read from it at generation time and the provider dropdown ignored it.
A key added via the web store had zero effect. This wires both ends.

- anthropic_compat / openai_compat _read_api_key: call secrets.get_secret(slug)
  (resolves store -> env -> None) instead of os.environ only. A key stored via
  the Generate panel is now actually consumed. Error message points users at
  the panel as well as the env var. Dropped the now-unused os import.
- web /content/providers: 'available' flag is now get_secret(slug) OR env var,
  so a provider with a stored-but-not-exported key shows as enabled, not greyed
  out. Bedrock's separate boto3-credential path is untouched.

TDD: 3 new tests (stored-key consumed at construction; missing-everywhere error
names both paths; provider available from store). Red confirmed before fix.
36/36 adapter+providers suites green; 66 secrets/route/content-gen regression
tests pass.

Remaining for the full feature: the Generate-panel key-entry UI (#2) — backend
secrets routes exist + tested, frontend does not call them yet.
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
The panel works live; the tests' locators were wrong. Each provider row renders
ALL THREE auth-kind control divs (api_key / bedrock_bearer / local_keyless),
x-show-hidden but present in the DOM, and every row has its own .key-ok /
.key-error status spans. So:
- unscoped input[type=password] / "Test & save" matched a visible control AND a
  hidden one -> strict-mode violation;
- page.wait_for_selector(".provider-row .key-ok") matched all 3 rows and waited
  on the first (openai's), which never becomes visible -> timeout.

Fix (selectors only, panel untouched, per handoff):
- scope inputs/buttons with :visible;
- assert "no VISIBLE password input" for Ollama (hidden ones legitimately exist);
- wait on the acted-on row's own .key-ok/.key-error via row.locator(...).wait_for.

All 8 settings-panel e2e tests pass (was 6 failed / 2 passed).
NetDevAutomate pushed a commit that referenced this pull request Sep 3, 2026
…-side ACP guard (P0)

Audit P0 #1 + #2. The user-controlled topic became a session-dir path
segment via a naive .replace(' ','-') slug in FOUR places, none of which
stripped '/', '\\' or '..' — and the dir is later rmtree'd on failure,
so a topic like '../../x' was a real escape+delete vector.

- New slug_session_dir() collapses everything outside [a-z0-9] to '-' and
  falls back to 'session' when empty; session_dir_name() routes through it.
- Replace the three inline duplicate slugs (web PTY/ttyd _start.py, CLI
  session/start.py) with the shared helper — root-cause fix, one segment.
- Add server-side ACP capability guard in _start_acp_session: a PTY-only
  agent (Claude Code, Codex) requesting transport=acp now gets a 400 with
  cause + repair BEFORE any spawn, instead of an opaque failure.
- ACP_CAPABLE_AGENTS is the single source of truth; _options.py's picker
  flags now reference it instead of a duplicated {kiro,gemini,grok} literal.

Tests: parametrized traversal cases + empty-slug fallback + ACP-guard
rejection for claude/codex + capable-set lock. Held-out gate: the traversal
tests fail on the pre-fix slug (verified by revert), pass on the fix.
NetDevAutomate added a commit that referenced this pull request Sep 3, 2026
… stage 3/7)

Closes R-05. Before this stage, an unrecognised value for `transport` —
including the retired `ttyd` — silently resolved to `pty`, both in the
request body (_resolve_transport fell through to a default) and via the
STUDYLOOP_TRANSPORT env-var kill switch. A caller asking for a transport
that no longer exists got a different one with no error, which is worse
than a clean rejection: it hides the fact that the thing asked for is gone.

`StartSessionRequest.transport` is now `Literal["pty", "acp"] | None`, so
Pydantic itself 422s an unrecognised body value before the handler runs.
`_resolve_transport()` raises the new `UnsupportedTransportError` for any
non-empty `STUDYLOOP_TRANSPORT` other than "pty"; `start_session()` catches
it and returns 422. Both paths are fixed in this one commit, per
REMEDIATION-PLAN's Gate 1 amendment #2, with a mandatory test for each:
`{"transport": "ttyd"}` -> 422, and `STUDYLOOP_TRANSPORT=ttyd` -> 422 (not
silently pty).

Deletes `_start_ttyd_session()` and `_ttyd_credentials()` (_start.py) — the
dispatcher's fallback branch that reached the legacy tmux+ttyd path is gone
along with them. `app.state.lan_username`/`lan_password` (web/app.py) are
removed too: `_ttyd_credentials()` was their only reader, and the plan's own
instruction is to leave no false authority markers rather than keep unread
state whose documented purpose no longer applies. Real LAN Basic-Auth is
unaffected — `create_app()` still wires `BasicAuthMiddleware` directly from
its username/password parameters, which never went through app.state.

Test reconciliation, beyond the manifest's literal line numbers where its
granularity didn't reach: TestLanCredentialsOnAppState (test_lan_auth.py,
5 tests) is deleted as one unit — its whole premise (app.state as the
ttyd/app auth divergence guard) is gone; test_start_rejects_no_tmux
(test_web_session.py) is deleted since no surviving transport touches tmux,
so its 503-on-no-multiplexer assertion has no home. Full ledger and
rationale for each in evidence/M1/stage-3/04-manifest.md. Pass count 3875 ->
3870, exactly at the strict-manifest floor (3877 - 4 - 3 = 3870).

Docs describing the ttyd server transport as still available to maintainers
(system-overview.md, architecture/current.md:149, cli-reference.md,
troubleshooting.md) are now stale and deliberately left for stage 7 per
PLAN-retire-ttyd.md's cut-point rule ("not shippable in doc terms mid-flight
... never cut 0.1.0 mid-flight").

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
NetDevAutomate added a commit that referenced this pull request Sep 3, 2026
The encrypted secrets store (secrets.py, commit fd294f4) was a sealed vault:
nothing read from it at generation time and the provider dropdown ignored it.
A key added via the web store had zero effect. This wires both ends.

- anthropic_compat / openai_compat _read_api_key: call secrets.get_secret(slug)
  (resolves store -> env -> None) instead of os.environ only. A key stored via
  the Generate panel is now actually consumed. Error message points users at
  the panel as well as the env var. Dropped the now-unused os import.
- web /content/providers: 'available' flag is now get_secret(slug) OR env var,
  so a provider with a stored-but-not-exported key shows as enabled, not greyed
  out. Bedrock's separate boto3-credential path is untouched.

TDD: 3 new tests (stored-key consumed at construction; missing-everywhere error
names both paths; provider available from store). Red confirmed before fix.
36/36 adapter+providers suites green; 66 secrets/route/content-gen regression
tests pass.

Remaining for the full feature: the Generate-panel key-entry UI (#2) — backend
secrets routes exist + tested, frontend does not call them yet.
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
The panel works live; the tests' locators were wrong. Each provider row renders
ALL THREE auth-kind control divs (api_key / bedrock_bearer / local_keyless),
x-show-hidden but present in the DOM, and every row has its own .key-ok /
.key-error status spans. So:
- unscoped input[type=password] / "Test & save" matched a visible control AND a
  hidden one -> strict-mode violation;
- page.wait_for_selector(".provider-row .key-ok") matched all 3 rows and waited
  on the first (openai's), which never becomes visible -> timeout.

Fix (selectors only, panel untouched, per handoff):
- scope inputs/buttons with :visible;
- assert "no VISIBLE password input" for Ollama (hidden ones legitimately exist);
- wait on the acted-on row's own .key-ok/.key-error via row.locator(...).wait_for.

All 8 settings-panel e2e tests pass (was 6 failed / 2 passed).
NetDevAutomate added a commit that referenced this pull request Sep 3, 2026
…-side ACP guard (P0)

Audit P0 #1 + #2. The user-controlled topic became a session-dir path
segment via a naive .replace(' ','-') slug in FOUR places, none of which
stripped '/', '\\' or '..' — and the dir is later rmtree'd on failure,
so a topic like '../../x' was a real escape+delete vector.

- New slug_session_dir() collapses everything outside [a-z0-9] to '-' and
  falls back to 'session' when empty; session_dir_name() routes through it.
- Replace the three inline duplicate slugs (web PTY/ttyd _start.py, CLI
  session/start.py) with the shared helper — root-cause fix, one segment.
- Add server-side ACP capability guard in _start_acp_session: a PTY-only
  agent (Claude Code, Codex) requesting transport=acp now gets a 400 with
  cause + repair BEFORE any spawn, instead of an opaque failure.
- ACP_CAPABLE_AGENTS is the single source of truth; _options.py's picker
  flags now reference it instead of a duplicated {kiro,gemini,grok} literal.

Tests: parametrized traversal cases + empty-slug fallback + ACP-guard
rejection for claude/codex + capable-set lock. Held-out gate: the traversal
tests fail on the pre-fix slug (verified by revert), pass on the fix.
NetDevAutomate added a commit that referenced this pull request Sep 3, 2026
… stage 3/7)

Closes R-05. Before this stage, an unrecognised value for `transport` —
including the retired `ttyd` — silently resolved to `pty`, both in the
request body (_resolve_transport fell through to a default) and via the
STUDYLOOP_TRANSPORT env-var kill switch. A caller asking for a transport
that no longer exists got a different one with no error, which is worse
than a clean rejection: it hides the fact that the thing asked for is gone.

`StartSessionRequest.transport` is now `Literal["pty", "acp"] | None`, so
Pydantic itself 422s an unrecognised body value before the handler runs.
`_resolve_transport()` raises the new `UnsupportedTransportError` for any
non-empty `STUDYLOOP_TRANSPORT` other than "pty"; `start_session()` catches
it and returns 422. Both paths are fixed in this one commit, per
REMEDIATION-PLAN's Gate 1 amendment #2, with a mandatory test for each:
`{"transport": "ttyd"}` -> 422, and `STUDYLOOP_TRANSPORT=ttyd` -> 422 (not
silently pty).

Deletes `_start_ttyd_session()` and `_ttyd_credentials()` (_start.py) — the
dispatcher's fallback branch that reached the legacy tmux+ttyd path is gone
along with them. `app.state.lan_username`/`lan_password` (web/app.py) are
removed too: `_ttyd_credentials()` was their only reader, and the plan's own
instruction is to leave no false authority markers rather than keep unread
state whose documented purpose no longer applies. Real LAN Basic-Auth is
unaffected — `create_app()` still wires `BasicAuthMiddleware` directly from
its username/password parameters, which never went through app.state.

Test reconciliation, beyond the manifest's literal line numbers where its
granularity didn't reach: TestLanCredentialsOnAppState (test_lan_auth.py,
5 tests) is deleted as one unit — its whole premise (app.state as the
ttyd/app auth divergence guard) is gone; test_start_rejects_no_tmux
(test_web_session.py) is deleted since no surviving transport touches tmux,
so its 503-on-no-multiplexer assertion has no home. Full ledger and
rationale for each in evidence/M1/stage-3/04-manifest.md. Pass count 3875 ->
3870, exactly at the strict-manifest floor (3877 - 4 - 3 = 3870).

Docs describing the ttyd server transport as still available to maintainers
(system-overview.md, architecture/current.md:149, cli-reference.md,
troubleshooting.md) are now stale and deliberately left for stage 7 per
PLAN-retire-ttyd.md's cut-point rule ("not shippable in doc terms mid-flight
... never cut 0.1.0 mid-flight").

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
NetDevAutomate added a commit that referenced this pull request Sep 3, 2026
The encrypted secrets store (secrets.py, commit 4e7207a) was a sealed vault:
nothing read from it at generation time and the provider dropdown ignored it.
A key added via the web store had zero effect. This wires both ends.

- anthropic_compat / openai_compat _read_api_key: call secrets.get_secret(slug)
  (resolves store -> env -> None) instead of os.environ only. A key stored via
  the Generate panel is now actually consumed. Error message points users at
  the panel as well as the env var. Dropped the now-unused os import.
- web /content/providers: 'available' flag is now get_secret(slug) OR env var,
  so a provider with a stored-but-not-exported key shows as enabled, not greyed
  out. Bedrock's separate boto3-credential path is untouched.

TDD: 3 new tests (stored-key consumed at construction; missing-everywhere error
names both paths; provider available from store). Red confirmed before fix.
36/36 adapter+providers suites green; 66 secrets/route/content-gen regression
tests pass.

Remaining for the full feature: the Generate-panel key-entry UI (#2) — backend
secrets routes exist + tested, frontend does not call them yet.
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
The panel works live; the tests' locators were wrong. Each provider row renders
ALL THREE auth-kind control divs (api_key / bedrock_bearer / local_keyless),
x-show-hidden but present in the DOM, and every row has its own .key-ok /
.key-error status spans. So:
- unscoped input[type=password] / "Test & save" matched a visible control AND a
  hidden one -> strict-mode violation;
- page.wait_for_selector(".provider-row .key-ok") matched all 3 rows and waited
  on the first (openai's), which never becomes visible -> timeout.

Fix (selectors only, panel untouched, per handoff):
- scope inputs/buttons with :visible;
- assert "no VISIBLE password input" for Ollama (hidden ones legitimately exist);
- wait on the acted-on row's own .key-ok/.key-error via row.locator(...).wait_for.

All 8 settings-panel e2e tests pass (was 6 failed / 2 passed).
NetDevAutomate added a commit that referenced this pull request Sep 3, 2026
…-side ACP guard (P0)

Audit P0 #1 + #2. The user-controlled topic became a session-dir path
segment via a naive .replace(' ','-') slug in FOUR places, none of which
stripped '/', '\\' or '..' — and the dir is later rmtree'd on failure,
so a topic like '../../x' was a real escape+delete vector.

- New slug_session_dir() collapses everything outside [a-z0-9] to '-' and
  falls back to 'session' when empty; session_dir_name() routes through it.
- Replace the three inline duplicate slugs (web PTY/ttyd _start.py, CLI
  session/start.py) with the shared helper — root-cause fix, one segment.
- Add server-side ACP capability guard in _start_acp_session: a PTY-only
  agent (Claude Code, Codex) requesting transport=acp now gets a 400 with
  cause + repair BEFORE any spawn, instead of an opaque failure.
- ACP_CAPABLE_AGENTS is the single source of truth; _options.py's picker
  flags now reference it instead of a duplicated {kiro,gemini,grok} literal.

Tests: parametrized traversal cases + empty-slug fallback + ACP-guard
rejection for claude/codex + capable-set lock. Held-out gate: the traversal
tests fail on the pre-fix slug (verified by revert), pass on the fix.
NetDevAutomate added a commit that referenced this pull request Sep 3, 2026
… stage 3/7)

Closes R-05. Before this stage, an unrecognised value for `transport` —
including the retired `ttyd` — silently resolved to `pty`, both in the
request body (_resolve_transport fell through to a default) and via the
STUDYLOOP_TRANSPORT env-var kill switch. A caller asking for a transport
that no longer exists got a different one with no error, which is worse
than a clean rejection: it hides the fact that the thing asked for is gone.

`StartSessionRequest.transport` is now `Literal["pty", "acp"] | None`, so
Pydantic itself 422s an unrecognised body value before the handler runs.
`_resolve_transport()` raises the new `UnsupportedTransportError` for any
non-empty `STUDYLOOP_TRANSPORT` other than "pty"; `start_session()` catches
it and returns 422. Both paths are fixed in this one commit, per
REMEDIATION-PLAN's Gate 1 amendment #2, with a mandatory test for each:
`{"transport": "ttyd"}` -> 422, and `STUDYLOOP_TRANSPORT=ttyd` -> 422 (not
silently pty).

Deletes `_start_ttyd_session()` and `_ttyd_credentials()` (_start.py) — the
dispatcher's fallback branch that reached the legacy tmux+ttyd path is gone
along with them. `app.state.lan_username`/`lan_password` (web/app.py) are
removed too: `_ttyd_credentials()` was their only reader, and the plan's own
instruction is to leave no false authority markers rather than keep unread
state whose documented purpose no longer applies. Real LAN Basic-Auth is
unaffected — `create_app()` still wires `BasicAuthMiddleware` directly from
its username/password parameters, which never went through app.state.

Test reconciliation, beyond the manifest's literal line numbers where its
granularity didn't reach: TestLanCredentialsOnAppState (test_lan_auth.py,
5 tests) is deleted as one unit — its whole premise (app.state as the
ttyd/app auth divergence guard) is gone; test_start_rejects_no_tmux
(test_web_session.py) is deleted since no surviving transport touches tmux,
so its 503-on-no-multiplexer assertion has no home. Full ledger and
rationale for each in evidence/M1/stage-3/04-manifest.md. Pass count 3875 ->
3870, exactly at the strict-manifest floor (3877 - 4 - 3 = 3870).

Docs describing the ttyd server transport as still available to maintainers
(system-overview.md, architecture/current.md:149, cli-reference.md,
troubleshooting.md) are now stale and deliberately left for stage 7 per
PLAN-retire-ttyd.md's cut-point rule ("not shippable in doc terms mid-flight
... never cut 0.1.0 mid-flight").

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
NetDevAutomate added a commit that referenced this pull request Sep 5, 2026
feat: merge study command + E2E experience tests + bridge CLI
NetDevAutomate added a commit that referenced this pull request Sep 5, 2026
The encrypted secrets store (secrets.py, commit f788055) was a sealed vault:
nothing read from it at generation time and the provider dropdown ignored it.
A key added via the web store had zero effect. This wires both ends.

- anthropic_compat / openai_compat _read_api_key: call secrets.get_secret(slug)
  (resolves store -> env -> None) instead of os.environ only. A key stored via
  the Generate panel is now actually consumed. Error message points users at
  the panel as well as the env var. Dropped the now-unused os import.
- web /content/providers: 'available' flag is now get_secret(slug) OR env var,
  so a provider with a stored-but-not-exported key shows as enabled, not greyed
  out. Bedrock's separate boto3-credential path is untouched.

TDD: 3 new tests (stored-key consumed at construction; missing-everywhere error
names both paths; provider available from store). Red confirmed before fix.
36/36 adapter+providers suites green; 66 secrets/route/content-gen regression
tests pass.

Remaining for the full feature: the Generate-panel key-entry UI (#2) — backend
secrets routes exist + tested, frontend does not call them yet.
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 5, 2026
The panel works live; the tests' locators were wrong. Each provider row renders
ALL THREE auth-kind control divs (api_key / bedrock_bearer / local_keyless),
x-show-hidden but present in the DOM, and every row has its own .key-ok /
.key-error status spans. So:
- unscoped input[type=password] / "Test & save" matched a visible control AND a
  hidden one -> strict-mode violation;
- page.wait_for_selector(".provider-row .key-ok") matched all 3 rows and waited
  on the first (openai's), which never becomes visible -> timeout.

Fix (selectors only, panel untouched, per handoff):
- scope inputs/buttons with :visible;
- assert "no VISIBLE password input" for Ollama (hidden ones legitimately exist);
- wait on the acted-on row's own .key-ok/.key-error via row.locator(...).wait_for.

All 8 settings-panel e2e tests pass (was 6 failed / 2 passed).
NetDevAutomate added a commit that referenced this pull request Sep 5, 2026
…-side ACP guard (P0)

Audit P0 #1 + #2. The user-controlled topic became a session-dir path
segment via a naive .replace(' ','-') slug in FOUR places, none of which
stripped '/', '\\' or '..' — and the dir is later rmtree'd on failure,
so a topic like '../../x' was a real escape+delete vector.

- New slug_session_dir() collapses everything outside [a-z0-9] to '-' and
  falls back to 'session' when empty; session_dir_name() routes through it.
- Replace the three inline duplicate slugs (web PTY/ttyd _start.py, CLI
  session/start.py) with the shared helper — root-cause fix, one segment.
- Add server-side ACP capability guard in _start_acp_session: a PTY-only
  agent (Claude Code, Codex) requesting transport=acp now gets a 400 with
  cause + repair BEFORE any spawn, instead of an opaque failure.
- ACP_CAPABLE_AGENTS is the single source of truth; _options.py's picker
  flags now reference it instead of a duplicated {kiro,gemini,grok} literal.

Tests: parametrized traversal cases + empty-slug fallback + ACP-guard
rejection for claude/codex + capable-set lock. Held-out gate: the traversal
tests fail on the pre-fix slug (verified by revert), pass on the fix.
NetDevAutomate added a commit that referenced this pull request Sep 5, 2026
… stage 3/7)

Closes R-05. Before this stage, an unrecognised value for `transport` —
including the retired `ttyd` — silently resolved to `pty`, both in the
request body (_resolve_transport fell through to a default) and via the
STUDYLOOP_TRANSPORT env-var kill switch. A caller asking for a transport
that no longer exists got a different one with no error, which is worse
than a clean rejection: it hides the fact that the thing asked for is gone.

`StartSessionRequest.transport` is now `Literal["pty", "acp"] | None`, so
Pydantic itself 422s an unrecognised body value before the handler runs.
`_resolve_transport()` raises the new `UnsupportedTransportError` for any
non-empty `STUDYLOOP_TRANSPORT` other than "pty"; `start_session()` catches
it and returns 422. Both paths are fixed in this one commit, per
REMEDIATION-PLAN's Gate 1 amendment #2, with a mandatory test for each:
`{"transport": "ttyd"}` -> 422, and `STUDYLOOP_TRANSPORT=ttyd` -> 422 (not
silently pty).

Deletes `_start_ttyd_session()` and `_ttyd_credentials()` (_start.py) — the
dispatcher's fallback branch that reached the legacy tmux+ttyd path is gone
along with them. `app.state.lan_username`/`lan_password` (web/app.py) are
removed too: `_ttyd_credentials()` was their only reader, and the plan's own
instruction is to leave no false authority markers rather than keep unread
state whose documented purpose no longer applies. Real LAN Basic-Auth is
unaffected — `create_app()` still wires `BasicAuthMiddleware` directly from
its username/password parameters, which never went through app.state.

Test reconciliation, beyond the manifest's literal line numbers where its
granularity didn't reach: TestLanCredentialsOnAppState (test_lan_auth.py,
5 tests) is deleted as one unit — its whole premise (app.state as the
ttyd/app auth divergence guard) is gone; test_start_rejects_no_tmux
(test_web_session.py) is deleted since no surviving transport touches tmux,
so its 503-on-no-multiplexer assertion has no home. Full ledger and
rationale for each in evidence/M1/stage-3/04-manifest.md. Pass count 3875 ->
3870, exactly at the strict-manifest floor (3877 - 4 - 3 = 3870).

Docs describing the ttyd server transport as still available to maintainers
(system-overview.md, architecture/current.md:149, cli-reference.md,
troubleshooting.md) are now stale and deliberately left for stage 7 per
PLAN-retire-ttyd.md's cut-point rule ("not shippable in doc terms mid-flight
... never cut 0.1.0 mid-flight").

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.

3 participants