- Paste an operator bearer token scoped to the Cloud Control Plane at
- {{ API_BASE_URL }}. The token is held in
- sessionStorage only — close this tab to discard it.
-
-
diff --git a/compose.deploy.yaml b/compose.deploy.yaml
index f3f7bce..914f7ce 100644
--- a/compose.deploy.yaml
+++ b/compose.deploy.yaml
@@ -25,26 +25,7 @@ services:
environment:
CLOUD_ENVIRONMENT: production
CLOUD_DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
- CLOUD_PUBLIC_CREDENTIALS_JSON: ${CLOUD_PUBLIC_CREDENTIALS_JSON}
- CLOUD_HOST_CREDENTIALS_JSON: ${CLOUD_HOST_CREDENTIALS_JSON}
- CLOUD_ENROLLMENT_TOKENS_JSON: ${CLOUD_ENROLLMENT_TOKENS_JSON:-[]}
- # Left disabled by default; the amcp.home.jerryyan.top deployment sets
- # CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED=true in its own .env (not
- # committed here) since it is the intended target for zero-token edge
- # enrollment. See docs/CLOUD_DEPLOYMENT.md.
- CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED: ${CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED:-false}
- CLOUD_SCHEDULER_INTERVAL_SECONDS: ${CLOUD_SCHEDULER_INTERVAL_SECONDS:-1}
- CLOUD_LEASE_REAPER_INTERVAL_SECONDS: ${CLOUD_LEASE_REAPER_INTERVAL_SECONDS:-5}
- CLOUD_LEASE_DURATION_SECONDS: ${CLOUD_LEASE_DURATION_SECONDS:-60}
- CLOUD_MAX_TASK_ATTEMPTS: ${CLOUD_MAX_TASK_ATTEMPTS:-3}
- CLOUD_USER_SESSION_IDLE_SECONDS: ${CLOUD_USER_SESSION_IDLE_SECONDS:-28800}
- CLOUD_USER_SESSION_ABSOLUTE_SECONDS: ${CLOUD_USER_SESSION_ABSOLUTE_SECONDS:-604800}
- CLOUD_LOGIN_FAILURE_LIMIT: ${CLOUD_LOGIN_FAILURE_LIMIT:-5}
- CLOUD_LOGIN_FAILURE_WINDOW_SECONDS: ${CLOUD_LOGIN_FAILURE_WINDOW_SECONDS:-900}
- CLOUD_LOGIN_BLOCK_SECONDS: ${CLOUD_LOGIN_BLOCK_SECONDS:-900}
- CLOUD_SESSION_COOKIE_SECURE: ${CLOUD_SESSION_COOKIE_SECURE:-true}
CLOUD_TRUST_PROXY_HEADERS: ${CLOUD_TRUST_PROXY_HEADERS:-false}
- CLOUD_CONSOLE_STATIC_DIR: /app/console-static
ports:
- "${CLOUD_API_PORT:-8001}:8001"
depends_on:
diff --git a/compose.yaml b/compose.yaml
index 7a78dc6..a351e27 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -26,22 +26,7 @@ services:
environment:
CLOUD_ENVIRONMENT: production
CLOUD_DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
- CLOUD_PUBLIC_CREDENTIALS_JSON: ${CLOUD_PUBLIC_CREDENTIALS_JSON}
- CLOUD_HOST_CREDENTIALS_JSON: ${CLOUD_HOST_CREDENTIALS_JSON}
- CLOUD_ENROLLMENT_TOKENS_JSON: ${CLOUD_ENROLLMENT_TOKENS_JSON:-[]}
- CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED: ${CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED:-false}
- CLOUD_SCHEDULER_INTERVAL_SECONDS: ${CLOUD_SCHEDULER_INTERVAL_SECONDS:-1}
- CLOUD_LEASE_REAPER_INTERVAL_SECONDS: ${CLOUD_LEASE_REAPER_INTERVAL_SECONDS:-5}
- CLOUD_LEASE_DURATION_SECONDS: ${CLOUD_LEASE_DURATION_SECONDS:-60}
- CLOUD_MAX_TASK_ATTEMPTS: ${CLOUD_MAX_TASK_ATTEMPTS:-3}
- CLOUD_USER_SESSION_IDLE_SECONDS: ${CLOUD_USER_SESSION_IDLE_SECONDS:-28800}
- CLOUD_USER_SESSION_ABSOLUTE_SECONDS: ${CLOUD_USER_SESSION_ABSOLUTE_SECONDS:-604800}
- CLOUD_LOGIN_FAILURE_LIMIT: ${CLOUD_LOGIN_FAILURE_LIMIT:-5}
- CLOUD_LOGIN_FAILURE_WINDOW_SECONDS: ${CLOUD_LOGIN_FAILURE_WINDOW_SECONDS:-900}
- CLOUD_LOGIN_BLOCK_SECONDS: ${CLOUD_LOGIN_BLOCK_SECONDS:-900}
- CLOUD_SESSION_COOKIE_SECURE: ${CLOUD_SESSION_COOKIE_SECURE:-true}
CLOUD_TRUST_PROXY_HEADERS: ${CLOUD_TRUST_PROXY_HEADERS:-false}
- CLOUD_CONSOLE_STATIC_DIR: /app/console-static
ports:
- "${CLOUD_API_PORT:-8001}:8001"
depends_on:
@@ -64,23 +49,8 @@ services:
command: ["device-host-agent"]
environment:
HOST_AGENT_CONTROL_PLANE_URL: http://cloud-api:8001
- HOST_AGENT_HOST_ID: ${HOST_AGENT_HOST_ID}
- HOST_AGENT_TOKEN: ${HOST_AGENT_TOKEN}
- HOST_AGENT_ENROLLMENT_TOKEN: ${HOST_AGENT_ENROLLMENT_TOKEN:-}
HOST_AGENT_IDENTITY_PATH: ${HOST_AGENT_IDENTITY_PATH:-/app/tasks/host_identity.json}
HOST_AGENT_LOCAL_ACCOUNT_PATH: ${HOST_AGENT_LOCAL_ACCOUNT_PATH:-/app/tasks/host_local_account.json}
- HOST_AGENT_DISPLAY_NAME: ${HOST_AGENT_DISPLAY_NAME:-}
- HOST_AGENT_HEARTBEAT_INTERVAL_SECONDS: ${HOST_AGENT_HEARTBEAT_INTERVAL_SECONDS:-30}
- HOST_AGENT_POLL_TIMEOUT_SECONDS: ${HOST_AGENT_POLL_TIMEOUT_SECONDS:-20}
- HOST_AGENT_RETRY_BACKOFF_SECONDS: ${HOST_AGENT_RETRY_BACKOFF_SECONDS:-1}
- HOST_AGENT_MAX_RETRY_BACKOFF_SECONDS: ${HOST_AGENT_MAX_RETRY_BACKOFF_SECONDS:-30}
- HOST_AGENT_MAX_RETRY_ATTEMPTS: ${HOST_AGENT_MAX_RETRY_ATTEMPTS:-5}
- AI_PLANNER_ENABLED: ${AI_PLANNER_ENABLED:-false}
- AI_PLANNER_PROVIDER: ${AI_PLANNER_PROVIDER:-anthropic}
- AI_PLANNER_MODEL: ${AI_PLANNER_MODEL:-}
- AI_PLANNER_TIMEOUT_SECONDS: ${AI_PLANNER_TIMEOUT_SECONDS:-30}
- ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
- OPENAI_API_KEY: ${OPENAI_API_KEY:-}
volumes:
- ${HOST_AGENT_TASKS_PATH:-./tasks}:/app/tasks
depends_on:
diff --git a/docs/CLOUD_DEPLOYMENT.md b/docs/CLOUD_DEPLOYMENT.md
index ef2527a..427cfb8 100644
--- a/docs/CLOUD_DEPLOYMENT.md
+++ b/docs/CLOUD_DEPLOYMENT.md
@@ -10,24 +10,29 @@ uv sync --locked --all-packages
## Local SQLite
SQLite is intended for local development and tests with one Cloud API process.
-Configure public and host credentials even in local mode because anonymous
-development access cannot authorize a host identity.
+The Cloud API uses persistent user sessions for human access and a Host-generated
+secret for later Host operations; no static bearer credential configuration is
+required.
```powershell
$env:CLOUD_ENVIRONMENT = "local"
$env:CLOUD_DATABASE_URL = "sqlite:///cloud/cloud.sqlite3"
-$env:CLOUD_PUBLIC_CREDENTIALS_JSON = '[{"principal_id":"local-sdk","token":"replace-public-token","scopes":["tasks:submit","tasks:read","pool:read","plugins:read","plugins:admin"]}]'
-$env:CLOUD_HOST_CREDENTIALS_JSON = '[{"principal_id":"local-host","token":"replace-host-token","scopes":[],"host_id":"host-local"}]'
+$env:CLOUD_SESSION_COOKIE_SECURE = "false"
uv run --package device-cloud-api device-cloud-api --host 127.0.0.1 --port 8001
```
-In a second terminal, start the Host Agent with the matching host identity and
-token:
+Create the first administrator interactively, then open the Console and sign
+in with that account:
+
+```bash
+uv run --package device-cloud-api device-cloud-admin users create --username admin --display-name "Local Administrator" --role admin
+```
+
+In a second terminal, configure the local Host Agent to reach this Cloud API:
```powershell
$env:HOST_AGENT_CONTROL_PLANE_URL = "http://127.0.0.1:8001"
-$env:HOST_AGENT_HOST_ID = "host-local"
-$env:HOST_AGENT_TOKEN = "replace-host-token"
+uv run --package device-host-agent device-host-agent setup
uv run --package device-host-agent device-host-agent
```
@@ -41,66 +46,23 @@ defaults to `./tasks`.
The Host Agent only initiates outbound HTTP requests. It does not expose an
inbound port.
-## Managed Edge Enrollment
-
-New edge installations do not need a pre-coordinated Host or device ID. The
-Cloud API accepts configured one-time enrollment credentials:
-
-```powershell
-$env:CLOUD_ENROLLMENT_TOKENS_JSON = '[{"principal_id":"edge-installer","token":"replace-with-a-long-random-one-time-token"}]'
-```
-
-On the edge Host, omit `HOST_AGENT_HOST_ID` and `HOST_AGENT_TOKEN` and provide
-the enrollment token only for the first successful enrollment:
-
-```bash
-export HOST_AGENT_CONTROL_PLANE_URL="https://cloud.example.com"
-export HOST_AGENT_ENROLLMENT_TOKEN="replace-with-a-long-random-one-time-token"
-export HOST_AGENT_IDENTITY_PATH="tasks/host_identity.json"
-export HOST_AGENT_DISPLAY_NAME="Edge Mac 01"
-uv run --package device-host-agent device-host-agent
-```
-
-Before its first request the Host Agent creates `HOST_AGENT_IDENTITY_PATH` with
-an instance identifier and long-lived random Host secret. The cloud consumes
-the enrollment token, assigns `host_id`, stores only credential digests, and
-returns the assigned ID. The Host Agent then enrolls each record from
-`tasks/device_config.sqlite3`, stores its cloud-assigned `device_id` in that
-database, connects the resulting devices, and starts heartbeat/claim loops.
-
-Keep the identity file and device configuration database on persistent edge
-storage with permissions limited to the service account. The identity file is
-a bearer secret: do not put it in an image, repository, log, or general backup.
-After successful enrollment, remove `HOST_AGENT_ENROLLMENT_TOKEN` from the edge
-environment. An intact identity file is sufficient for restart; if only a
-device mapping is lost, device enrollment reconstructs the same cloud ID.
-
-Enrollment tokens are one-time even when they remain in Cloud API environment
-configuration: their consumed digest is stored in the database. Reusing a token
-for another edge instance returns a conflict. Create a distinct token for every
-edge installation.
-
-Explicit `HOST_AGENT_HOST_ID` plus `HOST_AGENT_TOKEN` takes precedence and keeps
-the previous legacy behavior, including locally selected device IDs. This is
-the rollback and staged-migration path for existing deployments.
-
-## Self-Service Edge Enrollment (Zero-Token)
+## Direct Edge Enrollment
The Host Agent's default `HOST_AGENT_CONTROL_PLANE_URL` is
`https://amcp.home.jerryyan.top`. This is a single-operator home deployment
default; override the environment variable for local/dev/test runs pointed at
a different Cloud API.
-When `HOST_AGENT_ENROLLMENT_TOKEN` is not set and no cached identity exists,
-the Host Agent enrolls with no bearer credential at all. The Cloud API only
-accepts that request when `CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED=true`
-(default `false`); a configured enrollment token, if presented, always takes
-priority over self-service. This trades away any approval step: **any caller
-that can reach the control-plane URL can register itself as a new Host.**
-There is no rate limiting or throttling on this path by design — the intended
-mitigation is network-perimeter control (firewall/reverse-proxy access to the
-URL), not an in-process limiter. Only enable the flag on a deployment where
-that network boundary is enforced.
+When no cached identity exists, the Host Agent creates a random instance
+identifier and Host secret, registers directly with the Cloud API, persists the
+assigned `host_id`, and uses that secret for all later device enrollment,
+heartbeat, claim, renewal, and result calls. The Cloud stores only the secret
+digest. There is no configured enrollment-token or static Host-credential path.
+
+This trades away any approval step: **any caller that can reach the control
+plane can register itself as a new Host.** There is no rate limiting or
+throttling on this path by design; restrict access at the firewall or reverse
+proxy before exposing the endpoint.
Before the Host Agent's first unattended start, create the one-time local
operator account interactively:
@@ -124,6 +86,54 @@ docker compose run --rm host-agent device-host-agent setup
docker compose up -d
```
+Keep `HOST_AGENT_IDENTITY_PATH`, the local-account file, and
+`tasks/device_config.sqlite3` on persistent storage with permissions limited to
+the service account. The identity file is a bearer secret: do not put it in an
+image, repository, log, or general backup.
+
+### Local Web Console
+
+The Host Agent can optionally serve a small local-only web console on the
+edge machine: heartbeat/enrollment status, registered local devices, current
+assignment progress, local device add/edit/remove, a local account password
+change, and recent assignment/heartbeat history. It authenticates with the
+same local account created by `device-host-agent setup` above — there is no
+separate console credential.
+
+```text
+HOST_AGENT_CONSOLE_ENABLED=false
+HOST_AGENT_CONSOLE_BIND_HOST=127.0.0.1
+HOST_AGENT_CONSOLE_PORT=8765
+HOST_AGENT_CONSOLE_ALLOW_NON_LOOPBACK=false
+HOST_AGENT_CONSOLE_SESSION_TTL_SECONDS=43200
+HOST_AGENT_CONSOLE_HISTORY_LIMIT=200
+```
+
+- `HOST_AGENT_CONSOLE_ENABLED` — starts the console when `true`; disabled by
+ default, so existing deployments see no new listening port.
+- `HOST_AGENT_CONSOLE_BIND_HOST` — the address the console binds to; defaults
+ to loopback-only.
+- `HOST_AGENT_CONSOLE_PORT` — the TCP port the console listens on.
+- `HOST_AGENT_CONSOLE_ALLOW_NON_LOOPBACK` — required opt-in before
+ `HOST_AGENT_CONSOLE_BIND_HOST` may be a non-loopback address; the Host
+ Agent refuses to start otherwise.
+- `HOST_AGENT_CONSOLE_SESSION_TTL_SECONDS` — sliding idle timeout, in seconds,
+ for an authenticated console session.
+- `HOST_AGENT_CONSOLE_HISTORY_LIMIT` — number of recent assignment/heartbeat
+ entries the console retains before pruning older ones.
+
+Treat `HOST_AGENT_CONSOLE_ALLOW_NON_LOOPBACK` as an explicit,
+operator-accepted risk: the console has no built-in TLS and no rate
+limiting, so a non-loopback bind exposes an unencrypted login form to
+whatever network can reach that port. To reach the console from another
+machine instead, keep it bound to loopback and open an SSH local
+port-forward to the edge machine:
+
+```bash
+ssh -L 8765:127.0.0.1:8765 user@edge-host
+# then open http://127.0.0.1:8765 from the local browser
+```
+
## PostgreSQL Deployment
Start from `.env.example`, replace every `change-me-*` value, and keep the
@@ -151,10 +161,10 @@ the pytest suite (`-m "not integration"`), builds the image from the same
`device-host-agent --help`), and pushes `/:-`
plus `:latest` to the configured registry.
-`compose.deploy.yaml` is the same three-service stack as `compose.yaml`
-except `cloud-api` and `host-agent` reference `image:` instead of `build:`.
-Set `REGISTRY`, `IMAGE_NAME`, and `IMAGE_TAG` (see `.env.example`) to the tag
-Jenkins published, then deploy without a local build step:
+`compose.deploy.yaml` contains only PostgreSQL and the Cloud API; Host Agents
+run at their edge sites rather than beside the Cloud API. It references the
+fixed Jenkins registry image and interpolates only `IMAGE_TAG` (see
+`.env.example`). Deploy without a local build step:
```bash
docker compose -f compose.deploy.yaml pull
@@ -172,36 +182,22 @@ uv run --package device-cloud-api device-cloud-api --host 0.0.0.0 --port 8001
```
Production startup requires `CLOUD_ENVIRONMENT=production`, a current schema,
-and at least one configured bearer credential.
+and HTTPS before browser sessions are exposed. It does not require credential
+JSON. Create the first administrator interactively after startup, before
+opening the Console to operators.
-## Credentials And Scopes
+## Authentication And Host Identities
-`CLOUD_PUBLIC_CREDENTIALS_JSON` is a JSON array of public API principals. Grant
-only the scopes required by each integration:
+Cloud users are the human authorization boundary. Their `viewer`, `operator`,
+and `admin` roles map to the existing API scopes, and the browser sends an
+`HttpOnly` session cookie plus CSRF proof for unsafe operations. The deployment
+does not accept `CLOUD_PUBLIC_CREDENTIALS_JSON`,
+`CLOUD_HOST_CREDENTIALS_JSON`, or `CLOUD_ENROLLMENT_TOKENS_JSON`.
-- `tasks:submit`: submit tasks.
-- `tasks:read`: read task status and failure metadata.
-- `pool:read`: list hosts and devices.
-- `plugins:read`: list installed plugin registrations.
-- `plugins:admin`: register installed plugin entry points.
-
-`CLOUD_HOST_CREDENTIALS_JSON` contains Host Agent principals. Every entry must
-include exactly one `host_id`; its token is valid only for heartbeat, claim,
-renewal, and result operations for that host.
-
-`CLOUD_ENROLLMENT_TOKENS_JSON` contains bootstrap principals with only
-`principal_id` and `token`. These credentials cannot submit tasks, read the
-pool, or operate as a Host; they can only create one durable Host binding.
-Use high-entropy values generated by the deployment secret manager.
-
-`CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED` (default `false`) additionally accepts
-Host enrollment requests with no bearer token at all — see
-[Self-Service Edge Enrollment](#self-service-edge-enrollment-zero-token).
-
-Do not place bearer tokens in command history, image layers, Compose files, or
-logs. Use environment injection or the deployment platform's secret manager.
-Rotate a token by deploying the updated Cloud API credential set and Host Agent
-configuration together.
+A fresh Host sends its generated candidate secret only during direct
+registration. The Cloud stores its digest and returns a `host_id`; later Host
+operations use that secret and are strictly bound to the returned `host_id`.
+Protect the persisted Host identity file as a bearer secret.
Dynamically enrolled Host credentials are stored as digests in the cloud
database. This release exposes repository-level revocation rather than a public
@@ -230,12 +226,10 @@ PY
## Cloud Console (Web UI)
The repository ships an independent Vue 3 + Vite SPA at `cloud-console/` that
-renders task history, devices, hosts, plugins, and the user directory. Human
-operators sign in with a username and password; the Cloud API creates an
-expiring, revocable `HttpOnly` session cookie and uses a separate CSRF
-cookie/header for writes. Existing bearer tokens remain available through the
-Console's explicit **Use API token** action and for SDK, Host Agent, and
-automation compatibility.
+renders task history, devices, hosts, and plugins. Human operators sign in with
+a username and password; the Cloud API creates an expiring, revocable
+`HttpOnly` session cookie and uses a separate CSRF cookie/header for writes.
+The Console has no bearer-token fallback or user-directory view.
### HTTPS and session configuration
@@ -258,7 +252,9 @@ CLOUD_TRUST_PROXY_HEADERS=false
```
Set `CLOUD_TRUST_PROXY_HEADERS=true` only when a trusted proxy overwrites
-`X-Forwarded-For` before requests reach the Cloud API.
+`X-Forwarded-For` before requests reach the Cloud API. This affects the
+client-address bucket used for login throttling; the default is safe when the
+application is reached directly.
### Create and recover administrator accounts
@@ -281,11 +277,11 @@ docker compose exec cloud-api device-cloud-admin users revoke-sessions --usernam
```
Roles are fixed: `viewer` can read tasks/pool/plugins; `operator` additionally
-submits tasks; `admin` has unrestricted Cloud API access and manages users.
-Administrators create users, reset passwords, change roles, disable accounts,
-and revoke sessions from the **Users** Console view. New and reset users must
-change their temporary password before accessing other resources, and the API
-will not disable or demote the last enabled administrator.
+submits tasks; `admin` has unrestricted Cloud API access. The administration
+CLI creates users, resets passwords, changes roles, enables accounts, and
+revokes sessions. New and reset users must change their temporary password
+before accessing other resources, and the API will not disable or demote the
+last enabled administrator.
### Configure the CORS allow-list
@@ -328,22 +324,20 @@ at build time. The deployed origin must be in `CLOUD_CONSOLE_CORS_ORIGINS`.
### Same-origin deployment (baked into the Cloud API image)
-The Jenkins-built Docker image already carries the SPA at `/app/console-static`,
-and `compose.yaml` / `compose.deploy.yaml` set
-`CLOUD_CONSOLE_STATIC_DIR=/app/console-static` on the `cloud-api` service. In
-this mode the Cloud API itself serves the console at `/console/` (visiting `/`
-307-redirects there). Put that origin behind an HTTPS reverse proxy, then open
-for example `https://cloud.example.com/` directly — no separate dev server, no
-static host, and no CORS allow-list are needed because the SPA and API share one
-origin.
+The Jenkins-built Docker image already carries the SPA at `/app/console-static`
+and sets `CLOUD_CONSOLE_STATIC_DIR` in the image itself. The Cloud API serves
+the Console at `/console/` (visiting `/` 307-redirects there). Put that origin
+behind an HTTPS reverse proxy, then open for example
+`https://cloud.example.com/` directly — no separate dev server, static host, or
+CORS allow-list is needed because the SPA and API share one origin.
The SPA shell (`index.html`, JS, CSS) is served without credentials by design
so it can render the login page. All `/v1/*` resource calls remain scope-gated,
and unsafe cookie-authenticated calls require CSRF proof. The browser receives
only the non-secret CSRF value; it never receives the `HttpOnly` session secret.
-To opt out (e.g. for local development where you run `npm run dev`), leave
-`CLOUD_CONSOLE_STATIC_DIR` unset. The mount is conditional on that env var.
+Source-based local development leaves `CLOUD_CONSOLE_STATIC_DIR` unset; the
+mount remains conditional on that image-provided setting.
Jenkins build args (`NODE_IMAGE`, `NPM_REGISTRY`, `UV_IMAGE`, `APT_MIRROR`,
`UV_INDEX_URL`) default to CN mirrors so builds don't time out pulling from
@@ -406,9 +400,9 @@ For rollback:
1. Stop all Host Agents and the Cloud API.
2. Back up PostgreSQL or the SQLite database file.
-3. Before rolling back to a release without enrollment support, provision
- temporary static Host credentials for every managed edge that must continue
- operating. Stop those Host Agents and set their explicit Host ID/token.
+3. Before rolling back to an image that still requires static credentials,
+ restore that image's matching deployment configuration and provision the
+ required legacy credentials outside this release's Compose contract.
4. If the previous application version cannot use the current schema, run the
tested downgrade while no application process is connected:
@@ -417,7 +411,7 @@ For rollback:
```
5. Restore the previous application image or checkout and start the Cloud API.
-6. Verify `/health/ready`, then restart Host Agents with credentials compatible
+6. Verify `/health/ready`, then restart Host Agents with identities compatible
with the restored Cloud API.
Downgrading revision 0002 removes dynamic credential bindings and durable
diff --git a/docs/MACOS_IPHONE_SETUP.md b/docs/MACOS_IPHONE_SETUP.md
index af1eb7a..7ac7d42 100644
--- a/docs/MACOS_IPHONE_SETUP.md
+++ b/docs/MACOS_IPHONE_SETUP.md
@@ -376,10 +376,8 @@ uv run --package device-host-agent device-host-agent setup
`HOST_AGENT_CONTROL_PLANE_URL` 默认已固定为 `https://amcp.home.jerryyan.top`;
仅在连接其他云端环境(如本地/测试用的 `cloud-api`)时才需要覆盖该变量。未设置
-`HOST_AGENT_ENROLLMENT_TOKEN` 时,Host Agent 会直接向云端发起零 token 自助注册
-(需要云端 `CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED=true`);若仍持有云端管理员
-签发的一次性 enrollment token,也可以继续设置 `HOST_AGENT_ENROLLMENT_TOKEN`
-走原有的 token 注册路径:
+缓存身份不存在时,Host Agent 会直接向云端注册,由云端返回 `host_id`;后续运行
+使用本地持久化的随机 Host secret。无需配置静态 Host 或 enrollment token:
```bash
export HOST_AGENT_IDENTITY_PATH="tasks/host_identity.json"
@@ -394,17 +392,47 @@ uv run --package device-host-agent device-host-agent
首次启动顺序为:持久化候选 Host secret、向云端换取 `host_id`、为每个本地设备
换取 `device_id`、保存映射、连接 WDA、发送 heartbeat、开始 long-poll 领取任务。
-Host Agent 不监听入站端口。若使用了一次性 enrollment token,成功后可以将其从
-边缘环境移除;但必须保留并保护 `tasks/host_identity.json` 和
+Host Agent 不监听入站端口。必须保留并保护 `tasks/host_identity.json` 和
`tasks/device_config.sqlite3`;前者等同于 Host bearer credential。
-零 token 自助注册意味着任何能访问该云端地址的设备都能自行注册成为 Host,没有
-审批环节,也没有限流保护;这一取舍依赖网络边界(防火墙/反向代理)而非应用层
-限制,仅应在受控网络中开启 `CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED`。
+直接注册意味着任何能访问该云端地址的设备都能自行注册成为 Host,没有审批环节,
+也没有限流保护;这一取舍依赖网络边界(防火墙/反向代理)而非应用层限制。
-需要回滚到静态模式时,停止 Host Agent,由云端管理员配置匹配的静态 Host
-credential,然后显式设置 `HOST_AGENT_HOST_ID` 与 `HOST_AGENT_TOKEN`。这两个变量
-同时存在时优先于 enrollment state。
+### 启用本地 Web Console(可选)
+
+Host Agent 内置一个默认关闭的本地 Web Console,用于在这台 Mac 上直接查看和管理
+正在运行的 Host,无需 SSH 进去读原始状态文件。启用时追加环境变量后再启动:
+
+```bash
+export HOST_AGENT_CONSOLE_ENABLED="true"
+
+uv run --package device-host-agent device-host-agent
+```
+
+不要修改 `HOST_AGENT_CONSOLE_BIND_HOST`,保持默认回环地址 `127.0.0.1`;Console
+启动后在同一台 Mac 上打开:
+
+```
+http://127.0.0.1:8765
+```
+
+`8765` 是 `HOST_AGENT_CONSOLE_PORT` 的默认值(见
+`apps/device-host-agent/host_agent/config.py`)。登录使用与
+`uv run --package device-host-agent device-host-agent setup` 创建的同一个本地账号,
+没有单独的 Console 账号体系。
+
+登录后可以看到:
+
+- 状态仪表盘:最近一次 heartbeat 结果与时间、enrollment/identity 状态、本地登记
+ 设备及其连接状态、当前 assignment 执行进度。
+- 本地设备管理:新增、编辑、删除登记在这台 Host 上的设备,改动会立即在运行中的
+ `DeviceManager` 上生效,无需重启 Host Agent。
+- 修改密码:更新本地操作账号密码,需要先输入当前密码。
+- 最近历史:近期 assignment 与 heartbeat 的执行记录。
+
+完整的 `HOST_AGENT_CONSOLE_*` 环境变量列表(端口、非回环 bind 的显式 opt-in、
+session TTL、历史记录条数上限等)参见 `docs/CLOUD_DEPLOYMENT.md`;生产/远程场景下
+应优先使用 SSH 端口转发访问该 Console,而不是直接把它暴露到非回环地址。
## 10. 多设备与端口
diff --git a/openspec/changes/host-agent-local-console/.openspec.yaml b/openspec/changes/host-agent-local-console/.openspec.yaml
new file mode 100644
index 0000000..b119b63
--- /dev/null
+++ b/openspec/changes/host-agent-local-console/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-07-13
diff --git a/openspec/changes/host-agent-local-console/design.md b/openspec/changes/host-agent-local-console/design.md
new file mode 100644
index 0000000..ae41722
--- /dev/null
+++ b/openspec/changes/host-agent-local-console/design.md
@@ -0,0 +1,71 @@
+## Context
+
+`apps/device-host-agent` is currently a headless asyncio process (`HostAgentApplication.run_async` in `host_agent/app.py`): a heartbeat task and a claim/execute/report loop, both driven purely by outbound HTTP calls to the Cloud API. It has no inbound listener of any kind, and its `pyproject.toml` declares only `httpx`, `device-agent-runtime`, and `device-cloud-platform` as direct dependencies — no web framework.
+
+The root `device-agent-runtime` package (a workspace dependency of `device-host-agent`) already depends on `fastapi>=0.115.0` and `uvicorn[standard]>=0.30.0` (used by `api/rest.py` for the local single-machine Runtime API and by `console/`'s backend). Those packages are therefore already present in the resolved `uv.lock` and importable from `device-host-agent` today, even though `device-host-agent` does not declare them directly.
+
+Local operator-facing state currently lives in three separate stores on the edge machine: `host_agent/local_account.py::LocalAccountStore` (PBKDF2 credential), `host_agent/identity.py::HostIdentityStore` (enrollment identity/host_id), and `storage/device_config.py::DeviceConfigStore` (locally registered devices, SQLite). None of it is visible or editable except by reading/editing these files directly or via `device-host-agent setup` (account creation only). `openspec/changes/edge-host-self-enrollment` (not yet implemented) defines the CLI-only first-account-creation flow; this change does not alter that requirement.
+
+## Goals / Non-Goals
+
+**Goals:**
+- Give an operator on the edge machine a same-host web page to see heartbeat/enrollment/device/assignment status at a glance, and to perform the small set of actions that currently require hand-editing files: add/edit/remove a local device, change the local account password, review recent assignment/heartbeat outcomes.
+- Keep the default deployment posture unchanged: console off by default; when enabled, bound to loopback only unless the operator explicitly opts into a wider bind address.
+- Reuse the existing local account as the only credential — no second user/credential system.
+- Server-rendered HTML, not a SPA: no new frontend build tooling, no JS framework, minimal inline `fetch()` calls only for the few sections that benefit from polling refresh (heartbeat status, current assignment progress).
+
+**Non-Goals:**
+- No TLS termination, reverse proxy, or certificate management built into the Host Agent — LAN exposure beyond loopback is an explicit, documented operator opt-in with the risk called out, not a feature this change makes safe by default.
+- No multi-user accounts, roles, or audit logging beyond the bounded local history — the existing `local_account.py` model is single-account, and this change keeps it that way.
+- No syncing of the new local assignment/heartbeat history to the Cloud Console; it is a purely local, best-effort operational aid, not a system of record (the Cloud API/`cloud-console` already own durable task/attempt history).
+- No change to the outbound `host-agent-protocol` capability or to how devices are enrolled with the cloud; the console only calls the same local `DeviceConfigStore`/enrollment client code paths that `host_agent/app.py::_configured_device_manager` already uses at startup.
+
+## Decisions
+
+### Reuse FastAPI + Starlette's `HTMLResponse`, not a new micro-framework, not Jinja2
+FastAPI/Starlette are already transitively resolved via `device-agent-runtime`. Adding them as **explicit** direct dependencies of `device-host-agent` (rather than relying on the transitive edge) is the only `pyproject.toml` change needed — no new third-party web framework enters the dependency graph. Pages are built with small Python functions returning `HTMLResponse(content=...)` from hand-written f-string templates with `html.escape()` on every interpolated value (no Jinja2: the page count is small — login, dashboard, devices, history — and a templating engine is unjustified surface area for a handful of server-rendered fragments). This matches the user's explicit direction: server-rendered pages, not the `console/`/`cloud-console/` SPA pattern, and not a new heavyweight dependency.
+
+Alternative considered: `http.server`/stdlib-only implementation. Rejected — would duplicate routing, form parsing, and cookie handling that FastAPI/Starlette already provide for free given they're already in the dependency graph.
+
+### Run the console server in the same asyncio loop as the heartbeat/claim loop
+`HostAgentApplication.run_async` gains a third concurrent task (alongside `heartbeat_task` and the claim/process loop) that runs a `uvicorn.Server` configured with `install_signal_handlers=False` when `config.console_enabled`. It is started and stopped using the same `stop_requested`/`finally` shutdown sequence already used for the heartbeat task, so `Ctrl+C`/service-stop behavior is unchanged when the console is off (the default) and cleanly tears down the extra task when it's on.
+
+Alternative considered: separate process/thread running its own event loop. Rejected — the console needs live references to the same `DeviceManager`, `HostAgentClient`, and in-flight assignment state that the main loop owns; a separate process would need its own IPC layer to read that state, which is unjustified complexity for a same-host admin page.
+
+### Cookie session issued at login, not per-request Basic Auth
+Login is a normal HTML form POST to `/login` that calls `LocalAccountStore.verify()` once (PBKDF2, 600 000 iterations — intentionally expensive, on the order of ~100ms+, which is fine for one login but would be a real cost if paid on every polled `fetch()`). On success the server issues a random opaque session token (`secrets.token_urlsafe`), stored in an in-memory `dict[str, SessionState]` (process-local; a restart invalidates all sessions, which is acceptable for a single-operator local admin page), and sets it as an `HttpOnly`, `SameSite=Strict`, `Secure`-when-not-loopback session cookie with a sliding expiry (e.g. 12h idle timeout). All other routes require a valid session and redirect to `/login` otherwise. This mirrors the cookie+session shape already validated in the sibling `cloud-console-user-authentication` change, applied here to a single local account instead of a multi-user table.
+
+### CSRF token bound to the session, required on all mutating requests
+Because authentication is a cookie the browser attaches automatically, every state-changing endpoint (device add/edit/remove, password change, logout) requires a per-session CSRF token — rendered into the page/forms and also required as a request header on the small number of `fetch()`-based mutations — checked against the value stored alongside the session. Read-only status/history polling endpoints do not require it.
+
+### Local device CRUD updates the live `DeviceManager` in the same request, not just `DeviceConfigStore`
+`device/manager.py::DeviceManager` already exposes `register_device`/`unregister_device`. The console's device-CRUD handlers call the same sequence `host_agent/app.py::_configured_device_manager` uses at startup (persist to `DeviceConfigStore`, call `enrollment_client.enroll_device(...)` when `config.enrollment_managed`, then `manager.register_device(...)`) so a device added or removed through the web page takes effect immediately, without requiring a Host Agent restart. This existing sequence is extracted into a small shared helper used by both the startup path and the new console routes, rather than duplicated.
+
+### New bounded local history store for recent assignments/heartbeats
+The Host Agent currently discards assignment outcomes once reported to the control plane and keeps no heartbeat history at all. A new local-only SQLite table (e.g. `tasks/host_console_history.sqlite3`, following the existing `storage.device_config` pattern of a small dedicated SQLite file under `tasks/`) records the last N (configurable, default e.g. 200) assignment results and heartbeat syncs. `AssignmentProcessor` and `HeartbeatSynchronizer` accept an optional recorder callback (no-op when the console is disabled, so there is zero overhead in the default configuration) that appends a row after each terminal report / heartbeat sync; the console's history page reads from this table. Retention is enforced by pruning beyond the configured cap on write, not by a separate cron/background task.
+
+### Config additions, all opt-in and backward compatible
+`HostAgentConfig` gains: `console_enabled: bool = False`, `console_bind_host: str = "127.0.0.1"`, `console_port: int = 8765`, `console_allow_non_loopback: bool = False`, `console_session_ttl_seconds: float = 43200.0` (12h), `console_history_limit: int = 200` — all with matching `HOST_AGENT_CONSOLE_*` environment variables following the existing `_positive_float`/`_positive_int` validation helpers in `config.py`. Loading raises `HostAgentConfigurationError` if `console_bind_host` resolves to a non-loopback address while `console_allow_non_loopback` is not set, so the risky configuration requires two explicit affirmative settings, not one.
+
+## Risks / Trade-offs
+
+- **[Risk] No TLS by default; a non-loopback bind sends the session cookie and form-posted password over cleartext HTTP on the LAN.** → Mitigation: loopback-only by default; non-loopback requires the explicit second opt-in flag; document the recommended alternative (SSH local port-forward to keep the console loopback-only while still reachable remotely) in `docs/CLOUD_DEPLOYMENT.md`/`docs/MACOS_IPHONE_SETUP.md` rather than building TLS support into this change.
+- **[Risk] In-memory session store means every Host Agent process restart forces re-login.** → Accepted: restarts are infrequent for a background service and re-login is a low-friction PBKDF2 verify; avoids adding a persistent session store and its own cleanup/expiry code for a single-operator page.
+- **[Risk] Blocking SQLite calls (`DeviceConfigStore`, new history store) on the same asyncio loop that runs heartbeat/claim could add latency under concurrent console use.** → Mitigation: wrap console route handlers' store calls in `asyncio.to_thread`, consistent with the existing `asyncio.to_thread(self.executor.execute, ...)` pattern in `host_agent/lease.py`; SQLite operations here are small and infrequent (one operator, occasional page loads) so this is a low-severity concern even without the wrapping, but the pattern costs nothing to apply consistently.
+- **[Trade-off] Hand-written HTML via f-strings instead of a templating engine is more verbose per-page and pushes escaping discipline onto the author.** → Mitigation: a single small `escape()`-wrapping helper used for every interpolated value, and a lint/review checklist item (covered in tasks.md) rather than relying on an engine's autoescaping; the page count is small enough that this remains manageable.
+- **[Trade-off] New local-only history duplicates, in miniature, information the Cloud Console already owns durably.** → Accepted: this history exists specifically for operators without (or before) Cloud Console access, or debugging when the control plane itself is unreachable; it is explicitly not a system of record (Non-Goals).
+
+## Migration Plan
+
+1. Add `fastapi`/`uvicorn[standard]` as explicit direct dependencies in `apps/device-host-agent/pyproject.toml` (versions already pinned in the shared `uv.lock` via the transitive edge — no version drift expected).
+2. Add the new `HostAgentConfig` fields with the safe defaults above; existing deployments that don't set any `HOST_AGENT_CONSOLE_*` variable see no behavior change.
+3. Implement the console module and wire its optional startup/shutdown into `HostAgentApplication.run_async`, gated on `config.console_enabled`.
+4. Add the new bounded history store and the optional recorder hooks to `AssignmentProcessor`/`HeartbeatSynchronizer`, no-op by default.
+5. Document how to enable the console (env vars, loopback-only default, SSH port-forward recommendation for remote access) in `docs/CLOUD_DEPLOYMENT.md` and `docs/MACOS_IPHONE_SETUP.md`.
+6. Rollback: unset/leave `HOST_AGENT_CONSOLE_ENABLED` at its default `false`. No schema or state migration is introduced for existing stores (`DeviceConfigStore`, `LocalAccountStore`, `HostIdentityStore` are all read via their existing APIs, unchanged); the new history SQLite file is purely additive and can be deleted with no effect on Host Agent operation.
+
+## Open Questions
+
+- Exact default/max value for `console_history_limit` (row cap) — proposed default 200, may need tuning once real usage is observed.
+- Whether a future change should let the Cloud Console optionally pull this local history for remote debugging (explicitly out of scope here; would need a new outbound protocol surface and its own review).
+- Whether single-shared-account is sufficient long-term for edge machines with multiple physical operators, or whether that should be revisited alongside any future change to `local_account.py`'s single-account model.
diff --git a/openspec/changes/host-agent-local-console/proposal.md b/openspec/changes/host-agent-local-console/proposal.md
new file mode 100644
index 0000000..c6edc41
--- /dev/null
+++ b/openspec/changes/host-agent-local-console/proposal.md
@@ -0,0 +1,25 @@
+## Why
+
+`apps/device-host-agent` is a headless outbound worker: it has no HTTP server, no static assets, and no way to inspect or manage a running instance except by reading log output or editing `tasks/*.sqlite3`/`tasks/*.json` files by hand on the edge machine. Operators installing a new Host on an edge device (Mac/iPhone rig, etc.) currently must use `device-host-agent setup` (terminal-only, `getpass`) to create the local account, and have no local way to see heartbeat/enrollment status, review or edit locally registered devices, or check why the last assignment failed, without SSH-ing in and reading raw state files or cross-referencing the Cloud Console (which only shows what the Host last reported, not local-only state like unenrolled devices). A minimal local web page closes that operational gap.
+
+## What Changes
+
+- Add an embedded, server-rendered local web console to the Host Agent process: plain HTML responses from a lightweight HTTP server (no separate frontend build, no SPA framework), with a handful of endpoints returning small JSON fragments that a few inline `