-
diff --git a/console/src/api.ts b/console/src/api.ts
deleted file mode 100644
index d8a5d8a..0000000
--- a/console/src/api.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-import type {
- Device,
- RegisterDevicePayload,
- RuntimeConfig,
- TaskRecord,
- TimelineRecord,
-} from "./types";
-
-const configuredBaseUrl = import.meta.env.VITE_API_BASE_URL as string | undefined;
-export const API_BASE_URL = (
- configuredBaseUrl !== undefined ? configuredBaseUrl : "http://127.0.0.1:8000"
-).replace(/\/$/, "");
-
-async function request(path: string, init: RequestInit = {}): Promise {
- const response = await fetch(`${API_BASE_URL}${path}`, {
- ...init,
- headers: {
- Accept: "application/json",
- ...(init.body ? { "Content-Type": "application/json" } : {}),
- ...init.headers,
- },
- });
-
- if (!response.ok) {
- let message = `${response.status} ${response.statusText}`;
- try {
- const payload = (await response.json()) as { detail?: unknown };
- if (typeof payload.detail === "string") {
- message = payload.detail;
- } else if (payload.detail) {
- message = JSON.stringify(payload.detail);
- }
- } catch {
- message = await response.text();
- }
- throw new Error(message);
- }
-
- if (response.status === 204) {
- return undefined as T;
- }
- return (await response.json()) as T;
-}
-
-export function listDevices(): Promise {
- return request("/console/devices");
-}
-
-export function registerDevice(payload: RegisterDevicePayload): Promise {
- return request("/console/devices", {
- method: "POST",
- body: JSON.stringify(payload),
- });
-}
-
-export function unregisterDevice(deviceId: string): Promise {
- return request(`/console/devices/${encodeURIComponent(deviceId)}`, {
- method: "DELETE",
- });
-}
-
-export function listTasks(filters: {
- deviceId?: string;
- status?: string;
-}): Promise {
- const params = new URLSearchParams();
- if (filters.deviceId) {
- params.set("device_id", filters.deviceId);
- }
- if (filters.status) {
- params.set("status", filters.status);
- }
- const query = params.toString();
- return request(`/console/tasks${query ? `?${query}` : ""}`);
-}
-
-export function getTask(taskId: string): Promise {
- return request(`/console/tasks/${encodeURIComponent(taskId)}`);
-}
-
-export function getTimeline(taskId: string): Promise {
- return request(
- `/console/tasks/${encodeURIComponent(taskId)}/timeline`,
- );
-}
-
-export function getConfig(): Promise {
- return request("/console/config");
-}
-
-export function updateConfig(payload: RuntimeConfig): Promise {
- return request("/console/config", {
- method: "PUT",
- body: JSON.stringify(payload),
- });
-}
diff --git a/console/src/main.ts b/console/src/main.ts
deleted file mode 100644
index de275e7..0000000
--- a/console/src/main.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import { createApp } from "vue";
-import App from "./App.vue";
-import "./style.css";
-
-createApp(App).mount("#app");
diff --git a/console/src/types.ts b/console/src/types.ts
deleted file mode 100644
index c0ef851..0000000
--- a/console/src/types.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-export type DeviceStatus = "idle" | "busy" | "offline" | "error";
-
-export interface Device {
- id: string;
- name: string | null;
- status: DeviceStatus;
- driver_type: string;
- connection_info: Record;
-}
-
-export type TaskStatus =
- | "created"
- | "running"
- | "completed"
- | "failed"
- | "cancelled";
-
-export interface TaskRecord {
- id: string;
- goal: string;
- device_id: string;
- status: TaskStatus;
- created_at: string;
- updated_at: string;
- completed_at: string | null;
- failure_reason: string | null;
-}
-
-export interface TimelineRecord {
- index: number;
- scene: Record;
- prompt: string;
- tool_call: Record;
- result: Record;
- timestamp: string;
- screenshot_path?: string | null;
- image_base64?: string;
-}
-
-export interface RuntimeConfig {
- max_steps: number;
-}
-
-export interface RegisterDevicePayload {
- driver_type: string;
- name?: string | null;
- connection_info: Record;
-}
diff --git a/console/src/vite-env.d.ts b/console/src/vite-env.d.ts
deleted file mode 100644
index 11f02fe..0000000
--- a/console/src/vite-env.d.ts
+++ /dev/null
@@ -1 +0,0 @@
-///
diff --git a/console/tsconfig.json b/console/tsconfig.json
deleted file mode 100644
index 20c2ac8..0000000
--- a/console/tsconfig.json
+++ /dev/null
@@ -1,20 +0,0 @@
-{
- "compilerOptions": {
- "target": "ES2022",
- "useDefineForClassFields": true,
- "module": "ESNext",
- "lib": ["ES2022", "DOM", "DOM.Iterable"],
- "skipLibCheck": true,
- "moduleResolution": "Bundler",
- "allowImportingTsExtensions": true,
- "isolatedModules": true,
- "moduleDetection": "force",
- "noEmit": true,
- "jsx": "preserve",
- "strict": true,
- "noUnusedLocals": true,
- "noUnusedParameters": true
- },
- "include": ["src/**/*.ts", "src/**/*.vue"],
- "references": [{ "path": "./tsconfig.node.json" }]
-}
diff --git a/console/tsconfig.node.json b/console/tsconfig.node.json
deleted file mode 100644
index 91566d1..0000000
--- a/console/tsconfig.node.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "compilerOptions": {
- "composite": true,
- "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
- "skipLibCheck": true,
- "module": "ESNext",
- "moduleResolution": "Bundler",
- "allowSyntheticDefaultImports": true,
- "strict": true
- },
- "include": ["vite.config.ts"]
-}
diff --git a/console/vite.config.ts b/console/vite.config.ts
deleted file mode 100644
index bf8e9c1..0000000
--- a/console/vite.config.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { defineConfig } from "vite";
-import vue from "@vitejs/plugin-vue";
-
-export default defineConfig({
- plugins: [vue()],
- base: "/ui/",
-});
diff --git a/docs/CONSTITUTION.md b/docs/CONSTITUTION.md
index 69d11b2..facb1e3 100644
--- a/docs/CONSTITUTION.md
+++ b/docs/CONSTITUTION.md
@@ -48,8 +48,11 @@ owned by `packages/cloud-platform` and may depend on the Runtime through an
explicit workspace source; the Runtime distribution must never depend on or
package `cloud`.
-All Python members share the committed root `uv.lock`. The Vue/Vite `console/`
-remains outside the Python workspace and keeps its independent npm lifecycle.
+All Python members share the committed root `uv.lock`. The Runtime operator
+console is server-rendered by the `api` layer through Jinja2 templates and
+static assets packaged with `device-agent-runtime`; there is no separate
+frontend project or Node build step for the Runtime console. The unrelated
+`cloud-console/` Vue/Vite application keeps its own independent npm lifecycle.
## Change Discipline
diff --git a/docs/MACOS_IPHONE_SETUP.md b/docs/MACOS_IPHONE_SETUP.md
index 13aa274..a9cdfd5 100644
--- a/docs/MACOS_IPHONE_SETUP.md
+++ b/docs/MACOS_IPHONE_SETUP.md
@@ -331,23 +331,19 @@ curl -s -X POST http://127.0.0.1:8000/devices/iphone-1/launch \
点击坐标必须按当前设备屏幕坐标选择。先截图或使用 Appium Inspector 确认坐标,避免
误操作。
-如需启动 Web Console,保持 Runtime API 运行,再在第三个 Terminal 执行:
+Runtime API 自带同源 Web Console,无需额外的前端进程、Node 工具链或
+`RUNTIME_CONSOLE_STATIC_DIR`。保持 Runtime API 运行,浏览器访问
+`http://127.0.0.1:8000/`(会自动 307 跳转到 `/ui/`)即可:
-```bash
-cd console
-npm install
-npm run dev
-```
+- `/ui/`:设备状态面板,约每 10 秒自动刷新一次;
+- `/ui/tasks`:任务列表与筛选;
+- `/ui/tasks/{task_id}`:任务详情与逐步 timeline(含截图);
+- `/ui/config`:登记/移除设备、调整 `max_steps`。
-Console 默认连接 `http://127.0.0.1:8000`。已由上面启动脚本连接的
-`iphone-1` 会出现在设备列表中。不要在 Console 中重复登记同一台设备;当前登记
-操作只写入配置,不会自动 connect。
-
-如果不想为 Console 单独起一个 `npm run dev` 进程,可以改为一次性构建后交给
-Runtime API 同源托管,见 `console/README.md` 的「Same-Origin, Single-Process
-Mode」一节:设置 `VITE_API_BASE_URL=` 构建,再用 `RUNTIME_CONSOLE_STATIC_DIR`
-指向构建产物启动 Runtime API,浏览器访问 `/ui/` 即可;改前端代码后需要重新
-`npm run build`,不支持热更新。
+已由上面启动脚本连接的 `iphone-1` 会出现在设备列表中。不要在 Console 中
+重复登记同一台设备;当前登记操作只写入配置,不会自动 connect。Console 与
+`/console/*` JSON API 共用同一份 Runtime 状态,两者行为一致。Runtime Console
+仅假设受信任本地网络访问,不提供鉴权 / CSRF;如需暴露到非受信网络请另行评估。
## 9. 启动云端受管 Host Agent
diff --git a/openspec/changes/runtime-console-jinja2-templates/.openspec.yaml b/openspec/changes/runtime-console-jinja2-templates/.openspec.yaml
new file mode 100644
index 0000000..64105fc
--- /dev/null
+++ b/openspec/changes/runtime-console-jinja2-templates/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-07-14
diff --git a/openspec/changes/runtime-console-jinja2-templates/design.md b/openspec/changes/runtime-console-jinja2-templates/design.md
new file mode 100644
index 0000000..2b4e6d7
--- /dev/null
+++ b/openspec/changes/runtime-console-jinja2-templates/design.md
@@ -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.
diff --git a/openspec/changes/runtime-console-jinja2-templates/proposal.md b/openspec/changes/runtime-console-jinja2-templates/proposal.md
new file mode 100644
index 0000000..0fa75ad
--- /dev/null
+++ b/openspec/changes/runtime-console-jinja2-templates/proposal.md
@@ -0,0 +1,60 @@
+## Why
+
+The local Runtime console is currently a separately built Vue/Vite SPA. It
+requires a Node toolchain for development and an optional static-directory
+configuration for same-process serving, even though its data and mutations
+already live in the Runtime FastAPI process. Rendering the console with Jinja2
+will make the operator surface deploy with the Runtime itself while preserving
+the existing REST contract for programmatic clients.
+
+## What Changes
+
+- Add a same-origin, server-rendered Runtime console under `/ui/`, with Jinja2
+ pages for device status, task browsing/detail/timeline replay, device
+ registration/removal, and runtime configuration.
+- Keep the existing `/console/*` JSON endpoints and make page handlers and
+ JSON handlers share API-layer console operations so their observable
+ registration, deletion, filtering, and configuration semantics cannot
+ drift.
+- Package console templates and static assets with `device-agent-runtime`, add
+ direct Jinja2 and HTML form-parsing dependencies, and render every HTML page
+ through one autoescaping template environment.
+- Replace the Vue/Vite `console/` project, `RUNTIME_CONSOLE_STATIC_DIR`, and
+ SPA fallback static mount with Runtime-owned templates and normal static
+ assets. Remove the permissive CORS configuration that existed only for
+ cross-origin Vite development.
+- **BREAKING**: the independent `console/` npm workflow and
+ `RUNTIME_CONSOLE_STATIC_DIR` deployment mode are removed. Operators will
+ start the Runtime API normally and open `/ui/`; JSON API paths remain
+ unchanged.
+
+## Capabilities
+
+### New Capabilities
+
+- `runtime-console-template-rendering`: Same-origin Jinja2-rendered Runtime
+ console pages, automatic HTML escaping, form-based mutations, and packaged
+ Runtime-owned web assets.
+
+### Modified Capabilities
+
+- None. The existing canonical specs do not define the pending `web-console`
+ SPA, and the `/console/*` JSON API contract remains unchanged.
+
+## Impact
+
+- Affected code: `api/rest.py`, `api/console.py`, a new API-layer page router,
+ Runtime template/static asset directories, root `pyproject.toml`, and
+ console-focused tests.
+- Removed code/assets: top-level `console/` Vue/Vite sources, Node lockfile,
+ Vite environment configuration, and SPA deployment wiring.
+- Documentation: Runtime startup and console guidance in `README.md`,
+ `docs/CONSTITUTION.md`, and `docs/MACOS_IPHONE_SETUP.md` change to describe
+ the built-in `/ui/` console.
+- Security boundary: this change preserves the existing trusted-network,
+ unauthenticated Runtime console assumption. It does not add authentication,
+ authorization, or session/CSRF protection; non-trusted exposure needs a
+ separate security change.
+- Architecture: all new HTTP, HTML, and template concerns remain in the outer
+ `api` layer. No `core`, `driver`, `device`, `tools`, `perception`, or
+ `runtime` package gains web-framework dependencies.
diff --git a/openspec/changes/runtime-console-jinja2-templates/specs/runtime-console-template-rendering/spec.md b/openspec/changes/runtime-console-jinja2-templates/specs/runtime-console-template-rendering/spec.md
new file mode 100644
index 0000000..45bd757
--- /dev/null
+++ b/openspec/changes/runtime-console-jinja2-templates/specs/runtime-console-template-rendering/spec.md
@@ -0,0 +1,113 @@
+## ADDED Requirements
+
+### Requirement: Runtime console pages SHALL be served by the Runtime FastAPI application
+The Runtime FastAPI application SHALL serve its operator console from
+same-origin `/ui/` routes without requiring a separately running frontend
+process, a prebuilt SPA directory, or `RUNTIME_CONSOLE_STATIC_DIR`. `GET /`
+SHALL redirect an operator to `/ui/`.
+
+#### Scenario: Open the built-in console without static-directory configuration
+- **WHEN** the Runtime application starts without `RUNTIME_CONSOLE_STATIC_DIR`
+- **THEN** `GET /` redirects to `/ui/` and `GET /ui/` returns an HTML
+ dashboard rendered by the Runtime process
+
+#### Scenario: Navigate the operator workflows through page routes
+- **WHEN** an operator opens `/ui/tasks`, `/ui/tasks/{task_id}`, or
+ `/ui/config`
+- **THEN** the Runtime returns HTML pages for task browsing, task timeline
+ detail, and device/runtime configuration respectively
+
+### Requirement: Runtime console HTML SHALL use one autoescaping template environment
+Every Runtime console HTML response SHALL be rendered through one
+process-wide Jinja2 environment configured to autoescape `.html` and `.xml`
+templates. Page handlers SHALL NOT construct HTML through f-strings, string
+concatenation, or post-process rendered output to bypass that environment.
+
+#### Scenario: Untrusted device and task values are rendered safely
+- **WHEN** a device name, task goal, failure reason, or timeline value contains
+ ``
+- **THEN** the rendered console HTML contains an escaped text representation
+ and contains no script element originating from that value
+
+#### Scenario: A future page inherits HTML autoescaping
+- **WHEN** a future Runtime console route renders a `.html` template through
+ the shared environment
+- **THEN** its interpolated values are HTML-escaped without route-specific
+ escaping configuration
+
+### Requirement: Runtime console pages SHALL preserve console operational workflows
+The server-rendered console SHALL let an operator inspect device status, list
+and filter tasks, inspect a task's ordered timeline including available
+screenshots, register or remove a supported device, and view or update
+`max_steps`. Successful configuration mutations SHALL use POST/Redirect/GET;
+invalid form input SHALL be re-rendered as a readable HTML error without
+applying a partial mutation.
+
+#### Scenario: Browse filtered tasks and inspect a timeline
+- **WHEN** an operator selects a device or status filter and opens a known
+ task
+- **THEN** the task list contains only matching tasks and the task page renders
+ its timeline in step order with its screenshot when one exists
+
+#### Scenario: Register a device from the configuration page
+- **WHEN** an operator submits valid supported-device form values
+- **THEN** the Runtime registers and persists the device, responds with a
+ redirect to the configuration page, and the device is visible after the
+ redirect
+
+#### Scenario: Reject invalid configuration without partial mutation
+- **WHEN** an operator submits an unsupported driver type, malformed
+ connection value, or non-positive `max_steps`
+- **THEN** the Runtime returns an HTML validation error, preserves the prior
+ Runtime/configuration state, and does not perform a redirect
+
+### Requirement: Dashboard live status SHALL remain server-rendered
+The dashboard SHALL retain periodic live-status refresh without restoring a
+client-side application framework. Its browser enhancement SHALL request a
+server-rendered HTML fragment and replace only the live-status region; it
+SHALL NOT rebuild console state from a JSON API response.
+
+#### Scenario: Refresh dashboard status after the polling interval
+- **WHEN** the dashboard refresh enhancement runs while the Runtime is
+ available
+- **THEN** it retrieves a Jinja2-rendered status fragment and updates the
+ dashboard's live-status region without a full-page reload
+
+### Requirement: Existing console JSON API SHALL remain compatible
+The Runtime SHALL continue to expose the existing `/console/devices`,
+`/console/tasks`, `/console/tasks/{task_id}`, `/console/tasks/{task_id}/timeline`,
+and `/console/config` JSON endpoints with their existing methods, status
+codes, payloads, filtering behavior, and persistence semantics. Page and JSON
+routes SHALL use the same API-local console operations rather than issuing
+HTTP requests to each other.
+
+#### Scenario: Programmatic client reads console data after the UI migration
+- **WHEN** a client calls an existing `GET /console/*` endpoint after the
+ server-rendered console is deployed
+- **THEN** it receives the same JSON response shape and status behavior as
+ before the migration
+
+#### Scenario: A page mutation is visible through the JSON API
+- **WHEN** an operator registers or removes a device or updates `max_steps`
+ through a `/ui/` form
+- **THEN** the corresponding `/console/*` JSON endpoint reports the same
+ resulting Runtime state
+
+### Requirement: Runtime console templates and assets SHALL ship with the Python package
+The Runtime distribution SHALL package all console templates and static assets
+needed by `/ui/`. The Runtime console SHALL not depend on the top-level Vue/Vite
+`console/` project, Node package installation, Vite configuration, or the
+SPA-only static-directory mount. Same-origin console operation SHALL not
+require wildcard CORS configured for Vite development.
+
+#### Scenario: Run the console from an installed Runtime wheel
+- **WHEN** `device-agent-runtime` is built and installed outside the source
+ checkout
+- **THEN** the Runtime can serve `/ui/` and its required CSS/browser assets
+ from packaged resources
+
+#### Scenario: Start a Runtime after the SPA workflow is removed
+- **WHEN** an operator starts the Runtime API using the documented Python
+ command
+- **THEN** the console is available at `/ui/` without `npm install`,
+ `npm run build`, `VITE_API_BASE_URL`, or `RUNTIME_CONSOLE_STATIC_DIR`
diff --git a/openspec/changes/runtime-console-jinja2-templates/tasks.md b/openspec/changes/runtime-console-jinja2-templates/tasks.md
new file mode 100644
index 0000000..f3244ef
--- /dev/null
+++ b/openspec/changes/runtime-console-jinja2-templates/tasks.md
@@ -0,0 +1,42 @@
+## 1. Runtime package and web-resource setup
+
+- [x] 1.1 Add direct `jinja2>=3.1` and `python-multipart` Runtime dependencies, configure setuptools package-data for Runtime console templates/CSS/JavaScript, regenerate `uv.lock`, and verify `uv lock --check`.
+- [x] 1.2 Create the Runtime-owned template and static-asset layout under `api/` for the `/ui/` console, with names suitable for wheel packaging and standard static-file serving.
+- [x] 1.3 Add a package-resource smoke test that builds and installs `device-agent-runtime` outside the source checkout and confirms `/ui/` can find its templates and assets.
+
+## 2. Shared API-layer console operations
+
+- [x] 2.1 Refactor `api/console.py` to expose an API-local typed console service for device, task, timeline, and runtime-config reads and mutations, preserving current validation and error semantics.
+- [x] 2.2 Rewire `create_console_router()` to use the shared service and retain every existing `/console/*` method, JSON response shape, status code, filtering rule, and persistence behavior.
+- [x] 2.3 Update `api/rest.py` to construct one shared service from its existing injected Runtime state and pass it to both JSON and HTML console routers without changing lower-layer dependencies.
+
+## 3. Jinja2 Runtime console routes and pages
+
+- [x] 3.1 Implement `api/console_web.py` with one module-level Jinja2 `Environment`, `FileSystemLoader`, `select_autoescape(["html", "xml"])`, and a small `HTMLResponse` render helper.
+- [x] 3.2 Mount same-origin `/ui/` page routes and `/ui/assets` static resources; make `GET /` redirect to `/ui/` unconditionally.
+- [x] 3.3 Implement the shared base layout, dashboard, and Jinja-rendered live-status fragment, including the small polling enhancement that replaces only the live region.
+- [x] 3.4 Implement server-rendered task list/filter and task-detail/timeline pages, including ordered records, safe structured tool/result output, and available screenshot data URIs.
+- [x] 3.5 Implement the configuration page and POST/Redirect/GET device registration/removal and `max_steps` update handlers, with readable `400` HTML validation errors that retain submitted safe values and perform no partial mutation.
+- [x] 3.6 Port the existing console visual layout to package-owned CSS and the narrow polling script without adding a JavaScript framework or a frontend build step.
+
+## 4. Retire SPA deployment wiring and update documentation
+
+- [x] 4.1 Remove `RUNTIME_CONSOLE_STATIC_DIR`, `SpaStaticFiles`, the SPA fallback, and the wildcard CORS middleware used only for Vite development from `api/rest.py`.
+- [x] 4.2 Delete the top-level `console/` Vue/Vite project and remove its obsolete environment, npm, and ignore-file references while leaving `cloud-console/` untouched.
+- [x] 4.3 Update `README.md`, `docs/CONSTITUTION.md`, and `docs/MACOS_IPHONE_SETUP.md` to document normal Runtime startup followed by `/ui/`, the breaking removal of the npm/static-directory workflow, and the trusted-network-only security boundary.
+- [x] 4.4 Remove stale Docker ignore/configuration entries that only described the deleted Runtime SPA, without changing Cloud Console build or deployment behavior.
+
+## 5. Automated verification
+
+- [x] 5.1 Keep and extend console JSON API tests to prove `/console/*` compatibility before and after page-route mutations.
+- [x] 5.2 Add template tests covering every Runtime console template, module-level autoescape configuration, XSS probes in device/task/timeline/configuration values, safe structured JSON output, and absence of unintended `|safe` bypasses.
+- [x] 5.3 Add FastAPI `TestClient` coverage for root redirect, page navigation, empty/populated dashboard state, task filters/detail/timeline, dashboard fragment refresh, static assets, form PRG success paths, and invalid-form no-mutation paths.
+- [x] 5.4 Add regression coverage that same-origin `/ui/` works without `RUNTIME_CONSOLE_STATIC_DIR` and does not depend on the removed wildcard CORS middleware.
+
+## 6. Validation and migration handoff
+
+- [x] 6.1 Run targeted formatting, lint, compile, and Runtime console tests, then run the repository non-integration test suite; record any pre-existing failures separately from this change.
+- [x] 6.2 Build the Runtime wheel and run the isolated package-resource smoke test after the final dependency lock update.
+- [x] 6.3 Run `openspec validate runtime-console-jinja2-templates --strict` and resolve all validation failures.
+- [ ] 6.4 Perform a browser walkthrough of dashboard live refresh, task filtering/timeline replay, valid and invalid device configuration, device removal, and `max_steps` update using a real Runtime process.
+- [ ] 6.5 After human browser verification, reconcile the remaining manual verification and superseded SPA decision in the pending `web-console` change before any archive decision; do not mark it complete from automated evidence alone.
diff --git a/pyproject.toml b/pyproject.toml
index 6ec6dda..8a3b481 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -8,10 +8,12 @@ dependencies = [
"Appium-Python-Client>=5.1.1",
"fastapi>=0.115.0",
"httpx>=0.27.0",
+ "jinja2>=3.1",
"mcp>=1.27,<2",
"openai>=1.0.0",
"paddlepaddle>=3.0.0",
"paddleocr>=3.0.0",
+ "python-multipart>=0.0.20",
"uvicorn[standard]>=0.30.0",
]
@@ -53,6 +55,13 @@ include = [
"workflow*",
]
+[tool.setuptools.package-data]
+api = [
+ "templates/runtime_console/*.html",
+ "static/runtime_console/*.css",
+ "static/runtime_console/*.js",
+]
+
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra --import-mode=importlib"
diff --git a/tests/test_console_api.py b/tests/test_console_api.py
index 9906de0..1ba7bff 100644
--- a/tests/test_console_api.py
+++ b/tests/test_console_api.py
@@ -80,17 +80,18 @@ def test_console_status_endpoints_cover_empty_and_populated_states(tmp_path) ->
"task-old",
]
assert [
- task["id"]
- for task in client.get("/console/tasks?device_id=iphone-1").json()
+ task["id"] for task in client.get("/console/tasks?device_id=iphone-1").json()
] == ["task-old"]
- assert [task["id"] for task in client.get("/console/tasks?status=running").json()] == [
- "task-new"
- ]
+ assert [
+ task["id"] for task in client.get("/console/tasks?status=running").json()
+ ] == ["task-new"]
assert client.get("/console/tasks/task-old").json()["goal"] == "open settings"
assert client.get("/console/tasks/missing").status_code == 404
-def test_console_timeline_inlines_screenshot_and_handles_empty_history(tmp_path) -> None:
+def test_console_timeline_inlines_screenshot_and_handles_empty_history(
+ tmp_path,
+) -> None:
timeline = Timeline(ArtifactStore(tmp_path / "history"))
client, metadata_store = _client(tmp_path, timeline=timeline)
task = Task(id="task-1", goal="tap search", device_id="iphone-1")
@@ -193,3 +194,27 @@ def test_console_startup_reloads_persisted_devices_and_settings(tmp_path) -> Non
assert runner.config.max_steps == 31
assert [device.id for device in manager.list_devices()] == ["persisted-1"]
assert client.get("/console/devices").json()[0]["name"] == "Persisted iPhone"
+
+
+def test_console_json_reflects_page_form_mutations(tmp_path) -> None:
+ """A device registered via the /ui/ form must be visible through /console/* JSON."""
+ config_store = DeviceConfigStore(tmp_path / "device_config.sqlite3")
+ client, _ = _client(tmp_path, config_store=config_store)
+
+ response = client.post(
+ "/ui/config/devices",
+ data={
+ "name": "From Form",
+ "driver_type": "wda",
+ "server_url": "http://127.0.0.1:4723",
+ "udid": "form-udid",
+ "wda_local_port": "8100",
+ },
+ follow_redirects=False,
+ )
+ assert response.status_code == 303
+
+ json_devices = client.get("/console/devices").json()
+ assert len(json_devices) == 1
+ assert json_devices[0]["name"] == "From Form"
+ assert json_devices[0]["connection_info"]["udid"] == "form-udid"
diff --git a/tests/test_runtime_console_packaging.py b/tests/test_runtime_console_packaging.py
new file mode 100644
index 0000000..bfc5389
--- /dev/null
+++ b/tests/test_runtime_console_packaging.py
@@ -0,0 +1,79 @@
+"""Smoke test that console templates and assets ship inside the Runtime wheel.
+
+Builds ``device-agent-runtime`` into a temporary directory, installs it into an
+isolated venv that cannot reach the source checkout, and asserts the packaged
+``api`` package carries the Jinja2 templates and static assets needed by
+``/ui/``. This guards against setuptools package-data regressions that would
+let the console work from an editable checkout but break from a real install.
+"""
+
+from __future__ import annotations
+
+import subprocess
+import venv
+from pathlib import Path
+
+import pytest
+
+
+def _run(cmd: list[str], *, cwd: Path | None = None) -> str:
+ return subprocess.check_output(
+ cmd,
+ cwd=cwd,
+ stderr=subprocess.STDOUT,
+ text=True,
+ )
+
+
+@pytest.mark.integration
+def test_runtime_wheel_packages_console_templates_and_assets(tmp_path: Path) -> None:
+ repo_root = Path(__file__).resolve().parent.parent
+
+ wheel_dir = tmp_path / "wheels"
+ wheel_dir.mkdir()
+ _run(
+ ["uv", "build", "--package", "device-agent-runtime", "--wheel", "--no-sources"],
+ cwd=repo_root,
+ )
+ wheels = list(repo_root.glob("dist/*.whl"))
+ assert wheels, "uv build did not produce a wheel"
+ wheel_path = wheels[0]
+
+ venv_dir = tmp_path / "venv"
+ venv.create(venv_dir, with_pip=True, clear=True)
+ pip = str(venv_dir / "Scripts" / "pip.exe")
+ if not Path(pip).exists():
+ pip = str(venv_dir / "bin" / "pip")
+ _run([pip, "install", str(wheel_path)], cwd=tmp_path)
+
+ python = str(venv_dir / "Scripts" / "python.exe")
+ if not Path(python).exists():
+ python = str(venv_dir / "bin" / "python")
+
+ probe = _run(
+ [
+ python,
+ "-c",
+ (
+ "from importlib.resources import files; "
+ "api_root = files('api'); "
+ "templates = sorted(p.name for p in "
+ "(api_root / 'templates' / 'runtime_console').iterdir()); "
+ "assets = sorted(p.name for p in "
+ "(api_root / 'static' / 'runtime_console').iterdir()); "
+ "print(','.join(templates)); "
+ "print(','.join(assets))"
+ ),
+ ],
+ cwd=tmp_path,
+ )
+ template_names, asset_names = probe.strip().splitlines()
+ assert "base.html" in template_names
+ assert "dashboard.html" in template_names
+ assert "config.html" in template_names
+ assert "console.css" in asset_names
+ assert "dashboard.js" in asset_names
+
+ # Clean up the build artifact so it does not leak into the working tree.
+ for wheel in wheels:
+ wheel.unlink()
diff --git a/tests/test_runtime_console_web.py b/tests/test_runtime_console_web.py
new file mode 100644
index 0000000..1a18869
--- /dev/null
+++ b/tests/test_runtime_console_web.py
@@ -0,0 +1,393 @@
+from __future__ import annotations
+
+import base64
+from datetime import UTC, datetime
+from pathlib import Path
+
+import pytest
+
+from core.models import Task
+from device.manager import DeviceManager
+from runtime.task import TaskRunner, TaskRunnerConfig
+from storage.artifact_store import ArtifactStore
+from storage.device_config import DeviceConfigStore
+from storage.task_metadata import TaskMetadataStore
+from storage.timeline import Timeline
+from tests.fakes import PNG_10X20, FakeDriver
+
+
+def _client(tmp_path, *, manager=None, runner=None, config_store=None, timeline=None):
+ pytest.importorskip("fastapi")
+ from fastapi.testclient import TestClient
+
+ from api.rest import create_app
+
+ metadata_store = TaskMetadataStore(tmp_path / "tasks.sqlite3")
+ app = create_app(
+ manager=manager or DeviceManager(),
+ metadata_store=metadata_store,
+ task_runner=runner,
+ device_config_store=config_store
+ or DeviceConfigStore(tmp_path / "device_config.sqlite3"),
+ timeline=timeline or Timeline(ArtifactStore(tmp_path / "history")),
+ )
+ return TestClient(app), metadata_store
+
+
+_TEMPLATE_NAMES = [
+ "base.html",
+ "dashboard.html",
+ "_status_fragment.html",
+ "tasks.html",
+ "task_detail.html",
+ "config.html",
+]
+
+
+# -- 5.2 Template tests ------------------------------------------------------
+
+
+def test_module_jinja_environment_autoescapes_html_and_xml() -> None:
+ from api.console_web import _ENV
+
+ # select_autoescape(["html", "xml"]) returns a callable used by Jinja2.
+ assert callable(_ENV.autoescape)
+ assert _ENV.autoescape("foo.html") is True
+ assert _ENV.autoescape("foo.xml") is True
+
+
+def test_every_console_template_is_known_and_loadable() -> None:
+ from api.console_web import _ENV
+
+ for name in _TEMPLATE_NAMES:
+ assert _ENV.get_template(name) is not None
+
+
+def test_no_template_uses_safe_filter_bypass() -> None:
+ template_dir = (
+ Path(__file__).resolve().parent.parent / "api" / "templates" / "runtime_console"
+ )
+ for path in template_dir.glob("*.html"):
+ source = path.read_text(encoding="utf-8")
+ assert "| safe" not in source, f"{path.name} uses |safe bypass"
+ assert "|safe" not in source, f"{path.name} uses |safe bypass"
+
+
+def test_dashboard_escapes_untrusted_device_name(tmp_path) -> None:
+ manager = DeviceManager()
+ manager.register_device(
+ "dev-xss",
+ lambda: FakeDriver(),
+ name="",
+ driver_type="wda",
+ )
+ client, _ = _client(tmp_path, manager=manager)
+
+ body = client.get("/ui/").text
+ assert "<script>" in body
+ assert "" not in body
+
+
+def test_tasks_list_escapes_untrusted_goal(tmp_path) -> None:
+ client, metadata_store = _client(tmp_path)
+ metadata_store.create_task(
+ Task(
+ id="task-xss",
+ goal="",
+ device_id="dev-1",
+ )
+ )
+ body = client.get("/ui/tasks").text
+ assert "<script>" in body
+ assert "" not in body
+
+
+def test_task_detail_escapes_failure_reason_and_structured_output(tmp_path) -> None:
+ timeline = Timeline(ArtifactStore(tmp_path / "history"))
+ client, metadata_store = _client(tmp_path, timeline=timeline)
+ metadata_store.create_task(
+ Task(
+ id="task-detail",
+ goal="do thing",
+ device_id="dev-1",
+ failure_reason="",
+ )
+ )
+ timeline.append(
+ task_id="task-detail",
+ scene={"screen": {"width": 10, "height": 20}, "elements": []},
+ prompt="do thing",
+ tool_call={"action": ""},
+ screenshot=PNG_10X20,
+ )
+ body = client.get("/ui/tasks/task-detail").text
+ assert "" not in body
+ assert "",
+ "driver_type": "bad-driver",
+ },
+ )
+ assert response.status_code == 400
+ assert "" not in response.text
+ assert "<script>" in response.text
+
+
+# -- 5.3 TestClient page-route coverage --------------------------------------
+
+
+def test_root_redirects_to_ui(tmp_path) -> None:
+ client, _ = _client(tmp_path)
+ response = client.get("/", follow_redirects=False)
+ assert response.status_code == 307
+ assert response.headers["location"] == "/ui/"
+
+
+def test_dashboard_serves_html_with_empty_state(tmp_path) -> None:
+ client, _ = _client(tmp_path)
+ response = client.get("/ui/")
+ assert response.status_code == 200
+ assert "text/html" in response.headers["content-type"]
+ assert "No devices registered" in response.text
+
+
+def test_dashboard_shows_populated_metrics_and_devices(tmp_path) -> None:
+ manager = DeviceManager()
+ manager.register_device(
+ "iphone-1",
+ lambda: FakeDriver(),
+ name="Desk iPhone",
+ driver_type="wda",
+ )
+ client, metadata_store = _client(tmp_path, manager=manager)
+ metadata_store.create_task(
+ Task(id="t-running", goal="run", device_id="iphone-1", status="running")
+ )
+ metadata_store.create_task(
+ Task(id="t-failed", goal="fail", device_id="iphone-1", status="failed")
+ )
+ body = client.get("/ui/").text
+ assert "Desk iPhone" in body
+ assert "iphone-1" in body
+
+
+def test_status_fragment_endpoint_returns_html_partial(tmp_path) -> None:
+ client, _ = _client(tmp_path)
+ response = client.get("/ui/_status_fragment")
+ assert response.status_code == 200
+ assert "text/html" in response.headers["content-type"]
+ assert "Device Status" in response.text
+
+
+def test_tasks_page_supports_device_and_status_filters(tmp_path) -> None:
+ manager = DeviceManager()
+ manager.register_device(
+ "iphone-1",
+ lambda: FakeDriver(),
+ name="Desk",
+ driver_type="wda",
+ )
+ client, metadata_store = _client(tmp_path, manager=manager)
+ older = Task(
+ id="task-old",
+ goal="open settings",
+ device_id="iphone-1",
+ created_at=datetime(2026, 1, 1, tzinfo=UTC),
+ updated_at=datetime(2026, 1, 1, tzinfo=UTC),
+ )
+ newer = Task(
+ id="task-new",
+ goal="search",
+ device_id="iphone-2",
+ status="running",
+ created_at=datetime(2026, 1, 2, tzinfo=UTC),
+ updated_at=datetime(2026, 1, 2, tzinfo=UTC),
+ )
+ metadata_store.create_task(older)
+ metadata_store.create_task(newer)
+
+ body_all = client.get("/ui/tasks").text
+ assert "task-old" in body_all
+ assert "task-new" in body_all
+
+ body_filtered = client.get("/ui/tasks?device_id=iphone-1").text
+ assert "task-old" in body_filtered
+ assert "task-new" not in body_filtered
+
+ body_status = client.get("/ui/tasks?status=running").text
+ assert "task-new" in body_status
+ assert "task-old" not in body_status
+
+
+def test_task_detail_renders_timeline_with_screenshot(tmp_path) -> None:
+ timeline = Timeline(ArtifactStore(tmp_path / "history"))
+ client, metadata_store = _client(tmp_path, timeline=timeline)
+ metadata_store.create_task(
+ Task(id="task-with-timeline", goal="tap search", device_id="iphone-1")
+ )
+ timeline.append(
+ task_id="task-with-timeline",
+ scene={"screen": {"width": 10, "height": 20}, "elements": []},
+ prompt="tap search",
+ tool_call={"action": "tap", "args": {"x": 1, "y": 2}},
+ result={"ok": True},
+ screenshot=PNG_10X20,
+ )
+ body = client.get("/ui/tasks/task-with-timeline").text
+ expected_data_uri = "data:image/png;base64," + base64.b64encode(PNG_10X20).decode(
+ "ascii"
+ )
+ assert expected_data_uri in body
+ assert "tap" in body
+
+
+def test_task_detail_404_for_unknown_task(tmp_path) -> None:
+ client, _ = _client(tmp_path)
+ response = client.get("/ui/tasks/does-not-exist")
+ assert response.status_code == 404
+
+
+def test_config_page_lists_supported_drivers_and_current_max_steps(tmp_path) -> None:
+ config_store = DeviceConfigStore(tmp_path / "device_config.sqlite3")
+ config_store.set_setting("max_steps", 5)
+ runner = TaskRunner(config=TaskRunnerConfig(max_steps=1))
+ client, _ = _client(tmp_path, runner=runner, config_store=config_store)
+ body = client.get("/ui/config").text
+ assert "wda" in body
+ assert 'value="5"' in body
+
+
+def test_register_device_prg_redirects_and_persists(tmp_path) -> None:
+ config_store = DeviceConfigStore(tmp_path / "device_config.sqlite3")
+ client, _ = _client(tmp_path, config_store=config_store)
+ response = client.post(
+ "/ui/config/devices",
+ data={
+ "name": "Desk iPhone",
+ "driver_type": "wda",
+ "server_url": "http://127.0.0.1:4723",
+ "udid": "abc123",
+ "wda_local_port": "8100",
+ },
+ follow_redirects=False,
+ )
+ assert response.status_code == 303
+ assert response.headers["location"].endswith("/ui/config")
+ assert len(config_store.list()) == 1
+
+
+def test_register_device_rejects_bad_driver_without_partial_mutation(tmp_path) -> None:
+ config_store = DeviceConfigStore(tmp_path / "device_config.sqlite3")
+ client, _ = _client(tmp_path, config_store=config_store)
+ response = client.post(
+ "/ui/config/devices",
+ data={"name": "Bad", "driver_type": "android"},
+ )
+ assert response.status_code == 400
+ assert config_store.list() == []
+ assert "unsupported driver_type" in response.text
+
+
+def test_register_device_rejects_non_numeric_port_without_partial_mutation(
+ tmp_path,
+) -> None:
+ config_store = DeviceConfigStore(tmp_path / "device_config.sqlite3")
+ client, _ = _client(tmp_path, config_store=config_store)
+ response = client.post(
+ "/ui/config/devices",
+ data={
+ "name": "Bad Port",
+ "driver_type": "wda",
+ "wda_local_port": "not-a-number",
+ },
+ )
+ assert response.status_code == 400
+ assert config_store.list() == []
+ assert "wda_local_port must be a number" in response.text
+
+
+def test_remove_device_prg_redirects_and_removes(tmp_path) -> None:
+ config_store = DeviceConfigStore(tmp_path / "device_config.sqlite3")
+ config_store.add(
+ device_id="removable-1",
+ name="To Remove",
+ driver_type="wda",
+ connection_info={"udid": "abc"},
+ )
+ client, _ = _client(tmp_path, config_store=config_store)
+ response = client.post(
+ "/ui/config/devices/removable-1/delete",
+ follow_redirects=False,
+ )
+ assert response.status_code == 303
+ assert config_store.list() == []
+
+
+def test_update_max_steps_prg_redirects_and_applies(tmp_path) -> None:
+ runner = TaskRunner(config=TaskRunnerConfig(max_steps=1))
+ client, _ = _client(tmp_path, runner=runner)
+ response = client.post(
+ "/ui/config/max-steps",
+ data={"max_steps": "25"},
+ follow_redirects=False,
+ )
+ assert response.status_code == 303
+ assert runner.config.max_steps == 25
+
+
+def test_update_max_steps_rejects_non_positive_without_partial_mutation(
+ tmp_path,
+) -> None:
+ config_store = DeviceConfigStore(tmp_path / "device_config.sqlite3")
+ config_store.set_setting("max_steps", 10)
+ runner = TaskRunner(config=TaskRunnerConfig(max_steps=1))
+ client, _ = _client(tmp_path, runner=runner, config_store=config_store)
+ response = client.post("/ui/config/max-steps", data={"max_steps": "0"})
+ assert response.status_code == 400
+ assert runner.config.max_steps == 10
+ assert "max_steps must be positive" in response.text
+
+
+def test_update_max_steps_rejects_non_integer(tmp_path) -> None:
+ client, _ = _client(tmp_path)
+ response = client.post("/ui/config/max-steps", data={"max_steps": "abc"})
+ assert response.status_code == 400
+ assert "max_steps must be an integer" in response.text
+
+
+def test_static_assets_are_served(tmp_path) -> None:
+ client, _ = _client(tmp_path)
+ assert client.get("/ui/assets/console.css").status_code == 200
+ assert client.get("/ui/assets/dashboard.js").status_code == 200
+
+
+# -- 5.4 Regression: no SPA static dir, no wildcard CORS ---------------------
+
+
+def test_ui_works_without_runtime_console_static_dir(tmp_path, monkeypatch) -> None:
+ monkeypatch.delenv("RUNTIME_CONSOLE_STATIC_DIR", raising=False)
+ client, _ = _client(tmp_path)
+ assert client.get("/ui/").status_code == 200
+
+
+def test_runtime_app_does_not_register_wildcard_cors(tmp_path) -> None:
+ client, _ = _client(tmp_path)
+ # A same-origin browser client must not require CORS preflight. If wildcard
+ # CORS were still registered, an explicit Origin header would produce
+ # access-control-allow-origin in the response; assert it is absent for an
+ # arbitrary same-origin page request.
+ response = client.get(
+ "/ui/",
+ headers={"Origin": "http://127.0.0.1:8000"},
+ )
+ assert response.status_code == 200
+ assert "access-control-allow-origin" not in {k.lower() for k in response.headers}
diff --git a/uv.lock b/uv.lock
index dec0273..02752d0 100644
--- a/uv.lock
+++ b/uv.lock
@@ -403,10 +403,12 @@ dependencies = [
{ name = "appium-python-client" },
{ name = "fastapi" },
{ name = "httpx" },
+ { name = "jinja2" },
{ name = "mcp" },
{ name = "openai" },
{ name = "paddleocr" },
{ name = "paddlepaddle" },
+ { name = "python-multipart" },
{ name = "uvicorn", extra = ["standard"] },
]
@@ -421,10 +423,12 @@ requires-dist = [
{ name = "appium-python-client", specifier = ">=5.1.1" },
{ name = "fastapi", specifier = ">=0.115.0" },
{ name = "httpx", specifier = ">=0.27.0" },
+ { name = "jinja2", specifier = ">=3.1" },
{ name = "mcp", specifier = ">=1.27,<2" },
{ name = "openai", specifier = ">=1.0.0" },
{ name = "paddleocr", specifier = ">=3.0.0" },
{ name = "paddlepaddle", specifier = ">=3.0.0" },
+ { name = "python-multipart", specifier = ">=0.0.20" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" },
]