diff --git a/apps/device-host-agent/host_agent/app.py b/apps/device-host-agent/host_agent/app.py index a109d49..9e161e9 100644 --- a/apps/device-host-agent/host_agent/app.py +++ b/apps/device-host-agent/host_agent/app.py @@ -116,6 +116,11 @@ class HostAgentApplication: return await claim +class _EmbeddedConsoleServer(uvicorn.Server): + def install_signal_handlers(self) -> None: + """Host Agent owns process-level signal handling.""" + + def create_application( *, config: HostAgentConfig | None = None, @@ -148,61 +153,49 @@ def create_application( bootstrap_client.close() client = HostAgentClient(resolved_config) - history_store: ConsoleHistoryStore | None = None - status_tracker: AgentStatusTracker | None = None - console_server: uvicorn.Server | None = None + history_store = ConsoleHistoryStore( + resolved_config.identity_path.parent / "host_console_history.sqlite3", + limit=resolved_config.console_history_limit, + ) + status_tracker = AgentStatusTracker() console_enrollment_client: HostAgentEnrollmentClient | None = None - if resolved_config.console_enabled: - history_store = ConsoleHistoryStore( - resolved_config.identity_path.parent / "host_console_history.sqlite3", - limit=resolved_config.console_history_limit, - ) - status_tracker = AgentStatusTracker() - if resolved_config.enrollment_managed: - console_enrollment_client = HostAgentEnrollmentClient(resolved_config) - console_app = create_console_app( - config=resolved_config, - manager=resolved_manager, - config_store=config_store, - local_account_store=LocalAccountStore(resolved_config.local_account_path), - identity_store=resolved_identity_store, - history_store=history_store, - status_tracker=status_tracker, - session_manager=SessionManager( - ttl_seconds=resolved_config.console_session_ttl_seconds - ), - enrollment_client=console_enrollment_client, - ) - console_server = uvicorn.Server( - uvicorn.Config( - console_app, - host=resolved_config.console_bind_host, - port=resolved_config.console_port, - log_level="warning", - ) + if resolved_config.enrollment_managed: + console_enrollment_client = HostAgentEnrollmentClient(resolved_config) + console_app = create_console_app( + config=resolved_config, + manager=resolved_manager, + config_store=config_store, + local_account_store=LocalAccountStore(resolved_config.local_account_path), + identity_store=resolved_identity_store, + history_store=history_store, + status_tracker=status_tracker, + session_manager=SessionManager( + ttl_seconds=resolved_config.console_session_ttl_seconds + ), + enrollment_client=console_enrollment_client, + ) + console_server = _EmbeddedConsoleServer( + uvicorn.Config( + console_app, + host=resolved_config.console_bind_host, + port=resolved_config.console_port, + log_level="warning", ) + ) heartbeat = HeartbeatSynchronizer( resolved_manager, client, resolved_config, status_tracker=status_tracker, - on_sync=( - ( - lambda device_count: history_store.record_heartbeat( - device_count=device_count - ) - ) - if history_store is not None - else None + on_sync=lambda device_count: history_store.record_heartbeat( + device_count=device_count ), policy_cache=HostPolicyCacheStore( resolved_config.identity_path.parent / "host_governance_policy.json" ), - on_policy_sync=( - (lambda revision: history_store.record_policy_sync(revision=revision)) - if history_store is not None - else None + on_policy_sync=lambda revision: history_store.record_policy_sync( + revision=revision ), ) executor = AssignmentExecutor( @@ -216,14 +209,8 @@ def create_application( client, active_runner, status_tracker=status_tracker, - on_result=( - ( - lambda assignment, result: _record_assignment_history( - history_store, assignment, result - ) - ) - if history_store is not None - else None + on_result=lambda assignment, result: _record_assignment_history( + history_store, assignment, result ), ) return HostAgentApplication( diff --git a/apps/device-host-agent/host_agent/config.py b/apps/device-host-agent/host_agent/config.py index 783ac7e..c6dc390 100644 --- a/apps/device-host-agent/host_agent/config.py +++ b/apps/device-host-agent/host_agent/config.py @@ -29,7 +29,6 @@ class HostAgentConfig: retry_backoff_seconds: float = 1.0 max_retry_backoff_seconds: float = 30.0 max_retry_attempts: int = 5 - console_enabled: bool = False console_bind_host: str = "127.0.0.1" console_port: int = 8765 console_allow_non_loopback: bool = False @@ -96,7 +95,6 @@ def load_host_agent_config( "HOST_AGENT_MAX_RETRY_ATTEMPTS", 5, ), - console_enabled=_truthy(values, "HOST_AGENT_CONSOLE_ENABLED", False), console_bind_host=values.get( "HOST_AGENT_CONSOLE_BIND_HOST", "127.0.0.1" ).strip(), @@ -122,7 +120,7 @@ def load_host_agent_config( raise HostAgentConfigurationError( "maximum retry backoff must not be less than initial backoff" ) - if config.console_enabled and ( + if ( config.console_bind_host not in _LOOPBACK_BIND_HOSTS and not config.console_allow_non_loopback ): diff --git a/apps/device-host-agent/tests/test_app.py b/apps/device-host-agent/tests/test_app.py index 7bc041a..163521d 100644 --- a/apps/device-host-agent/tests/test_app.py +++ b/apps/device-host-agent/tests/test_app.py @@ -362,7 +362,7 @@ def test_main_task_cancellation_waits_for_active_work_shutdown() -> None: asyncio.run(scenario()) -def test_console_enabled_serves_http_and_shuts_down_cleanly(tmp_path) -> None: +def test_console_serves_http_and_shuts_down_cleanly(tmp_path) -> None: async def scenario() -> None: port = _free_loopback_port() config = HostAgentConfig( @@ -371,7 +371,6 @@ def test_console_enabled_serves_http_and_shuts_down_cleanly(tmp_path) -> None: token="secret", identity_path=tmp_path / "host_identity.json", local_account_path=tmp_path / "host_local_account.json", - console_enabled=True, console_bind_host="127.0.0.1", console_port=port, ) @@ -449,9 +448,9 @@ def test_console_enabled_serves_http_and_shuts_down_cleanly(tmp_path) -> None: asyncio.run(scenario()) -def test_console_disabled_by_default_opens_no_socket(tmp_path, monkeypatch) -> None: +def test_console_is_created_by_default(tmp_path, monkeypatch) -> None: monkeypatch.chdir(tmp_path) application = create_application(config=_config(), manager=DeviceManager()) - assert application.console_server is None + assert application.console_server is not None asyncio.run(application.client.aclose()) diff --git a/apps/device-host-agent/tests/test_config.py b/apps/device-host-agent/tests/test_config.py index 245ac6f..904327f 100644 --- a/apps/device-host-agent/tests/test_config.py +++ b/apps/device-host-agent/tests/test_config.py @@ -104,10 +104,9 @@ def test_load_host_agent_config_rejects_invalid_values( load_host_agent_config(overrides) -def test_console_defaults_are_disabled_and_do_not_trigger_validation() -> None: +def test_console_defaults_are_loopback_bound() -> None: config = load_host_agent_config({}) - assert config.console_enabled is False assert config.console_bind_host == "127.0.0.1" assert config.console_port == 8765 assert config.console_allow_non_loopback is False @@ -115,18 +114,10 @@ def test_console_defaults_are_disabled_and_do_not_trigger_validation() -> None: assert config.console_history_limit == 200 -def test_console_enabled_with_default_loopback_bind_passes() -> None: - config = load_host_agent_config({"HOST_AGENT_CONSOLE_ENABLED": "true"}) - - assert config.console_enabled is True - assert config.console_bind_host == "127.0.0.1" - - @pytest.mark.parametrize("bind_host", ["127.0.0.1", "localhost", "::1"]) -def test_console_enabled_with_loopback_bind_host_passes(bind_host: str) -> None: +def test_console_loopback_bind_host_passes(bind_host: str) -> None: config = load_host_agent_config( { - "HOST_AGENT_CONSOLE_ENABLED": "true", "HOST_AGENT_CONSOLE_BIND_HOST": bind_host, } ) @@ -134,20 +125,18 @@ def test_console_enabled_with_loopback_bind_host_passes(bind_host: str) -> None: assert config.console_bind_host == bind_host -def test_console_enabled_with_non_loopback_bind_without_opt_in_raises() -> None: +def test_console_non_loopback_bind_without_opt_in_raises() -> None: with pytest.raises(HostAgentConfigurationError): load_host_agent_config( { - "HOST_AGENT_CONSOLE_ENABLED": "true", "HOST_AGENT_CONSOLE_BIND_HOST": "0.0.0.0", } ) -def test_console_enabled_with_non_loopback_bind_with_opt_in_succeeds() -> None: +def test_console_non_loopback_bind_with_opt_in_succeeds() -> None: config = load_host_agent_config( { - "HOST_AGENT_CONSOLE_ENABLED": "true", "HOST_AGENT_CONSOLE_BIND_HOST": "0.0.0.0", "HOST_AGENT_CONSOLE_ALLOW_NON_LOOPBACK": "true", } @@ -157,24 +146,15 @@ def test_console_enabled_with_non_loopback_bind_with_opt_in_succeeds() -> None: assert config.console_allow_non_loopback is True -def test_console_disabled_with_non_loopback_bind_does_not_raise() -> None: - config = load_host_agent_config({"HOST_AGENT_CONSOLE_BIND_HOST": "0.0.0.0"}) - - assert config.console_enabled is False - assert config.console_bind_host == "0.0.0.0" - - def test_console_env_vars_parse_numeric_and_bool_fields() -> None: config = load_host_agent_config( { - "HOST_AGENT_CONSOLE_ENABLED": "1", "HOST_AGENT_CONSOLE_PORT": "9001", "HOST_AGENT_CONSOLE_SESSION_TTL_SECONDS": "3600", "HOST_AGENT_CONSOLE_HISTORY_LIMIT": "50", } ) - assert config.console_enabled is True assert config.console_port == 9001 assert config.console_session_ttl_seconds == 3600 assert config.console_history_limit == 50 diff --git a/docs/CLOUD_DEPLOYMENT.md b/docs/CLOUD_DEPLOYMENT.md index c106cd9..8adaad0 100644 --- a/docs/CLOUD_DEPLOYMENT.md +++ b/docs/CLOUD_DEPLOYMENT.md @@ -93,7 +93,7 @@ image, repository, log, or general backup. ### Local Web Console -The Host Agent can optionally serve a small local-only web console on the +The Host Agent always serves 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 @@ -101,7 +101,6 @@ 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 @@ -109,8 +108,8 @@ 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. +- The Console starts with every Host Agent and binds to `127.0.0.1:8765` by + default. - `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. diff --git a/docs/MACOS_IPHONE_SETUP.md b/docs/MACOS_IPHONE_SETUP.md index 7ac7d42..ff49e57 100644 --- a/docs/MACOS_IPHONE_SETUP.md +++ b/docs/MACOS_IPHONE_SETUP.md @@ -392,22 +392,16 @@ uv run --package device-host-agent device-host-agent 首次启动顺序为:持久化候选 Host secret、向云端换取 `host_id`、为每个本地设备 换取 `device_id`、保存映射、连接 WDA、发送 heartbeat、开始 long-poll 领取任务。 -Host Agent 不监听入站端口。必须保留并保护 `tasks/host_identity.json` 和 +Host Agent 会在本机回环地址提供 Console。必须保留并保护 `tasks/host_identity.json` 和 `tasks/device_config.sqlite3`;前者等同于 Host bearer credential。 直接注册意味着任何能访问该云端地址的设备都能自行注册成为 Host,没有审批环节, 也没有限流保护;这一取舍依赖网络边界(防火墙/反向代理)而非应用层限制。 -### 启用本地 Web Console(可选) +### 使用本地 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 启动时会同时启动本地 Web Console,用于在这台 Mac 上直接查看和管理 +正在运行的 Host,无需 SSH 进去读原始状态文件。 不要修改 `HOST_AGENT_CONSOLE_BIND_HOST`,保持默认回环地址 `127.0.0.1`;Console 启动后在同一台 Mac 上打开: diff --git a/openspec/changes/host-agent-local-console/design.md b/openspec/changes/host-agent-local-console/design.md index ae41722..bcc76ee 100644 --- a/openspec/changes/host-agent-local-console/design.md +++ b/openspec/changes/host-agent-local-console/design.md @@ -10,7 +10,7 @@ Local operator-facing state currently lives in three separate stores on the edge **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. +- Start the Console with every Host Agent while binding it to loopback by default; an operator must explicitly opt 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). @@ -28,7 +28,7 @@ FastAPI/Starlette are already transitively resolved via `device-agent-runtime`. 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. +`HostAgentApplication.run_async` runs a third concurrent task (alongside `heartbeat_task` and the claim/process loop) for a `uvicorn.Server` configured with `install_signal_handlers=False`. It is started and stopped using the same `stop_requested`/`finally` shutdown sequence as the heartbeat task, so `Ctrl+C`/service-stop cleanly tears down the Console too. 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. @@ -44,8 +44,8 @@ Because authentication is a cookie the browser attaches automatically, every sta ### 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. +### Mandatory startup with loopback defaults +`HostAgentConfig` gains: `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` — with matching `HOST_AGENT_CONSOLE_*` environment variables except for an enable flag, because the Console is mandatory. 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 @@ -58,11 +58,11 @@ The Host Agent currently discards assignment outcomes once reported to the contr ## 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`. +2. Add the new `HostAgentConfig` fields with the safe loopback defaults above; every Host Agent starts the Console. +3. Implement the console module and wire its startup/shutdown into `HostAgentApplication.run_async` for every Host Agent process. 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. +5. Document mandatory Console startup, loopback-only defaults, and the SSH port-forward recommendation for remote access in `docs/CLOUD_DEPLOYMENT.md` and `docs/MACOS_IPHONE_SETUP.md`. +6. Rollback: deploy a prior Host Agent release if the embedded Console must be removed. 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 diff --git a/openspec/changes/host-agent-local-console/proposal.md b/openspec/changes/host-agent-local-console/proposal.md index c6edc41..5a8cfc8 100644 --- a/openspec/changes/host-agent-local-console/proposal.md +++ b/openspec/changes/host-agent-local-console/proposal.md @@ -6,14 +6,14 @@ - 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 `