From bbb01d08822c05ba509b844d84e704202c4af718 Mon Sep 17 00:00:00 2001 From: Wyatt Date: Thu, 9 Jul 2026 20:43:23 -0400 Subject: [PATCH] feat(phase-3): web UI served same-origin by the API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a no-build, same-origin web frontend so an operator can open a URL, see every agent's state, and answer a paused question with no terminal (Phase 3 DoD). The UI is a client of the existing API — no endpoint, schema, or auth change — so the 106 existing tests pass unchanged. - app.py serves the bundled UI from / and /static, gated on UI_ENABLED (default on); optional CORS_ORIGINS (default empty => no middleware) for hosting the UI on a separate origin. Dedicated /static prefix + explicit / route so API routes are never shadowed. Zero new runtime deps (StaticFiles/CORSMiddleware ship with Starlette). - static/: vanilla fetch + plain CSS + vendored alpine.min.js (v3.14.8, no CDN). Token captured once into localStorage; all API values render via x-text (never x-html) to block agent-authored markup injection. Project switcher, agent list, checkmark panel, paginated log, shared feed, and Answer / Answer & Resume. Polling scoped to the selected agent to avoid an N+1 over the fleet. - config.py: ui_enabled, cors_origins (+ cors_origin_list); documented in .env.example. - tests/test_api_ui.py: serving, unauthenticated shell, non-shadowing 401 regression, CORS toggle, UI_ENABLED=false. 114 tests, ruff clean. The static assets ship in the wheel by default (they live inside the packaged src/handler tree) — no force-include needed. --- .env.example | 9 + docs/PLAN.md | 12 +- pyproject.toml | 2 + src/handler/api/app.py | 39 +++- src/handler/api/static/alpine.min.js | 8 + src/handler/api/static/app.js | 320 +++++++++++++++++++++++++++ src/handler/api/static/index.html | 205 +++++++++++++++++ src/handler/api/static/styles.css | 179 +++++++++++++++ src/handler/config.py | 13 ++ tests/test_api_ui.py | 99 +++++++++ 10 files changed, 881 insertions(+), 5 deletions(-) create mode 100644 src/handler/api/static/alpine.min.js create mode 100644 src/handler/api/static/app.js create mode 100644 src/handler/api/static/index.html create mode 100644 src/handler/api/static/styles.css create mode 100644 tests/test_api_ui.py diff --git a/.env.example b/.env.example index 7a09b54..bc68e3b 100644 --- a/.env.example +++ b/.env.example @@ -34,6 +34,15 @@ PROJECTS_ROOT=/var/lib/handler/projects # Closes the "merge locally, push to main" path around the forge-merge approval gate. # PROTECTED_BRANCHES=main,master +# Phase 3 (web UI). Serve the bundled UI from "/" and "/static". Set false for a +# headless, API-only deployment. Applied at process start (restart to change). +# UI_ENABLED=true + +# Extra origins allowed to call the API cross-origin (comma-separated). Only needed if +# you host the UI on a DIFFERENT origin than the API; the shipped UI is same-origin and +# needs none. Empty => no CORS middleware. +# CORS_ORIGINS=https://handler.example.ts.net + # Per-project credentials are NOT set here — they live on each project's `credential_ref` # as a POINTER (env:VAR / file:/path / cmd:...), resolved and injected only at spawn. # The database never stores the raw token. Example, when registering a project: diff --git a/docs/PLAN.md b/docs/PLAN.md index d2ee6b7..46f2bae 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -233,10 +233,16 @@ Reviewed by a separate `code-reviewer` pass; findings on gate bypass (local-merg **Definition of done:** an operator registers a project with a `credential_ref`, runs `handler forge-init`, and spawns junior/senior/deploy agents; the junior opens a PR, the senior approves via `handler approve`, and only then can the deploy agent merge — enforced by the gate, not convention — with the push→CI verdict recorded back automatically, all against any forge `forge` supports and with no raw credential ever stored. ### Phase 3 — Production UI -- [ ] Web frontend, API-backed only (same contract as `curl`) -- [ ] Project switcher, agent list per project, live checkmark view, log history, "answer this question" form, plus a view for the shared/global feed +- [x] Web frontend, API-backed only (same contract as `curl`) — a no-build, same-origin static UI (vanilla `fetch` + plain CSS + one vendored `alpine.min.js`, no npm/bundler) served by FastAPI itself from `/` and `/static`. Zero new Python runtime deps (`StaticFiles`/`CORSMiddleware` ship with Starlette). Serving is additive and gated on `UI_ENABLED` (default on); an optional `CORS_ORIGINS` (default empty → no middleware) supports hosting the UI on a separate origin. +- [x] Project switcher, agent list per project (live status badges), live checkmark view, paginated log history, "Answer" / "Answer & Resume" form for paused agents, and a shared/global feed + shared-context view. Polling is scoped to the selected agent (~3 requests/tick) to avoid an N+1 over the fleet. -**Definition of done:** open a URL, see every agent's state, answer a paused question, no terminal required. +**Definition of done:** open a URL, see every agent's state, answer a paused question, no terminal required. **Met.** + +The static shell is served **unauthenticated** (it holds no data); the browser prompts for the `AUTH_TOKEN` once, stores it in `localStorage`, and attaches it to every API call. All API values render via Alpine `x-text` (never `x-html`) so agent-authored strings can't inject markup. New tests in `tests/test_api_ui.py` lock the serving, the unauthenticated shell, a **non-shadowing** regression (`/projects` still 401s without auth with the UI mounted), the CORS toggle, and `UI_ENABLED=false`. 114 tests, ruff clean. + +**Acceptance script (manual e2e):** seed a project + an agent driven to `paused_for_input` with an `open_question`; run `uvicorn handler.api.app:app --port 8000` with `AUTH_TOKEN` set. (1) Open `http://localhost:8000/` → shell loads with no token, shows the modal. (2) Paste the token → the project switcher lists the project. (3) Select the project → the agent shows an amber `paused_for_input` badge. (4) Select the agent → checkmark panel (where_it_stopped, next_steps, open_question, tests/build gate badges, timestamps) and newest-first log with Prev/Next. (5) Type a reply → **Answer & Resume** → the log gains the answer and the badge flips to blue `working` within one poll tick. (6) Enter a bad token → the next call 401s → the app clears the token and re-prompts. (7) Open the **Shared** tab → the global feed and shared-context render. The backend half of this flow (every endpoint the UI calls, including the unauthenticated shell, the 401 gate, and the `/answer` backfill) is verified end-to-end against a live uvicorn. + +**Out of scope (additive follow-ups):** an aggregate `GET /projects/{project}/overview` (agents + latest checkmark in one call) to show every agent's checkmark at once; spawning agents / registering projects from the UI (still CLI-driven); shared-context **writes** from the UI (would need the shared-write token — MVP is read-only). ### Phase 4 — Observability (moved back, now optional) - [ ] Prometheus metrics endpoint on the API (agent counts, pending questions, checkpoint rate) diff --git a/pyproject.toml b/pyproject.toml index 5c5bc3b..42edbe6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,8 @@ dev = [ handler = "handler.control.cli:main" [tool.hatch.build.targets.wheel] +# The bundled web UI (src/handler/api/static/*) ships automatically: it lives inside the +# packaged `src/handler` tree, and hatchling includes non-.py files there by default. packages = ["src/handler"] [tool.pytest.ini_options] diff --git a/src/handler/api/app.py b/src/handler/api/app.py index fd19174..a75a55d 100644 --- a/src/handler/api/app.py +++ b/src/handler/api/app.py @@ -1,17 +1,28 @@ """FastAPI application factory. Run with: ``uvicorn handler.api.app:create_app --factory``. The UI and any future -integration are just clients of this — same contract as ``curl``. +integration are just clients of this — same contract as ``curl``. When ``ui_enabled`` +(the default) the bundled web UI (Phase 3) is served same-origin from ``/`` and +``/static``; the shell is a client of the very same API, so no contract changes. """ from __future__ import annotations -from fastapi import FastAPI +from pathlib import Path +from fastapi import FastAPI +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles + +from ..config import get_settings from .routes import agents, interaction, projects, shared +_STATIC_DIR = Path(__file__).parent / "static" + def create_app() -> FastAPI: + settings = get_settings() + app = FastAPI( title="Handler API", version="0.1.0", @@ -26,6 +37,30 @@ def create_app() -> FastAPI: app.include_router(agents.router) app.include_router(interaction.router) app.include_router(shared.router) + + # Optional CORS, only for operators who host the UI on a different origin than the + # API. Empty CORS_ORIGINS => middleware never added => behaviour identical to headless. + if settings.cors_origin_list: + from fastapi.middleware.cors import CORSMiddleware + + app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origin_list, + allow_methods=["GET", "POST", "PUT"], + allow_headers=["Authorization", "Content-Type"], + ) + + # Serve the bundled UI same-origin. A dedicated "/static" prefix + an explicit "/" + # route (never a "/"-mounted catch-all) so the API routes above can't be shadowed. + # The shell holds no data and is served unauthenticated; all data comes from the + # authed API calls the browser makes after the operator supplies the bearer token. + if settings.ui_enabled and _STATIC_DIR.is_dir(): + app.mount("/static", StaticFiles(directory=_STATIC_DIR), name="static") + + @app.get("/", include_in_schema=False) + def index() -> FileResponse: + return FileResponse(_STATIC_DIR / "index.html") + return app diff --git a/src/handler/api/static/alpine.min.js b/src/handler/api/static/alpine.min.js new file mode 100644 index 0000000..42e438a --- /dev/null +++ b/src/handler/api/static/alpine.min.js @@ -0,0 +1,8 @@ +/*! Alpine.js v3.14.8 — vendored (no CDN, offline/portable). MIT License. + * Source: https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js + * Do not edit; re-vendor by re-downloading the same pinned version. */ +(()=>{var nt=!1,it=!1,W=[],ot=-1;function Ut(e){Rn(e)}function Rn(e){W.includes(e)||W.push(e),Mn()}function Wt(e){let t=W.indexOf(e);t!==-1&&t>ot&&W.splice(t,1)}function Mn(){!it&&!nt&&(nt=!0,queueMicrotask(Nn))}function Nn(){nt=!1,it=!0;for(let e=0;ee.effect(t,{scheduler:r=>{st?Ut(r):r()}}),at=e.raw}function ct(e){N=e}function Yt(e){let t=()=>{};return[n=>{let i=N(n);return e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(o=>o())}),e._x_effects.add(i),t=()=>{i!==void 0&&(e._x_effects.delete(i),$(i))},i},()=>{t()}]}function ve(e,t){let r=!0,n,i=N(()=>{let o=e();JSON.stringify(o),r?n=o:queueMicrotask(()=>{t(o,n),n=o}),r=!1});return()=>$(i)}var Xt=[],Zt=[],Qt=[];function er(e){Qt.push(e)}function te(e,t){typeof t=="function"?(e._x_cleanups||(e._x_cleanups=[]),e._x_cleanups.push(t)):(t=e,Zt.push(t))}function Ae(e){Xt.push(e)}function Oe(e,t,r){e._x_attributeCleanups||(e._x_attributeCleanups={}),e._x_attributeCleanups[t]||(e._x_attributeCleanups[t]=[]),e._x_attributeCleanups[t].push(r)}function lt(e,t){e._x_attributeCleanups&&Object.entries(e._x_attributeCleanups).forEach(([r,n])=>{(t===void 0||t.includes(r))&&(n.forEach(i=>i()),delete e._x_attributeCleanups[r])})}function tr(e){for(e._x_effects?.forEach(Wt);e._x_cleanups?.length;)e._x_cleanups.pop()()}var ut=new MutationObserver(mt),ft=!1;function ue(){ut.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),ft=!0}function dt(){kn(),ut.disconnect(),ft=!1}var le=[];function kn(){let e=ut.takeRecords();le.push(()=>e.length>0&&mt(e));let t=le.length;queueMicrotask(()=>{if(le.length===t)for(;le.length>0;)le.shift()()})}function m(e){if(!ft)return e();dt();let t=e();return ue(),t}var pt=!1,Se=[];function rr(){pt=!0}function nr(){pt=!1,mt(Se),Se=[]}function mt(e){if(pt){Se=Se.concat(e);return}let t=[],r=new Set,n=new Map,i=new Map;for(let o=0;o{s.nodeType===1&&s._x_marker&&r.add(s)}),e[o].addedNodes.forEach(s=>{if(s.nodeType===1){if(r.has(s)){r.delete(s);return}s._x_marker||t.push(s)}})),e[o].type==="attributes")){let s=e[o].target,a=e[o].attributeName,c=e[o].oldValue,l=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},u=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?l():s.hasAttribute(a)?(u(),l()):u()}i.forEach((o,s)=>{lt(s,o)}),n.forEach((o,s)=>{Xt.forEach(a=>a(s,o))});for(let o of r)t.some(s=>s.contains(o))||Zt.forEach(s=>s(o));for(let o of t)o.isConnected&&Qt.forEach(s=>s(o));t=null,r=null,n=null,i=null}function Ce(e){return z(B(e))}function k(e,t,r){return e._x_dataStack=[t,...B(r||e)],()=>{e._x_dataStack=e._x_dataStack.filter(n=>n!==t)}}function B(e){return e._x_dataStack?e._x_dataStack:typeof ShadowRoot=="function"&&e instanceof ShadowRoot?B(e.host):e.parentNode?B(e.parentNode):[]}function z(e){return new Proxy({objects:e},Dn)}var Dn={ownKeys({objects:e}){return Array.from(new Set(e.flatMap(t=>Object.keys(t))))},has({objects:e},t){return t==Symbol.unscopables?!1:e.some(r=>Object.prototype.hasOwnProperty.call(r,t)||Reflect.has(r,t))},get({objects:e},t,r){return t=="toJSON"?Pn:Reflect.get(e.find(n=>Reflect.has(n,t))||{},t,r)},set({objects:e},t,r,n){let i=e.find(s=>Object.prototype.hasOwnProperty.call(s,t))||e[e.length-1],o=Object.getOwnPropertyDescriptor(i,t);return o?.set&&o?.get?o.set.call(n,r)||!0:Reflect.set(i,t,r)}};function Pn(){return Reflect.ownKeys(this).reduce((t,r)=>(t[r]=Reflect.get(this,r),t),{})}function Te(e){let t=n=>typeof n=="object"&&!Array.isArray(n)&&n!==null,r=(n,i="")=>{Object.entries(Object.getOwnPropertyDescriptors(n)).forEach(([o,{value:s,enumerable:a}])=>{if(a===!1||s===void 0||typeof s=="object"&&s!==null&&s.__v_skip)return;let c=i===""?o:`${i}.${o}`;typeof s=="object"&&s!==null&&s._x_interceptor?n[o]=s.initialize(e,c,o):t(s)&&s!==n&&!(s instanceof Element)&&r(s,c)})};return r(e)}function Re(e,t=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o){return e(this.initialValue,()=>In(n,i),s=>ht(n,i,s),i,o)}};return t(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a)=>{let c=n.initialize(o,s,a);return r.initialValue=c,i(o,s,a)}}else r.initialValue=n;return r}}function In(e,t){return t.split(".").reduce((r,n)=>r[n],e)}function ht(e,t,r){if(typeof t=="string"&&(t=t.split(".")),t.length===1)e[t[0]]=r;else{if(t.length===0)throw error;return e[t[0]]||(e[t[0]]={}),ht(e[t[0]],t.slice(1),r)}}var ir={};function y(e,t){ir[e]=t}function fe(e,t){let r=Ln(t);return Object.entries(ir).forEach(([n,i])=>{Object.defineProperty(e,`$${n}`,{get(){return i(t,r)},enumerable:!1})}),e}function Ln(e){let[t,r]=_t(e),n={interceptor:Re,...t};return te(e,r),n}function or(e,t,r,...n){try{return r(...n)}catch(i){re(i,e,t)}}function re(e,t,r=void 0){e=Object.assign(e??{message:"No error message given."},{el:t,expression:r}),console.warn(`Alpine Expression Error: ${e.message} + +${r?'Expression: "'+r+`" + +`:""}`,t),setTimeout(()=>{throw e},0)}var Me=!0;function ke(e){let t=Me;Me=!1;let r=e();return Me=t,r}function R(e,t,r={}){let n;return x(e,t)(i=>n=i,r),n}function x(...e){return sr(...e)}var sr=xt;function ar(e){sr=e}function xt(e,t){let r={};fe(r,e);let n=[r,...B(e)],i=typeof t=="function"?$n(n,t):Fn(n,t,e);return or.bind(null,e,t,i)}function $n(e,t){return(r=()=>{},{scope:n={},params:i=[]}={})=>{let o=t.apply(z([n,...e]),i);Ne(r,o)}}var gt={};function jn(e,t){if(gt[e])return gt[e];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(async()=>{ ${e} })()`:e,o=(()=>{try{let s=new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`);return Object.defineProperty(s,"name",{value:`[Alpine] ${e}`}),s}catch(s){return re(s,t,e),Promise.resolve()}})();return gt[e]=o,o}function Fn(e,t,r){let n=jn(t,r);return(i=()=>{},{scope:o={},params:s=[]}={})=>{n.result=void 0,n.finished=!1;let a=z([o,...e]);if(typeof n=="function"){let c=n(n,a).catch(l=>re(l,r,t));n.finished?(Ne(i,n.result,a,s,r),n.result=void 0):c.then(l=>{Ne(i,l,a,s,r)}).catch(l=>re(l,r,t)).finally(()=>n.result=void 0)}}}function Ne(e,t,r,n,i){if(Me&&typeof t=="function"){let o=t.apply(r,n);o instanceof Promise?o.then(s=>Ne(e,s,r,n)).catch(s=>re(s,i,t)):e(o)}else typeof t=="object"&&t instanceof Promise?t.then(o=>e(o)):e(t)}var wt="x-";function C(e=""){return wt+e}function cr(e){wt=e}var De={};function d(e,t){return De[e]=t,{before(r){if(!De[r]){console.warn(String.raw`Cannot find directive \`${r}\`. \`${e}\` will use the default order of execution`);return}let n=G.indexOf(r);G.splice(n>=0?n:G.indexOf("DEFAULT"),0,e)}}}function lr(e){return Object.keys(De).includes(e)}function pe(e,t,r){if(t=Array.from(t),e._x_virtualDirectives){let o=Object.entries(e._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=Et(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),t=t.concat(o)}let n={};return t.map(dr((o,s)=>n[o]=s)).filter(mr).map(zn(n,r)).sort(Kn).map(o=>Bn(e,o))}function Et(e){return Array.from(e).map(dr()).filter(t=>!mr(t))}var yt=!1,de=new Map,ur=Symbol();function fr(e){yt=!0;let t=Symbol();ur=t,de.set(t,[]);let r=()=>{for(;de.get(t).length;)de.get(t).shift()();de.delete(t)},n=()=>{yt=!1,r()};e(r),n()}function _t(e){let t=[],r=a=>t.push(a),[n,i]=Yt(e);return t.push(i),[{Alpine:K,effect:n,cleanup:r,evaluateLater:x.bind(x,e),evaluate:R.bind(R,e)},()=>t.forEach(a=>a())]}function Bn(e,t){let r=()=>{},n=De[t.type]||r,[i,o]=_t(e);Oe(e,t.original,o);let s=()=>{e._x_ignore||e._x_ignoreSelf||(n.inline&&n.inline(e,t,i),n=n.bind(n,e,t,i),yt?de.get(ur).push(n):n())};return s.runCleanups=o,s}var Pe=(e,t)=>({name:r,value:n})=>(r.startsWith(e)&&(r=r.replace(e,t)),{name:r,value:n}),Ie=e=>e;function dr(e=()=>{}){return({name:t,value:r})=>{let{name:n,value:i}=pr.reduce((o,s)=>s(o),{name:t,value:r});return n!==t&&e(n,t),{name:n,value:i}}}var pr=[];function ne(e){pr.push(e)}function mr({name:e}){return hr().test(e)}var hr=()=>new RegExp(`^${wt}([^:^.]+)\\b`);function zn(e,t){return({name:r,value:n})=>{let i=r.match(hr()),o=r.match(/:([a-zA-Z0-9\-_:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=t||e[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var bt="DEFAULT",G=["ignore","ref","data","id","anchor","bind","init","for","model","modelable","transition","show","if",bt,"teleport"];function Kn(e,t){let r=G.indexOf(e.type)===-1?bt:e.type,n=G.indexOf(t.type)===-1?bt:t.type;return G.indexOf(r)-G.indexOf(n)}function J(e,t,r={}){e.dispatchEvent(new CustomEvent(t,{detail:r,bubbles:!0,composed:!0,cancelable:!0}))}function D(e,t){if(typeof ShadowRoot=="function"&&e instanceof ShadowRoot){Array.from(e.children).forEach(i=>D(i,t));return}let r=!1;if(t(e,()=>r=!0),r)return;let n=e.firstElementChild;for(;n;)D(n,t,!1),n=n.nextElementSibling}function E(e,...t){console.warn(`Alpine Warning: ${e}`,...t)}var _r=!1;function gr(){_r&&E("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),_r=!0,document.body||E("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's ` + + + + + + + + + + + diff --git a/src/handler/api/static/styles.css b/src/handler/api/static/styles.css new file mode 100644 index 0000000..329192f --- /dev/null +++ b/src/handler/api/static/styles.css @@ -0,0 +1,179 @@ +/* Handler web UI — plain CSS, no preprocessor. */ + +:root { + --bg: #0f1419; + --panel: #1a2029; + --card: #212936; + --border: #2d3644; + --text: #e6e9ee; + --muted: #8b96a5; + --accent: #4c8dff; + --blue: #4c8dff; + --green: #35c26a; + --amber: #e8a33d; + --red: #e5484d; + --grey: #5a6572; +} + +[x-cloak] { display: none !important; } + +* { box-sizing: border-box; } + +body { + margin: 0; + font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + background: var(--bg); + color: var(--text); +} + +h2, h3, h4 { margin: 0 0 0.5rem; font-weight: 600; } +h3 { font-size: 1rem; } +h4 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.03em; color: var(--muted); } + +.muted { color: var(--muted); } +.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.85em; } +.nowrap { white-space: nowrap; } +.error { color: var(--red); } + +button { + background: var(--card); + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.4rem 0.8rem; + cursor: pointer; + font: inherit; +} +button:hover:not(:disabled) { border-color: var(--accent); } +button:disabled { opacity: 0.4; cursor: not-allowed; } +button.primary { background: var(--accent); border-color: var(--accent); color: #fff; } +button.ghost { background: transparent; } + +input, textarea, select { + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.4rem 0.6rem; + font: inherit; + width: 100%; +} +textarea { resize: vertical; } + +/* --- token modal --- */ +.modal-backdrop { + position: fixed; inset: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; align-items: center; justify-content: center; + z-index: 50; +} +.modal { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + padding: 1.5rem; + width: min(360px, 90vw); + display: flex; flex-direction: column; gap: 0.75rem; +} + +/* --- topbar --- */ +.topbar { + display: flex; align-items: center; justify-content: space-between; + gap: 1rem; flex-wrap: wrap; + padding: 0.75rem 1rem; + background: var(--panel); + border-bottom: 1px solid var(--border); +} +.brand { font-weight: 700; font-size: 1.05rem; } +.topbar-controls { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; } +.tabs { display: flex; gap: 0.25rem; } +.tabs button.active { background: var(--accent); border-color: var(--accent); color: #fff; } + +.banner { margin: 0; padding: 0.5rem 1rem; background: rgba(229,72,77,0.12); } + +/* --- layout --- */ +.layout { + display: grid; + grid-template-columns: 300px 1fr; + gap: 1rem; + padding: 1rem; + align-items: start; +} +@media (max-width: 780px) { .layout { grid-template-columns: 1fr; } } + +.panel { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + padding: 1rem; +} + +/* --- agent list --- */ +.agent-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.25rem; } +.agent-list li { + display: flex; align-items: center; gap: 0.5rem; + padding: 0.5rem 0.6rem; + border: 1px solid transparent; + border-radius: 6px; + cursor: pointer; +} +.agent-list li:hover { background: var(--card); } +.agent-list li.selected { background: var(--card); border-color: var(--accent); } +.agent-name { flex: 1; font-weight: 500; } +.needs-answer { font-size: 0.7rem; color: var(--amber); text-transform: uppercase; letter-spacing: 0.03em; } + +/* --- detail cards --- */ +.detail { display: flex; flex-direction: column; gap: 1rem; } +.card { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 1rem; } + +.kv { display: grid; grid-template-columns: 130px 1fr; gap: 0.4rem 1rem; margin: 0; } +.kv dt { color: var(--muted); } +.kv dd { margin: 0; } +.next-steps { margin: 0; padding-left: 1.1rem; } + +.answer .question { background: var(--bg); border-left: 3px solid var(--amber); padding: 0.5rem 0.75rem; border-radius: 4px; } +.answer-actions { display: flex; gap: 0.5rem; margin-top: 0.6rem; } +.answer-msg { margin: 0.5rem 0 0; color: var(--green); } +.answer-msg.error { color: var(--red); } + +/* --- tables --- */ +.table-wrap { overflow-x: auto; } +table.log { width: 100%; border-collapse: collapse; } +table.log th, table.log td { + text-align: left; padding: 0.4rem 0.6rem; + border-bottom: 1px solid var(--border); + vertical-align: top; +} +table.log th { color: var(--muted); font-weight: 500; font-size: 0.8rem; } + +.pager { display: flex; align-items: center; gap: 0.75rem; margin-top: 0.6rem; } + +/* --- badges --- */ +.badge { + display: inline-block; + padding: 0.1rem 0.5rem; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; + border: 1px solid transparent; + white-space: nowrap; +} +/* fallback for unknown/new vocabulary values */ +.badge { background: rgba(90,101,114,0.2); color: var(--grey); } + +.badge-status-working { background: rgba(76,141,255,0.18); color: var(--blue); } +.badge-status-paused_for_input { background: rgba(232,163,61,0.18); color: var(--amber); } +.badge-status-blocked { background: rgba(229,72,77,0.18); color: var(--red); } +.badge-status-done { background: rgba(53,194,106,0.18); color: var(--green); } + +.badge-gate-pass { background: rgba(53,194,106,0.18); color: var(--green); } +.badge-gate-fail { background: rgba(229,72,77,0.18); color: var(--red); } +.badge-gate-unknown { background: rgba(90,101,114,0.2); color: var(--grey); } + +.badge-ci-not_applicable { background: rgba(90,101,114,0.2); color: var(--grey); } +.badge-ci-pending { background: rgba(232,163,61,0.18); color: var(--amber); } +.badge-ci-pass { background: rgba(53,194,106,0.18); color: var(--green); } +.badge-ci-fail { background: rgba(229,72,77,0.18); color: var(--red); } + +.badge-visibility-project { background: rgba(90,101,114,0.2); color: var(--grey); } +.badge-visibility-global { background: transparent; color: var(--blue); border-color: var(--blue); } diff --git a/src/handler/config.py b/src/handler/config.py index eaa763d..dc65230 100644 --- a/src/handler/config.py +++ b/src/handler/config.py @@ -48,10 +48,23 @@ class Settings(BaseSettings): # the "merge locally, push to main" path around the forge-merge approval gate. protected_branches: str = "main,master" + # Serve the bundled web UI (Phase 3) from "/" and "/static". Off => headless, API-only + # deployment (the API contract is identical either way). + ui_enabled: bool = True + + # Optional extra origins allowed to call the API cross-origin, for operators who host the + # UI on a different origin than the API. Empty => no CORS middleware, same-origin only + # (the shipped UI is same-origin and needs none). Comma-separated. + cors_origins: str = "" + @property def protected_branch_set(self) -> set[str]: return {b.strip() for b in self.protected_branches.split(",") if b.strip()} + @property + def cors_origin_list(self) -> list[str]: + return [o.strip() for o in self.cors_origins.split(",") if o.strip()] + @property def effective_shared_write_token(self) -> str: """Token required to write shared_context; defaults to the global token.""" diff --git a/tests/test_api_ui.py b/tests/test_api_ui.py new file mode 100644 index 0000000..039690f --- /dev/null +++ b/tests/test_api_ui.py @@ -0,0 +1,99 @@ +"""Phase 3 UI serving: the bundled web UI is served same-origin and, critically, is +*additive* — it must not shadow any existing API route, and both the toggle (UI_ENABLED) +and the optional CORS behave as documented. The frontend JS itself has no test runner +(by design) and is verified via the manual e2e walkthrough in docs/PLAN.md. +""" + +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + + +def _reset_caches() -> None: + from handler import config + from handler.db import engine + + config.get_settings.cache_clear() + engine.get_engine.cache_clear() + + +def _fresh_client(monkeypatch, **overrides) -> TestClient: + """Build an app after applying env overrides — the shared `client` fixture bakes in + defaults, so toggle tests need their own app constructed post-setenv.""" + for key, value in overrides.items(): + monkeypatch.setenv(key, value) + _reset_caches() + from handler.api.app import create_app + + return TestClient(create_app()) + + +# --- shell + assets are served, unauthenticated ------------------------------------- + + +def test_index_served_unauthenticated(client): + res = client.get("/") # no Authorization header + assert res.status_code == 200 + assert res.headers["content-type"].startswith("text/html") + assert "Handler" in res.text + # the shell must never inline data or a token + assert "Bearer" not in res.text + + +@pytest.mark.parametrize( + "path, marker", + [ + ("/static/app.js", "function app("), + ("/static/styles.css", ".badge"), + ("/static/alpine.min.js", "Alpine.js"), + ], +) +def test_static_assets_served_unauthenticated(client, path, marker): + res = client.get(path) # no auth + assert res.status_code == 200 + assert marker in res.text + + +# --- the static surface must NOT shadow the API ------------------------------------ + + +def test_api_routes_not_shadowed(client, auth): + # /health still open + assert client.get("/health").json() == {"status": "ok"} + # /projects still requires auth (the static mount didn't swallow it) + assert client.get("/projects").status_code == 401 + res = client.get("/projects", headers=auth) + assert res.status_code == 200 + assert res.json() == [] + # "/" is an explicit route, not a catch-all: unknown paths still 404 + assert client.get("/does-not-exist").status_code == 404 + + +# --- CORS: off by default, on when configured -------------------------------------- + + +def test_cors_absent_by_default(client): + res = client.get("/health", headers={"Origin": "https://example.com"}) + assert res.status_code == 200 + assert "access-control-allow-origin" not in {k.lower() for k in res.headers} + + +def test_cors_present_when_configured(env, monkeypatch): + origin = "https://handler.example.ts.net" + client = _fresh_client(monkeypatch, CORS_ORIGINS=origin) + res = client.get("/health", headers={"Origin": origin}) + assert res.status_code == 200 + assert res.headers.get("access-control-allow-origin") == origin + + +# --- UI_ENABLED=false => headless, API intact -------------------------------------- + + +def test_ui_disabled_serves_no_shell_but_api_works(env, monkeypatch, auth): + client = _fresh_client(monkeypatch, UI_ENABLED="false") + assert client.get("/").status_code == 404 + assert client.get("/static/app.js").status_code == 404 + # API is untouched + assert client.get("/health").status_code == 200 + assert client.get("/projects", headers=auth).status_code == 200