Files
q792602257andClaude Opus 4.6 e00c50e703 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>
2026-07-15 08:03:13 +08:00

234 lines
12 KiB
Markdown

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