feat(api): server-rendered Jinja2 Runtime console at /ui/
Replaces the separate Vue/Vite `console/` SPA with a same-origin, server-rendered console built on a module-level Jinja2 Environment with select_autoescape(["html","xml"]). - Add api/console_web.py with /ui/ routes (dashboard, tasks, task detail/timeline, config) and a _status_fragment polled every 10s. - Refactor api/console.py into a typed ConsoleService shared by the JSON and HTML routers so validation/persistence cannot drift. - Remove RUNTIME_CONSOLE_STATIC_DIR, SpaStaticFiles, and the wildcard CORS middleware from api/rest.py; GET / now redirects to /ui/. - Delete the top-level console/ project; add jinja2 and python-multipart as direct dependencies and ship templates/CSS/JS via package-data. - Add 31 tests (XSS probes, PRG flows, fragment refresh, no-static-dir and no-CORS regressions, wheel-packaging smoke test). /console/* JSON endpoints remain unchanged. The console keeps the trusted-network-only boundary; auth/CSRF is intentionally deferred. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
## Context
|
||||
|
||||
The root Runtime API currently exposes console state and mutations through
|
||||
`api/console.py` under `/console/*`. Its human interface is a separate
|
||||
`console/` Vue/Vite SPA. `api/rest.py` only serves that SPA when
|
||||
`RUNTIME_CONSOLE_STATIC_DIR` points to a built distribution, uses a
|
||||
SPA-specific 404 fallback, and enables wildcard CORS for cross-origin Vite
|
||||
development.
|
||||
|
||||
The existing `web-console` change is still unarchived and contains the
|
||||
opposite design decision: an independent SPA with no backend templates. There
|
||||
is no canonical Runtime-console spec in `openspec/specs/`, so this change
|
||||
introduces a new capability rather than modifying a pending change's delta
|
||||
spec. The Host Agent already uses Jinja2, but it is a distinct application
|
||||
with local-account authentication and must not become a dependency of the
|
||||
Runtime API.
|
||||
|
||||
The Runtime layering rule requires all HTTP, template, static-asset, and form
|
||||
parsing concerns to remain in `api`. `core`, `driver`, `device`, `tools`,
|
||||
`perception`, `storage`, and `runtime` retain their current framework-free
|
||||
contracts.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Serve the Runtime operator console from the same FastAPI process at `/ui/`
|
||||
without Node, Vite, a prebuilt SPA directory, or a second development
|
||||
process.
|
||||
- Preserve the observable behavior of the existing device, task, timeline,
|
||||
and runtime-config console workflows, while retaining `/console/*` JSON
|
||||
endpoints for programmatic clients.
|
||||
- Ensure every HTML interpolation is protected by Jinja2 autoescaping and
|
||||
ship templates and assets in the Runtime wheel.
|
||||
- Keep mutations server-owned through ordinary POST/Redirect/GET form flows
|
||||
and reuse one API-local implementation for page and JSON routes.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Add authentication, authorization, sessions, CSRF protection, rate limits,
|
||||
or a public-network deployment model for the Runtime console.
|
||||
- Change `/agent/task`, `/devices`, MCP, `DeviceManager`, `TaskRunner`,
|
||||
`TaskMetadataStore`, `Timeline`, or persisted data formats.
|
||||
- Add new driver types, editable planner settings, Cloud Console features, or
|
||||
Host Agent console features.
|
||||
- Retain a generic client-side application framework or a Node build pipeline.
|
||||
- Edit or archive the pending `web-console` change as part of this change.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1: Add a Runtime page router in `api/console_web.py`
|
||||
|
||||
`api/console_web.py` will expose `create_console_web_router()` with the `/ui`
|
||||
prefix. `api/rest.py::create_app()` will build the existing Runtime stores and
|
||||
manager once, construct the console service once, and include both the JSON
|
||||
router and the page router in the same FastAPI application. `GET /` will
|
||||
always redirect to `/ui/` because the console is no longer optional build
|
||||
output.
|
||||
|
||||
The page surface will use stable server routes:
|
||||
|
||||
- `GET /ui/` for the device/status dashboard;
|
||||
- `GET /ui/tasks` for task filtering and browsing;
|
||||
- `GET /ui/tasks/{task_id}` for one task and its ordered timeline;
|
||||
- `GET /ui/config` for device registration/removal and `max_steps`;
|
||||
- POST routes below `/ui/` for device registration, device removal, and
|
||||
configuration updates.
|
||||
|
||||
Successful POST handlers use `303 See Other` redirects. Validation failures
|
||||
return the originating page with an HTML `400` response and preserved safe
|
||||
form values. This is preferred over trying to preserve the SPA's client-side
|
||||
state machine because normal browser navigation and forms are sufficient for
|
||||
the console's low-frequency operational actions.
|
||||
|
||||
An alternative of replacing `/console/*` JSON endpoints with HTML endpoints
|
||||
was rejected: the JSON API has explicit tests and remains useful to scripts
|
||||
and future clients. An alternative of a separate FastAPI app was rejected:
|
||||
it would duplicate the `DeviceManager`, stores, and `TaskRunner` composition.
|
||||
|
||||
### D2: Share API-local console operations rather than issuing loopback HTTP
|
||||
requests
|
||||
|
||||
`api/console.py` will gain a small API-local `ConsoleService` (or equivalent
|
||||
typed operation object) that owns the current console reads and mutations:
|
||||
device listing/registration/removal, task listing/detail/timeline lookup, and
|
||||
runtime configuration reads/updates. The existing JSON router and the new
|
||||
page router receive the same service instance.
|
||||
|
||||
The service stays in `api` and delegates to the existing injected
|
||||
`DeviceManager`, `TaskMetadataStore`, `Timeline`, `DeviceConfigStore`, and
|
||||
`TaskRunner`. Page handlers MUST NOT make HTTP requests to the process's own
|
||||
`/console/*` endpoints. This keeps validation, driver allow-listing,
|
||||
persistence, error mapping, and state mutation single-sourced without moving
|
||||
web concepts into a lower layer.
|
||||
|
||||
Duplicating the route bodies was rejected because future changes could make
|
||||
JSON and form behavior diverge. Moving the service into `runtime` or
|
||||
`storage` was rejected because it would introduce HTTP/UI-oriented
|
||||
application concerns below the API adapter boundary.
|
||||
|
||||
### D3: Use a module-level Jinja2 environment and package-owned assets
|
||||
|
||||
`api/console_web.py` will construct one module-level Jinja2 `Environment`
|
||||
using `FileSystemLoader` rooted at `api/templates/runtime_console/` and
|
||||
`select_autoescape(["html", "xml"])`. Route handlers will render with
|
||||
`get_template(...).render(...)` and return `HTMLResponse`; they will not
|
||||
build HTML through f-strings, string concatenation, or post-process rendered
|
||||
HTML.
|
||||
|
||||
Templates will include a shared `base.html`, dashboard, task list, task
|
||||
detail, configuration page, and any small server-rendered dashboard fragment.
|
||||
CSS and the narrow polling script will be regular package assets mounted under
|
||||
`/ui/assets`, not Vite output. The root package will explicitly include the
|
||||
template, CSS, and JavaScript globs in setuptools package data so an installed
|
||||
wheel behaves like an editable checkout.
|
||||
|
||||
`jinja2>=3.1` and `python-multipart` will be direct root dependencies. The
|
||||
latter is declared directly even though it is currently available through an
|
||||
unrelated transitive dependency, because `Request.form()` is part of this
|
||||
feature's runtime contract.
|
||||
|
||||
`starlette.templating.Jinja2Templates` was rejected because direct Jinja2
|
||||
rendering is explicit, matches the existing Host Agent convention, and avoids
|
||||
coupling the implementation to helper signature changes. Inline all-CSS HTML
|
||||
was rejected because a dedicated static stylesheet keeps base templates
|
||||
readable without restoring a frontend build system.
|
||||
|
||||
### D4: Retain server-owned live status with a narrow rendered fragment
|
||||
|
||||
The dashboard will keep the current approximately 10-second live-status
|
||||
refresh through a small static browser script. It fetches an HTML fragment
|
||||
rendered by the same Jinja2 environment and replaces only the dashboard's
|
||||
live-status region. The browser does not fetch JSON and recreate console DOM
|
||||
state; all stateful markup still originates on the server.
|
||||
|
||||
Task filters, task selection, timeline step selection, device mutations, and
|
||||
configuration updates use normal GET/POST navigation. This retains useful
|
||||
status freshness without reintroducing a SPA framework or making a full-page
|
||||
reload the only refresh mechanism.
|
||||
|
||||
A zero-JavaScript periodic refresh was rejected because it would require full
|
||||
page reloads and regress the existing dashboard behavior. Reusing the Vue
|
||||
reactivity layer was rejected because it retains the independent build and
|
||||
deployment boundary this change removes.
|
||||
|
||||
### D5: Preserve JSON compatibility while retiring SPA deployment wiring
|
||||
|
||||
The `/console/*` paths, methods, success payloads, error status codes, and
|
||||
persistence semantics remain unchanged. Existing `tests/test_console_api.py`
|
||||
continues to be the compatibility baseline. `api/rest.py` removes
|
||||
`RUNTIME_CONSOLE_STATIC_DIR`, `SpaStaticFiles`, and its SPA-only fallback.
|
||||
The broad CORS middleware added only for cross-origin Vite development is
|
||||
removed; same-origin `/ui/` must not depend on it.
|
||||
|
||||
The top-level `console/` directory, Vite environment files, package lockfile,
|
||||
and npm instructions are removed. Root documentation changes to a single
|
||||
Runtime startup command followed by `/ui/`. The unrelated `cloud-console/`
|
||||
SPA and its deployment remain unchanged.
|
||||
|
||||
Keeping `RUNTIME_CONSOLE_STATIC_DIR` as a deprecated fallback was rejected:
|
||||
it would preserve an unsupported second rendering path and force every future
|
||||
console change to be tested twice. A backward-compatible redirect from the
|
||||
old external Vite development port is impossible because it is a separate
|
||||
process, so the operator migration is documented as a breaking change.
|
||||
|
||||
### D6: Apply autoescaping uniformly and retain the trusted-network boundary
|
||||
|
||||
All interpolated device names, IDs, connection values, task goals, failure
|
||||
reasons, timeline text, JSON-like tool/result data, and errors pass through
|
||||
the shared autoescaping environment. Structured values use Jinja's `tojson`
|
||||
filter only in safe text contexts; no current template uses `|safe` or a
|
||||
global autoescape opt-out. Screenshot bytes remain data sourced from the
|
||||
existing timeline and are rendered only as the established PNG data URI.
|
||||
|
||||
Removing wildcard CORS reduces the old SPA development surface but does not
|
||||
provide authentication and does not by itself prevent cross-site HTML form
|
||||
submission. The Runtime console therefore remains documented as
|
||||
trusted-network-only. Authentication/session/CSRF design is intentionally
|
||||
separate, so this migration does not create a misleading partial security
|
||||
model.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [A task, device name, or JSON value could carry HTML/script content] -> The
|
||||
shared autoescaping environment, `tojson` for structured output, no safe
|
||||
bypasses, and XSS regression tests make the protection mechanical.
|
||||
- [Templates or assets work from a checkout but not an installed wheel] ->
|
||||
Explicit setuptools package-data rules and a built-wheel smoke test verify
|
||||
deployment behavior.
|
||||
- [Removing the npm/Vite workflow disrupts an operator's existing runbook] ->
|
||||
Mark the removal as breaking, update all Runtime console documentation, and
|
||||
retain a Git-revert rollback path with no data migration.
|
||||
- [Removing CORS breaks an undiscovered browser client] -> The existing CORS
|
||||
configuration was introduced for the deleted Vite development flow; JSON
|
||||
clients outside a browser remain unaffected. A future browser integration
|
||||
must add an explicit origin policy rather than restore a wildcard.
|
||||
- [Base64 screenshots make large task-detail responses expensive] -> Preserve
|
||||
the existing bounded-task behavior and do not change timeline storage or
|
||||
transfer format in this rendering migration.
|
||||
- [The pending `web-console` change still contains a SPA decision and one
|
||||
manual task] -> Record this change as the rendering-mechanism successor;
|
||||
reconcile the older change only after the new server-rendered browser
|
||||
workflow has been manually verified.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Add direct dependencies and package-data declarations, then create the
|
||||
Jinja2 environment, templates, static assets, API-local console service,
|
||||
and `/ui/` page routes while preserving existing JSON-route tests.
|
||||
2. Add focused template, page-route, mutation/PRG, fragment-refresh, and XSS
|
||||
tests. Build and install the Runtime wheel in an isolated environment to
|
||||
verify package resources are present.
|
||||
3. Remove `console/`, SPA static mounting, the static-directory environment
|
||||
variable, Vite-specific CORS, and obsolete ignore/configuration files.
|
||||
4. Update root Runtime and macOS setup documentation to direct operators to
|
||||
`/ui/`, and document the removed npm/static-directory workflow.
|
||||
5. Run formatting, linting, non-integration Runtime tests, wheel build/smoke
|
||||
checks, strict OpenSpec validation, and a browser walkthrough covering
|
||||
dashboard refresh, task replay, device mutation, and `max_steps` update.
|
||||
|
||||
Rollback is a source revert. It restores the Vue sources and static mount if
|
||||
needed and does not alter device configuration, task metadata, or timeline
|
||||
data, so no data rollback or schema migration is required.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- The remaining manual verification task in the pending `web-console` change
|
||||
must be reconciled before that older change is archived. It is not safe to
|
||||
mark it complete solely because this proposal exists; the new `/ui/`
|
||||
browser walkthrough supplies the replacement evidence after implementation.
|
||||
- Authentication and CSRF protection remain deliberately deferred. Any plan to
|
||||
expose the Runtime console beyond a trusted local network requires a
|
||||
separate threat model and change proposal.
|
||||
Reference in New Issue
Block a user