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>
12 KiB
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-consolechange 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/tasksfor task filtering and browsing;GET /ui/tasks/{task_id}for one task and its ordered timeline;GET /ui/configfor device registration/removal andmax_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,
tojsonfor 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-consolechange 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
- 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. - 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.
- Remove
console/, SPA static mounting, the static-directory environment variable, Vite-specific CORS, and obsolete ignore/configuration files. - Update root Runtime and macOS setup documentation to direct operators to
/ui/, and document the removed npm/static-directory workflow. - 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_stepsupdate.
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-consolechange 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.