Compare commits

..
145 Commits
Author SHA1 Message Date
showtan001 9076f8ddb0 feat: discover and add connected iOS devices
Tests / Test apps.device-host-agent.tests.test_mcp_token.test_load_or_create_concurrent_calls_do_not_corrupt failed
2026-09-07 18:33:49 +08:00
showtan001 8c99dc015a feat: add on-demand device screenshots
Tests / Test apps.device-host-agent.tests.test_mcp_token.test_load_or_create_concurrent_calls_do_not_corrupt failed
2026-08-31 10:43:37 +08:00
showtan001 60ee157e97 feat: preserve planner context across task steps
Tests / Test apps.device-host-agent.tests.test_mcp_token.test_load_or_create_concurrent_calls_do_not_corrupt failed
2026-08-30 22:59:19 +08:00
showtan001 dd8df33910 Log task planner conversations locally
Tests / Test apps.device-host-agent.tests.test_mcp_token.test_load_or_create_concurrent_calls_do_not_corrupt failed
2026-08-30 22:38:28 +08:00
showtan001 5458f3b8a4 Support Anthropic conversation logging and retries
Tests / Test apps.device-host-agent.tests.test_mcp_token.test_load_or_create_concurrent_calls_do_not_corrupt failed
2026-08-30 22:29:19 +08:00
showtan001 44e1a6651a Support multimodal planner scenes without OCR 2026-08-30 21:56:11 +08:00
showtan001 5b8daab457 Recover task metadata store schema on startup 2026-08-30 21:50:57 +08:00
showtan001 697e54427b Bind chat agent sessions to individual devices 2026-08-30 21:48:39 +08:00
showtan001 fdaca7539b Show local conversation activity in web console 2026-08-30 21:46:50 +08:00
showtan001 3c9e65c78e Record local agent reasoning and tool activity
Tests / Test apps.device-host-agent.tests.test_mcp_token.test_load_or_create_concurrent_calls_do_not_corrupt failed
2026-08-29 21:19:30 +08:00
showtan001 71fd182f50 Support multimodal images in local chat agent 2026-08-29 21:17:20 +08:00
showtan001 a315c62f3a Improve local agent chat and device health detection 2026-08-29 16:03:15 +08:00
showtan001 050d1329c4 Add local host mode and configurable LLM providers 2026-08-24 08:49:18 +08:00
q792602257 433ab41f95 Merge branch 'worktree-host-agent-mcp-server' — Host-Agent MCP Server
Tests / Test apps.device-host-agent.tests.test_mcp_token.test_load_or_create_concurrent_calls_do_not_corrupt failed
Adds a Streamable HTTP MCP server (mount /mcp, port 8765) to the
device-host-agent process so Hermes Agent (or any MCP client) can
drive devices directly, coexisting with the Cloud Control Plane
worker path. Per-device session-level locking with 20s TTL,
independent bearer-token auth, and bidirectional cloud ↔ MCP
coordination via a new heartbeat field.

Implementation:
- 4 new modules (mcp_token, mcp_lock, web/mcp_auth, web/mcp)
- Console mount at /mcp with bearer auth sub-app
- Cloud heartbeat payload + scheduler skip MCP-busy devices
- AssignmentExecutor fail-fast reverse check
- CLI mcp-token subcommand
- docs/MCP_INTEGRATION.md + MACOS_IPHONE_SETUP.md section

Spec: docs/superpowers/specs/2026-07-21-host-agent-mcp-server-design.md
Plan: docs/superpowers/plans/2026-07-21-host-agent-mcp-server.md

18 implementation commits ( Tasks 1-15 + final fix wave).
Spec/plan cherry-picks are detected as already-applied via patch-id.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

# Conflicts:
#	docs/superpowers/specs/2026-07-21-host-agent-mcp-server-design.md
2026-07-21 17:00:29 +08:00
q792602257andClaude Opus 4.6 70e0624a47 fix(host-agent): align MCP integration with mcp SDK 1.28.1 realities
Three final-review deviations closed:

I1 (session-end release): mcp SDK 1.28.1 exposes no per-session
shutdown callback (only a server-level lifespan). Lower the
McpBusyTracker default TTL from 60s to 20s and update spec §6.5,
Q5/R3, D9, and docs/MCP_INTEGRATION.md concurrency section to
document the TTL-only recovery path. 20s is short enough to recover
within one 30s heartbeat interval but long enough that an active
session does not lose its lease during normal operator pauses.

I2 (JSON-RPC error shape): FastMCP Tool.run wraps every non-
UrlElicitationRequiredError exception (including McpError with typed
ErrorData) into ToolError, which the lowlevel call_tool handler
serializes as CallToolResult(isError=true, content=[TextContent(...)]).
There is no public path that surfaces JSON-RPC -32000 with structured
data.busy_owner from a tool call site. Update spec §7 error matrix
and docs/MCP_INTEGRATION.md error table to document the actual wire
shape; busy_owner now lives in the text content.

I3 (typing): mcp_server: Any = None -> FastMCP | None = None via
TYPE_CHECKING, keeping the mcp import lazy (matches precedent
elsewhere in the codebase) while adding static type checking at the
create_console_app boundary.

Tests added (4):
- test_default_ttl_is_20_seconds — locks I1's new default TTL
- test_default_ttl_recovers_dead_session_within_one_window — locks
  I1's recovery semantics (lease sweeped on next read after 20s)
- test_busy_error_wire_shape_is_calltoolresult_iserror — pins I2's
  wire envelope via Tool.run + lowlevel Server._make_error_result
- test_busy_error_text_includes_cloud_assignment_owner — same for
  the cloud_assignment busy_owner branch

Full non-integration suite: 697 passed / 54 deselected (was 693 / 54).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-21 16:25:35 +08:00
q792602257andClaude Opus 4.6 e69cea0245 style: ruff format after MCP server integration
Reformat the files touched by Tasks 1-14 of the host-agent MCP server
plan. No semantic changes; pre-existing format issues in unrelated
files (test_templates, test_skill_sync_wiring, 0010_skill_management,
test_skill_catalog_mcp) left untouched for a separate housekeeping
pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-21 15:52:32 +08:00
q792602257andClaude Opus 4.6 6d9237a592 test: align skill catalog and migration tests with new MCP API
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-21 15:31:51 +08:00
q792602257 6241fb9d6d docs: add MCP integration guide 2026-07-21 15:24:16 +08:00
q792602257andClaude Opus 4.6 2d0c740c88 feat(host-agent): add mcp-token CLI subcommand
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-21 15:21:32 +08:00
q792602257 ce64c4eb47 feat(host-agent): wire MCP server into create_application 2026-07-21 15:17:28 +08:00
q792602257 ce2469616e feat(host-agent): mount /mcp + surface MCP status in console 2026-07-21 15:09:41 +08:00
q792602257andClaude Opus 4.6 dcb4798408 feat(host-agent): fail-fast cloud assignment when MCP holds device
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-21 15:00:54 +08:00
q792602257 ab15218b27 feat(host-agent): include mcp_busy_device_ids in heartbeat payload 2026-07-21 14:56:21 +08:00
q792602257 b0932dd398 feat(cloud): skip MCP-busy devices in scheduler 2026-07-21 14:37:21 +08:00
q792602257 9e3007e7f6 feat(cloud): accept mcp_busy_device_ids in heartbeat payload 2026-07-21 14:32:44 +08:00
q792602257andClaude Opus 4.6 98089b6748 fix(host-agent): use stable ServerSession id for MCP lock identity
The previous _current_session_id() implementation tried to import a
non-existent get_context() helper, so the production code path always
fell through to the empty _TEST_SESSION_ID ContextVar — meaning every
MCP client shared the empty-string identity and there was no per-session
isolation in production.

Use Context.session (the long-lived ServerSession object) as the source
of identity. id(ctx.session) is stable across every tool call the same
client makes within a Streamable HTTP session, which is exactly what the
busy tracker needs to renew leases.

Wire FastMCP to inject the Context into the wrapper by setting
tool.context_kwarg = "ctx" after swapping tool.fn; wrap the swap in a
defensive try/except that surfaces a FastMcpSdkIncompatibilityError on
future SDK layout drift.

Add 4 tests covering the production path: stability across calls in the
same session, isolation between sessions, fallback to _TEST_SESSION_ID
when no Context is supplied, and verification that the registered tool
declares context_kwarg="ctx".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-21 14:28:50 +08:00
q792602257 b73db01626 feat(host-agent): wrap tool_handlers with busy check + status mapping 2026-07-21 14:09:42 +08:00
q792602257 cf8affe4d7 feat(host-agent): add BearerAuthMiddleware for MCP server 2026-07-21 14:02:20 +08:00
q792602257 61c923b92b feat(host-agent): add McpBusyTracker for per-device session locks 2026-07-21 13:58:37 +08:00
q792602257 c7faee8da3 feat(host-agent): add McpTokenStore for MCP bearer token 2026-07-21 13:55:24 +08:00
q792602257 29b9a8c39a refactor(api): make tool_handlers require a DeviceManager
Eliminates the silent fallback to DEFAULT_MANAGER that produced the
DeviceNotFoundError incident. All existing callers already pass
manager explicitly.
2026-07-21 13:52:01 +08:00
q792602257 d1b0fffabb build(host-agent): add mcp as direct dependency 2026-07-21 13:49:17 +08:00
q792602257andClaude Opus 4.6 47eac0f2a7 docs(superpowers): add host-agent MCP server implementation plan
15-task TDD plan implementing the spec committed in 3b62195. Covers
the four new host-agent modules (mcp_token, mcp_lock, web/mcp_auth,
web/mcp), cloud heartbeat + scheduler coordination, console mount
wiring, CLI subcommand, and documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-21 13:47:15 +08:00
q792602257andClaude Opus 4.6 2f8f4a36c6 docs(superpowers): add host-agent MCP server design spec
Design for mounting a Streamable HTTP MCP server inside the host-agent
process so Hermes Agent (or any MCP client) can drive devices directly.
Reuses the existing console FastAPI + uvicorn on port 8765, adds bearer-
token auth, per-device session-level locks with 60s TTL, and cloud
coordination via a new heartbeat field. Cloud scheduler skips devices
reported as MCP-busy.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-21 13:47:15 +08:00
q792602257andClaude Opus 4.6 28dccb908c docs(superpowers): add host-agent MCP server implementation plan
15-task TDD plan implementing the spec committed in 3b62195. Covers
the four new host-agent modules (mcp_token, mcp_lock, web/mcp_auth,
web/mcp), cloud heartbeat + scheduler coordination, console mount
wiring, CLI subcommand, and documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-21 13:46:27 +08:00
q792602257andClaude Opus 4.6 3b62195a36 docs(superpowers): add host-agent MCP server design spec
Design for mounting a Streamable HTTP MCP server inside the host-agent
process so Hermes Agent (or any MCP client) can drive devices directly.
Reuses the existing console FastAPI + uvicorn on port 8765, adds bearer-
token auth, per-device session-level locks with 60s TTL, and cloud
coordination via a new heartbeat field. Cloud scheduler skips devices
reported as MCP-busy.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-21 13:18:10 +08:00
q792602257 358f4623ba feat(planner): add execution context to prompts
Tests / Test passed: 977
2026-07-16 10:25:20 +08:00
q792602257 240b7be7b8 fix(cloud-console): refresh task planner history 2026-07-16 09:35:31 +08:00
q792602257 9a17297f1e feat(perception): expose active app metadata in UI tree
Tests / Test passed: 971
2026-07-16 08:13:04 +08:00
q792602257 059fb272bb test(device-host-agent): add conftest.py to disable humanize in tests
Tests / Test passed: 966
The root tests/conftest.py fixture disables APEX_HUMANIZE_ENABLED by default for deterministic assertions, but apps/device-host-agent/tests/ was outside its scope. test_e2e.py::test_public_sdk_reports_fake_device_success_and_runtime_failure failed because tap coordinates were jittered (2.67..., 3.37...) instead of exact (2, 3).

Add the same autouse fixture to apps/device-host-agent/tests/conftest.py so all tests in that directory inherit the deterministic behavior.
2026-07-15 21:19:20 +08:00
q792602257 41006b098a feat(perception): sample OCR text foreground/background colors
Tests / Test apps.device-host-agent.tests.test_e2e.test_public_sdk_reports_fake_device_success_and_runtime_failure failed
PaddleOCR itself returns no color info, only text/bounds/confidence.
Add pixel-level post-processing in perception/ocr.py: crop the
screenshot to each OCR box, split pixels into two luminance clusters
via Otsu threshold, and treat the minority cluster as the text stroke
(foreground) and the majority as the background. New
SceneElement.foreground_color/background_color fields ("#rrggbb",
None when not OCR-sourced or sampling fails) round-trip through
to_dict/from_dict alongside the existing accessibility-state fields.
Planner system prompt documents the new fields as a secondary signal.

pillow is promoted from an implicit paddleocr transitive dependency to
an explicit direct dependency since perception/ocr.py now imports PIL
directly; uv.lock re-resolved with no version change (already locked
at 12.3.0).
2026-07-15 20:58:47 +08:00
q792602257andClaude Opus 4.6 f64f98834f Merge branch 'worktree-gesture-humanize': gesture primitives + humanize
Tests / Test apps.device-host-agent.tests.test_e2e.test_public_sdk_reports_fake_device_success_and_runtime_failure failed
Adds long_press/double_tap atomic gestures, a centralized humanize layer
(coordinate jitter, curved W3C-Actions swipe, timing jitter) gated by
APEX_HUMANIZE_ENABLED, and planner integration. 651 non-integration tests
pass on the branch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-15 20:11:05 +08:00
q792602257 9da73cc6e3 fix(humanize): preserve gaussian magnitude in jitter_point; unify swipe return shape 2026-07-15 19:58:02 +08:00
q792602257 5a93651db7 feat(planner): expose long_press/double_tap to the AI planner 2026-07-15 19:45:06 +08:00
q792602257 bd6b7e64e2 Merge branch 'worktree-task-cancellation': task cancellation feature
Tests / Test passed: 926
# Conflicts:
#	packages/cloud-platform/cloud/schema.py
2026-07-15 19:39:28 +08:00
q792602257 dda70940c0 feat(tools): humanize swipe into curved W3C path when enabled 2026-07-15 19:38:07 +08:00
q792602257 865c163683 test(tools): assert tap humanize actually jitters coords 2026-07-15 19:33:46 +08:00
q792602257 85f0d6e188 feat(tools): humanize tap coordinates; default off in tests 2026-07-15 19:29:57 +08:00
q792602257 fd6365cf6e Add end-to-end cancellation path test and verify no regressions (task-cancellation 9.1-9.2)
- test_cancellation_full_path_queued_immediate_and_dispatched_collaborative
  exercises the full public-API cancellation path: immediate cancel of a
  queued task, collaborative cancel of a dispatched task surfaced through
  lease renewal and a cancelled terminal report, and visibility of the
  cancelled status via both the get and list endpoints.
- Full backend suite (869 passed, 50 skipped) and cloud-console frontend
  suite (27 passed) + typecheck show no regressions; the only failures are
  4 pre-existing live-LLM integration tests unrelated to this change.
2026-07-15 19:23:35 +08:00
q792602257andClaude Opus 4.6 d8e7be4ccb feat(tools): add double_tap tool with humanize hook
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-15 19:20:35 +08:00
q792602257andClaude Opus 4.6 c4ee4279ef feat(tools): add long_press tool with humanize hook
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-15 19:16:25 +08:00
q792602257 1dd24825ca Document task cancellation latency in CLOUD_DEPLOYMENT.md (task-cancellation 8.1) 2026-07-15 19:14:52 +08:00
q792602257 18f053e64b Add Host Agent local console task cancellation (task-cancellation 7.1-7.3)
- New internal API route POST /internal/v1/hosts/{host_id}/tasks/{task_id}/cancel,
  authenticated via the host's own bearer credential (authorize_host) with an
  ownership check, since host tokens carry no scopes and cannot reach the
  public SDK's tasks:submit-scoped cancel endpoint.
- HostAgentClient.cancel_task() calls the new internal route directly.
- create_console_app() gains a cancel_task callable with automatic default
  wiring from host_client, so production app.py needs no changes.
- Local console: POST /tasks/{task_id}/cancel route resolves the local
  execution id to its Cloud source_task_id before cancelling, and the task
  detail page/template show a Cancel button plus notice/error banners.
- Tests across all three layers: internal API route, Jinja2 template
  rendering, and FastAPI console route behavior.
2026-07-15 19:13:40 +08:00
q792602257 8a73edf4db feat(driver): add Driver.double_tap via W3C actions 2026-07-15 19:12:10 +08:00
q792602257 c25ccb491d feat(driver): add W3C actions helper and Driver.swipe_path 2026-07-15 19:04:57 +08:00
q792602257 ff91bd4f70 feat(driver): add Driver.long_press on WDA and Android 2026-07-15 18:57:53 +08:00
q792602257 7d79f677fe feat(humanize): add coordinate/duration/swipe-path jitter module 2026-07-15 18:51:15 +08:00
q792602257 4d04d7ac83 Add Cloud Console cancel action and cancelled status
- Widen TaskStatus to include "cancelled"; add it to TasksView's
  STATUSES filter dropdown.
- Add TaskCancellationResponse type and cancelTask(taskId) to api.ts.
- Add a Cancel button to TasksView's task detail panel, gated on
  tasks:submit and a non-terminal task status; updates the displayed
  status on success and surfaces errors via the existing error path.
- Extract the cancellability rule into a pure taskCancellation.ts
  module (mirroring taskProgress.ts/plannerHistory.ts) with unit
  tests, since the project has no Vue component-mounting test setup.

Task 6/9 of task-cancellation change.
2026-07-15 18:43:55 +08:00
q792602257 6776ac2f2d Add public SDK cancel endpoint and CloudClient method
- POST /v1/tasks/{task_id}/cancel: tasks:submit scoped, 200 for
  immediate/idempotent cancellation, 202 for newly recorded pending
  cancellation, 404 for unknown task, 409 for terminal task.
- TaskCancellationResponse{task_id, status} model.
- Widen list_tasks status_filter Literal to include "cancelled".
- CloudClient.cancel_task(task_id).
- SDK-level tests covering queued/assigned/idempotent/404/409/scope
  cases for both the router and CloudClient.

Task 5/9 of task-cancellation change.
2026-07-15 18:39:00 +08:00
q792602257 d3024b4810 feat(host-agent): stop assignment execution collaboratively on cancellation
- LeaseGuard gains an is_cancellation convenience property
- ActiveAssignmentRunner marks the lease lost with a cancellation
  reason when a renewal response reports cancel_requested
- AssignmentExecutor threads stop_reason through to TaskRunner/
  WorkflowRunner and maps a cancellation-flavored stop to
  AssignmentExecutionResult.status = "cancelled" instead of "failed"
- AssignmentProcessor forwards a three-way done/cancelled/failed
  status when reporting the terminal result
- Add/extend tests across lease, assignment, processor, and client
2026-07-15 18:28:21 +08:00
q792602257andClaude Opus 4.6 a58ded055e docs(superpowers): add gesture primitives + humanize implementation plan
9-task TDD plan: humanize module, Driver.long_press/swipe_path/double_tap
on WDA+Android (+ all Fake subclasses), tool wrappers, tap/swipe hooks,
and planner integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-15 18:26:34 +08:00
q792602257andClaude Opus 4.6 557c8a25ba docs(superpowers): add gesture primitives + humanize design spec
Design for long_press/double_tap atomic gestures and a centralized
humanize layer (coordinate jitter, curved W3C-Actions swipe, timing
jitter) gated by APEX_HUMANIZE_ENABLED.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-15 18:15:43 +08:00
q792602257 d69be48f96 feat(planner): persist reusable action semantics
Tests / Test passed: 879
2026-07-15 18:14:28 +08:00
q792602257 8a0d48eada feat(cloud): surface cancellation over the internal Host<->Cloud protocol
- LeaseRenewalResponse gains cancel_requested (populated from the
  repository's renew_lease result)
- TerminalResultRequest.status widened to accept "cancelled"
- Add internal API tests for a renewal surfacing cancel_requested=True
  and a cancelled terminal report being accepted/idempotent
2026-07-15 18:13:43 +08:00
q792602257 361dada276 feat(perception): surface accessibility interaction state on UI-tree elements
SceneElement gains enabled/clickable/selected/checked/focused (bool | None),
populated from the literal attributes Appium's XCUITest and UiAutomator2
page_source already emit (iOS: enabled only; Android: all five). None means
"not reported by this platform", not false. to_dict() omits unset fields to
keep the LLM-facing scene JSON compact; planner_prompts.py documents the new
fields so the AI planner knows how to use them (e.g. don't tap disabled
elements, use selected/checked to judge whether a toggle already matches the
goal).
2026-07-15 18:10:53 +08:00
q792602257 19c6669800 feat(cloud): add durable cancellation support to task repository
- Add nullable cancel_requested_at column (migration 0012)
- Widen ScheduledTaskStatus/TerminalTaskStatus to include cancelled
- Add CancellationRequestStatus + request_task_cancellation() to
  CloudRepository protocol and SQLAlchemy implementation
- renew_lease() now returns LeaseRenewalResult, surfacing whether
  cancellation is pending, instead of a bare status string
- reap_expired_leases() resolves pending-cancellation tasks to
  cancelled instead of requeuing/failing them
- record_task_result() accepts cancelled and clears
  cancel_requested_at on any terminal write

Note: internal_api/api.py's renew_assignment route still compares
renew_lease()'s return value against a bare string; it will be
updated in the next task (Internal Host<->Cloud protocol) to consume
LeaseRenewalResult and populate the new cancel_requested wire field.
2026-07-15 18:08:48 +08:00
q792602257 7c6cdc5b67 feat(host-agent): persist OCR/UI overlay toggle preference
Tests / Test passed: 868
Save the "Show OCR/UI-tree bounding boxes" checkbox state to localStorage so it persists across page refreshes in the task detail view.
2026-07-15 18:01:10 +08:00
q792602257 88189770ff feat(runtime): add cancellation-aware stop_reason to TaskRunner and WorkflowRunner
- TaskRunner.run() and WorkflowRunner.run()/resume() accept an optional
  stop_reason callable alongside should_stop, distinguishing a genuine
  cancellation from other stop conditions (e.g. lost lease).
- is_cancellation_reason() shared helper added to runtime/task.py.
- WorkflowRunner._stop_status() now branches cancelled/failed based on
  stop_reason, correcting a prior blanket cancelled-on-any-stop behavior
  that conflicted with the host-agent-protocol spec's requirement to
  distinguish cancellation from lease-loss stops.
- Default behavior (stop_reason=None) is preserved exactly for both
  runners so existing callers/tests are unaffected.
- Task 1 of openspec change task-cancellation.
2026-07-15 17:52:33 +08:00
q792602257 947434b65a docs(openspec): add task-cancellation proposal, design, specs, tasks 2026-07-15 17:38:02 +08:00
q792602257 a25542694d fix test
Tests / Test passed: 868
2026-07-15 17:04:59 +08:00
q792602257 17a709c92f fix(perception): disable PaddleOCR doc-unwarping for screenshots
Tests / Test tests.test_device_config.test_device_config_store_settings_get_set_and_defaults failed
PaddleOCR's OCR.yaml pipeline defaults to use_doc_orientation_classify
and use_doc_unwarping enabled, which are meant for photographed paper
documents. Applied to a flat, upright device screenshot, UVDoc
geometrically warps the image before detection, and returns box
coordinates in that warped space with no inverse mapping back to the
original image.

Verified on a real screenshot: with unwarping on, the same detected
element ("新项目") shifts from y=158 to y=71 versus the original image,
and 2 boxes near the top edge (status bar time/battery) are dropped
entirely. Disabling both flags by default (still overridable via
explicit kwargs) makes detected boxes match the original screenshot.
2026-07-15 16:51:36 +08:00
q792602257 4046c9452d fix(deps): pin paddlepaddle below 3.3.0 to avoid OCR text corruption
paddlepaddle 3.3.1 silently corrupts non-ASCII (CJK) recognized text
into literal U+FFFD replacement characters during rec postprocessing,
while leaving confidence scores high and ASCII/digit text unaffected.
The same release also breaks CPU oneDNN inference on Windows entirely
(NotImplementedError in onednn_instruction.cc). Verified on a real
task screenshot that downgrading to 3.2.x eliminates the corruption
with no other environment changes (same GBK-locale machine).
2026-07-15 16:41:58 +08:00
q792602257 7f439f0db5 fix(perception): reconcile points/pixels scale and stale overlay screenshot
Tests / Test tests.test_device_config.test_device_config_store_settings_get_set_and_defaults failed
Host-agent console showed OCR/UI-tree overlay boxes misaligned with the
displayed screenshot. Two independent causes, both confirmed with real
task data and pixel-level measurement of a user-provided screenshot:

1. perception/ui_parser.py parses XCUITest UI-tree bounds as iOS logical
   points, while scene_builder.py's Scene.width/height (via infer_png_size)
   and OCR bounds are in screenshot pixels, never reconciled (2.0x on
   Retina devices). build_scene() now detects the scale from the first
   x==0,y==0 UI element and rescales OCR bounds down to points-space,
   reporting Scene.width/height in points too. No-op for Android, where
   UiAutomator2 bounds already match pixels 1:1. This also fixes tap()
   landing at the wrong location for OCR-matched text, and lets the IOU
   fusion between UI-tree and OCR elements actually fire on iOS.

2. runtime/task.py captured `scene` (OCR/UI-tree data) before the LLM
   planning call, but re-captured `before_screenshot` for each step
   afterward - a real time gap during which on-screen content (e.g. a
   keyboard) could shift, producing a directional drift between the
   overlay and the displayed image. The first step of each plan batch
   now reuses the screenshot already taken for planning instead of
   capturing a new one; later steps in a multi-step batch still take a
   fresh capture (left unresolved, scoped out by request).

Regression tests added for both the scale reconciliation (using real
828x1792 vs 414x896 numbers) and the screenshot reuse behavior.
2026-07-15 16:12:53 +08:00
q792602257 c50ce1faec OCR不展开
Tests / Test tests.test_device_config.test_device_config_store_settings_get_set_and_defaults failed
2026-07-15 15:06:06 +08:00
q792602257 701983ccdd config: increase max_steps limit from 20 to 999999
Remove the 20-step execution limit that was causing "max steps exceeded" errors for long-running tasks. Increase the default max_steps to 999999 in all configurations, effectively removing the practical limit while maintaining the safety mechanism.

Changes:

- runtime/task.py: TaskRunnerConfig.max_steps 20 → 999999

- agents/collab_runner.py: CollaborativeTaskRunnerConfig.max_steps 20 → 999999

- storage/device_config.py: DEFAULT_MAX_STEPS 20 → 999999
2026-07-15 14:58:06 +08:00
q792602257 8162509158 feat(host-agent): persist UI-tree evidence and add overlay/action visualization
Tests / Test passed: 863
Fixes issue 3: the host-agent console showed OCR results but never real
UI-tree data, because _ui_tree_nodes() checked for a get_ui_tree/ui_tree
tool action that has never existed anywhere in the codebase.

- storage/timeline.py: add a ui_tree_results field to TimelineRecord and
  Timeline.append(), mirroring the existing ocr_results field.
- runtime/task.py: _append_timeline() now extracts scene.elements with
  source == "ui" into ui_tree_results (scene_builder.build_scene() already
  preserved these; they were just never persisted).
- host_agent/web/app.py: _ui_tree_nodes() reads the new field directly
  instead of the dead tool-action check. New _overlay_payload() exposes
  each step's scene dimensions and fused element list for client-side
  rendering.
- task_detail.html: adds a toggle to overlay OCR (orange) and UI-tree
  (blue) bounding boxes on the before-action screenshot, plus a visual
  marker for the actually executed action (tap circle, or an animated
  swipe path) using an SVG viewBox so no manual coordinate-scaling JS is
  needed. Legacy/incomplete records degrade to no overlay, never an error.

Also corrects openspec/specs/runtime-task-evidence and
host-agent-console-task-pages, which had encoded the same nonexistent-tool
assumption, via the new host-agent-console-visual-evidence change.

600 tests passing; ruff/compileall/openspec validate all clean.
2026-07-15 14:39:28 +08:00
q792602257 367fd0d412 fix(planner): allow rationale/thinking by using tool_choice=auto
Tests / Test passed: 855
Forced tool_choice ("any"/"required") makes both Anthropic and OpenAI
skip any text/thinking block before the tool call, which silently made
rationale and thinking always None despite the planner-reflection-history
change's capture code being correct. Switch the primary call to
tool_choice="auto" (Anthropic: type=auto, disable_parallel_tool_use=true;
OpenAI: "auto") so the model can emit its reflection text, and add a
one-time forced retry (Anthropic "any", OpenAI "required", thinking
disabled) if the model responds without a tool call, guaranteeing a step
never stalls. Also add OpenAI text_output capture from message.content,
which was never extracted before (Anthropic-only gap).

Update planner-reflection-history design.md/tasks.md to document the bug
found during the pending manual smoke test (task 8.5) and the fix (new
section 9).
2026-07-15 13:43:39 +08:00
q792602257 24992fc9fb fix test
Tests / Test passed: 851
2026-07-15 12:53:50 +08:00
q792602257 a5aeb8889c feat(runtime): add planner reflection history with rationale and thinking
Tests / Test failed: 2, passed: 849
- ToolCallDecision captures thinking blocks and pre-tool text output
- AnthropicToolCallingClient supports optional extended thinking (budget_tokens + beta header)
- PlannedStep carries rationale and thinking from each LLM decision
- WorldEvent replaces scene_summary with rationale/thinking/page fields (backward-compatible)
- AI planner system prompt instructs reflection before each tool call
- _history_summary() emits compact {page, rationale, action, success} dicts
- Cloud DB migration 0011 adds nullable rationale/thinking columns to planner_decision_log
- OpenAI client extracts reasoning_content into thinking field
2026-07-15 12:43:22 +08:00
q792602257 96e403ee47 chore(openspec): archive task execution visibility 2026-07-15 12:10:14 +08:00
q792602257 77d4813bb2 feat(host-agent): make execution history authoritative
Tests / Test failed: 2, passed: 830
2026-07-15 11:46:27 +08:00
q792602257 ccde30e378 feat(runtime): capture step evidence in console
Tests / Test passed: 862
2026-07-15 10:12:09 +08:00
q792602257 8d5b02e37f fix(perception): degrade to OCR-only when UI tree is unavailable
Tests / Test passed: 858
driver.tree() failures (WDA/Appium session errors) previously raised
uncaught, killing describe_screen() before OCR ever ran. Malformed
tree content (invalid XML) had the same problem inside
parse_ui_tree(). Both are now caught and logged, falling back to an
empty ui_elements list so the scene degrades to OCR-only, mirroring
the existing OCR-failure fallback in run_ocr().
2026-07-15 10:03:30 +08:00
q792602257 778af2da53 fix(cloud): align planner configuration and records
Tests / Test passed: 856
2026-07-15 09:43:14 +08:00
q792602257andClaude Opus 4.6 f8054cb58c chore(skills): docs + ruff format for skill-management-console
Tests / Test passed: 855
Documents Skill Management in CLOUD_DEPLOYMENT.md (cloud-skill store,
per-host entitlement, incremental sync, local authoring/override,
inventory report, skills:admin scope) and applies ruff check/format to
all touched modules. All tasks complete; full non-integration suite
green (593 passed) and openspec validate --strict passes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-15 08:10:20 +08:00
q792602257andClaude Opus 4.6 fd0ea3a066 feat(console): Cloud Console Skills management view
Tests / Test passed: 855
Adds SkillsView.vue (cloud-skill CRUD, per-host entitlement grant/revoke,
read-only host local-skill inventory), skill API client methods + types,
and wires it into App.vue behind the skills:admin scope. Console
typecheck/build/tests green (20 passed).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-15 08:06:49 +08:00
q792602257andClaude Opus 4.6 8300c3b6b7 Merge branch 'worktree-runtime-console-jinja2-templates'
Server-rendered Jinja2 Runtime console at /ui/, replacing the Vue/Vite SPA.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-15 08:05:02 +08:00
q792602257andClaude Opus 4.6 e00c50e703 feat(api): server-rendered Jinja2 Runtime console at /ui/
Replaces the separate Vue/Vite `console/` SPA with a same-origin,
server-rendered console built on a module-level Jinja2 Environment
with select_autoescape(["html","xml"]).

- Add api/console_web.py with /ui/ routes (dashboard, tasks, task
  detail/timeline, config) and a _status_fragment polled every 10s.
- Refactor api/console.py into a typed ConsoleService shared by the
  JSON and HTML routers so validation/persistence cannot drift.
- Remove RUNTIME_CONSOLE_STATIC_DIR, SpaStaticFiles, and the wildcard
  CORS middleware from api/rest.py; GET / now redirects to /ui/.
- Delete the top-level console/ project; add jinja2 and python-multipart
  as direct dependencies and ship templates/CSS/JS via package-data.
- Add 31 tests (XSS probes, PRG flows, fragment refresh, no-static-dir
  and no-CORS regressions, wheel-packaging smoke test).

/console/* JSON endpoints remain unchanged. The console keeps the
trusted-network-only boundary; auth/CSRF is intentionally deferred.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-15 08:03:13 +08:00
q792602257andClaude Opus 4.6 0d944ec97d feat(host-agent): wire skill sync + inventory report into app lifecycle
Adds host_agent/skill_sync.py (HostAgentSkillSync) which constructs the
synced + local skill stores, the Cloud API sync client, and the runner,
then drives them on the host-agent lifecycle: incremental per-host pull
into the synced catalog, fork-on-revocation, and a best-effort local-
skill inventory report to the Cloud (design D7). Wired into
create_application + run_async start/stop. Host-agent suite green
(219 passed).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-15 08:02:31 +08:00
q792602257andClaude Opus 4.6 a8ba2312fc feat(skills): cloud sync client + incremental sync + fork-on-revocation
Adds CloudApiSkillClient (Cloud API per-host sync endpoint + inventory
report), forwards since_version for incremental sync (full-replace on
first/stale), and forks a local override into a standalone local skill
when its cloud skill is revoked (design D9). Skill-side tests green (87
passed).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-15 07:55:08 +08:00
q792602257andClaude Opus 4.6 cbfdb2ae39 feat(cloud): skill management + host-scoped sync REST endpoints
Adds the skills:admin router (cloud/sdk/skill_api.py) for cloud-skill CRUD
and per-host entitlement grant/revoke with CSRF/scope/audit, and a
host-scoped router serving incremental per-host sync deltas plus the
agent local-skill inventory report/readback. Both composed into the
Cloud API app. cloud-api suite green (46 passed).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-15 07:51:27 +08:00
q792602257andClaude Opus 4.6 52e442790a feat(cloud): cloud-managed skill store + per-host entitlement + sync versioning
Tests / Test passed: 819
Adds cloud/skills.py (domain + service), SQLAlchemy models and Alembic
migration 0010_skill_management (cloud_skills, cloud_skill_entitlements,
cloud_skill_sync_state, a per-host changelog, and host_skill_inventory),
and repository methods with a monotonic per-host entitlement_version that
drives correct incremental fetch_host_delta. cloud-api suite green (41
passed); HEAD_REVISION bumped to 0010.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-15 07:45:22 +08:00
q792602257andClaude Opus 4.6 8baf3a6a8b feat(skills): unified read-merge + authoring/override MCP tools
Adds the merged read surface (api/skill_catalog_view.py) over synced +
local stores with origin discrimination and override precedence, and
extends the skill MCP tools with create_skill/update_skill/delete_skill
that dispatch by origin (edit local skills; create/update/remove local
overrides for cloud skills). Wired into api.mcp.create_mcp_server.
Full non-integration suite green (564 passed).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-15 07:38:12 +08:00
q792602257andClaude Opus 4.6 dd03abbbb0 feat(skills): open skill-management-console change + local skill store
Opens the skill-management-console openspec change (cloud/local skill split
with local override) with proposal, design (D1-D11), four delta specs, and
tasks. Implements the agent-side persistent local skill store
(storage/local_skills.py): authored local skills + cloud-skill overrides in
a physically separate SQLite file, with fork-on-revocation. 10 tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-15 07:28:42 +08:00
q792602257andClaude Opus 4.6 e5a12f9b74 chore(openspec): archive skill-catalog-subscription
Change is complete (24/24 tasks) per its declared scope (read-only local
catalog + MCP tools + sync client contract). Management UI and the
upstream Subscription Platform were explicitly out of scope. Deltas
synced into three new main specs: skill-catalog, skill-mcp-tools,
skill-subscription-sync.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-15 00:09:24 +08:00
q792602257andClaude Opus 4.6 56f3f96363 chore(openspec): archive database-llm-provider-management
Tests / Test passed: 794
Change is complete (17/17 tasks). Deltas synced: MODIFIED the
cloud-planner-proxy "Endpoint resolves exactly one tool-call decision"
requirement to resolve provider config from the active database profile,
and created a new main spec openspec/specs/llm-provider-management/spec.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-14 23:32:08 +08:00
q792602257andClaude Opus 4.6 8e37b965aa chore(openspec): archive cloud-planner-proxy
Change is complete (20/20 tasks). Deltas synced: MODIFIED the
agent-runtime "Pluggable dual-provider tool-calling abstraction"
requirement (added transport selection), and created a new main spec
openspec/specs/cloud-planner-proxy/spec.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-14 23:30:25 +08:00
q792602257andClaude Opus 4.6 c01dd4c6b2 chore(openspec): archive ai-planner-runtime
Change is complete (22/22 tasks) and its delta spec has been synced
into a new main spec openspec/specs/agent-runtime/spec.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-14 23:28:17 +08:00
q792602257andClaude Opus 4.6 9669b52498 chore(openspec): archive uv-workspace-packaging
Change is complete (17/17 tasks) and its delta spec has been synced
into a new main spec openspec/specs/workspace-packaging/spec.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-14 21:24:59 +08:00
q792602257andClaude Opus 4.6 fa10cccf71 chore(openspec): archive downgrade-python-3-13-paddleocr
Change is complete (19/19 tasks) and its delta spec has been synced
into a new main spec openspec/specs/python-runtime-baseline/spec.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-14 21:18:57 +08:00
q792602257andClaude Opus 4.6 989fdbf878 chore(openspec): archive edge-host-enrollment
Tests / Test passed: 794
Sync delta specs into main specs before archiving: modified
cloud-control-plane, device-pool, and host-agent-protocol; created
new edge-host-enrollment capability spec. openspec validate --specs
reports 18/18 passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-14 21:01:47 +08:00
q792602257 30f09b6268 feat(host-agent): default planner transport to cloud
Tests / Test passed: 794
2026-07-14 20:42:27 +08:00
q792602257 6e511111c4 fix(perception): harden PaddleOCR result handling
Tests / Test passed: 793
2026-07-14 20:07:49 +08:00
q792602257 25ebc10a8a feat: downgrade Python baseline to 3.13 for PaddleOCR compatibility
Tests / Test passed: 789
paddlepaddle has no Python 3.14 (cp314) wheel on PyPI, so host-agent
deployments on 3.14 can never install it, causing OCR to fail at
runtime with RuntimeError. Pin the workspace to Python 3.13 across
all pyproject.toml files, the Docker base image, and the Jenkins CI
image; regenerate uv.lock against 3.13.

Also fixes a pre-existing Python-2-style `except X, Y:` syntax error
(invalid in all Python 3.x) in runtime/task.py and
packages/cloud-platform/cloud/{sql_repository,internal_api/api}.py,
introduced in 22d37ca9 and unrelated to this change's scope, which
blocked the full test suite from collecting on any interpreter
version.

openspec change: downgrade-python-3-13-paddleocr
2026-07-14 18:05:49 +08:00
q792602257 ecb1dba9ff chore(openspec): archive host-agent-console-task-submission
Tests / Test passed: 789
2026-07-14 17:55:27 +08:00
q792602257 a883903b66 style(host-agent): format Console task submission files 2026-07-14 17:52:35 +08:00
q792602257 a46f7d02a1 Merge branch 'opsx/host-agent-console-task-submission' into master
Tests / Test passed: 789
Adds a CSRF-protected task submission form to the local Console Tasks page

# Conflicts:
#	apps/device-host-agent/host_agent/app.py
2026-07-14 17:05:37 +08:00
q792602257 fb09924835 feat(host-agent): add Console task submission with Host self-submission client
Adds a CSRF-protected task submission form to the local Console Tasks page
2026-07-14 17:02:41 +08:00
q792602257andClaude Sonnet 5 bb6f24bbcb docs(openspec): correct host-agent-single-instance-lock's Why section
This change was originally proposed as the root-cause fix for the
2026-07-14 DeviceNotFoundError incident. That diagnosis was wrong: it was
subsequently confirmed only one Host Agent process was running at the time,
ruling out the duplicate-process precondition this change addresses. The
actual root cause was execution.py's create_task_runner() omitting manager=
when wiring TaskRunner (see 08cef7c). Reframe the Why section: this change
stands on its own as independent duplicate-process hardening, not as a fix
for an incident it turned out not to have caused.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 16:36:04 +08:00
q792602257andClaude Sonnet 5 08cef7ca3c fix(host-agent): thread device manager into task runner observer/screenshot
create_task_runner() built TaskRunner's observer/screenshot_provider by
calling describe_screen(device_id)/take_screenshot(device_id) without
manager=, so both silently fell back to the process-global DEFAULT_MANAGER
singleton instead of the Host Agent's real, device-populated DeviceManager.
DEFAULT_MANAGER never has any device registered, so every task's first step
raised DeviceNotFoundError even though the console (which does pass
manager=) showed the same device as connected. Deterministic on every task,
independent of process count.

Add regression tests confirming both lambdas now resolve devices via the
configured manager; verified each fails with the original DeviceNotFoundError
symptom when the fix is reverted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 16:35:48 +08:00
q792602257andClaude Opus 4.6 82567fd248 chore(openspec): add host-agent-single-instance-lock change artifacts
Tests / Test passed: 759
Proposal, design, spec, and tasks for the per-installation exclusive
instance lock. 15/16 tasks complete; only manual real-environment
verification (5.4) remains, with semantics covered by unit tests in
test_app.py and test_instance_lock.py.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-14 15:49:45 +08:00
q792602257andClaude Opus 4.6 d00ada67a5 feat(host-agent): add single-instance lock to prevent duplicate-process dispatch races
Acquire an exclusive, non-blocking filelock on the identity state directory
as the first action of create_application(), before resolve_host_identity()
or any enrollment/heartbeat side effect. A second process against the same
identity_path exits immediately with InstanceAlreadyRunningError naming the
lock path; the lock releases automatically on any process exit (including
SIGKILL) via OS-level advisory locking, and explicitly during run_async()'s
shutdown finally block. filelock is promoted from transitive to direct
dependency (version unchanged at 3.29.7).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-14 15:49:30 +08:00
q792602257 99bde4febb test: align deployment and lease contracts
Tests / Test passed: 748
2026-07-14 13:53:00 +08:00
q792602257andClaude Opus 4.6 9afbdc91fa style(cloud): reformat modules and restore except-tuple parentheses
Tests / Test failed: 4, passed: 744
Apply consistent line-length formatting across governance, plugins, the
SDK routers (governance_api, user_api), user_auth, and migrations
0003/0005/0006.

Also restore the parentheses on two except clauses that had been dropped
into invalid Python 3 `except A, B:` syntax: plugins._coerce_to_manifest
(KeyError, ValueError) and PasswordHasher.verify (InvalidHashError,
VerificationError). Both modules now import cleanly again.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-14 13:30:13 +08:00
q792602257andClaude Opus 4.6 c0a653fa3f feat(host-agent): serve built console via SPA-aware static mount
Add an optional single-process mode where the backend serves the built
console bundle itself, so operators don't need a separate `npm run dev`
for edge/dev setups. When RUNTIME_CONSOLE_STATIC_DIR points at the
console dist directory, the app mounts a SpaStaticFiles handler at /ui/
(with 404 fallback to index.html for client-side routing) and redirects
/ to /ui/. The console build uses an empty VITE_API_BASE_URL for relative
API paths (same-origin, no CORS), and Vite's base is set to /ui/ so
assets resolve under the mount. /console/* JSON API is unchanged and is
shared by both serve modes.

api.ts now treats an explicitly-empty VITE_API_BASE_URL as "use relative
paths" instead of falling back to the dev default, which previously
forced absolute URLs even in same-origin builds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-14 13:29:58 +08:00
q792602257andClaude Opus 4.6 8381b3068a feat(host-agent): migrate local console to Jinja2 templates with autoescape
Tests / Test failed: 4, passed: 744
Replace hand-written f-string + html.escape() rendering in the Host Agent
local console with a module-level Jinja2 Environment configured with
select_autoescape(["html","xml"]). XSS safety now holds by mechanism
rather than per-call discipline — every operator-controlled field
(device name, connection_info, task summary, etc.) is escaped by the
engine uniformly.

Eight templates under host_agent/web/templates/ replace the former
_chrome(), _CSS, escape(), and per-page _xxx_body() helpers: base.html
(header/nav/CSS + {% block body %}), login, dashboard (with the polling
<script> preserved byte-identically inside {% raw %}), devices, account,
history, tasks_list, and task_detail. The task-list and task-detail
templates — added by the just-landed task-execution-progress-visibility
change — were also migrated here rather than left in f-string form,
since this change removes the shared helpers they depended on.

URLs, auth/session/CSRF semantics, redirects, and /api/status JSON are
unchanged. 15 new template tests cover render-smoke, XSS probing, script
byte-identity, and no-autoescape-bypass guards. Tasks 8.1-8.6 (manual
browser verification) remain.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-14 13:05:19 +08:00
q792602257andClaude Opus 4.6 ec261d57c2 feat: surface task execution progress across Host Agent and Cloud
Host Agent now persists step-level execution detail locally (via a real
TaskMetadataStore/Timeline wired into TaskRunner) and reports a bounded
in-progress snapshot piggybacked on lease renewal. Cloud persists that
snapshot per active assignment and exposes it through the existing task
list/detail query path; Cloud Console renders it as a live badge. Host
Agent's local console gains authenticated, read-only task list and
detail/timeline pages (same-origin, server-rendered) with inlined
screenshots.

Also fixes a pre-existing gap in the shared Timeline: the actual
per-step LLM prompt is now recorded instead of the task goal, benefiting
both Runtime and Host Agent consoles. When a host uses the cloud planner
transport, each decide call's prompt and resulting tool decision are
durably logged in a new planner_decision_log table (with bounded
retention) and browsable from Cloud Console; direct-transport hosts
explicitly surface a "not reported" state.

Includes Alembic migrations 0008 (progress columns on scheduled_tasks)
and 0009 (planner_decision_log), bounded Host-Agent-local retention,
dual-backend repository parity, and Vitest + pytest coverage. Task 6.5
(manual end-to-end device verification) remains.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-14 12:47:49 +08:00
q792602257 c049c3c1b1 fix(host-agent): distinguish connected devices from busy-with-task in console
Tests / Test failed: 2, passed: 693
DeviceManager marks a device "busy" as soon as an Appium/WDA session is
connected, which is unrelated to whether a task is currently executing on
it. The Host Agent's local console displayed this raw status, making
connected-but-idle devices look permanently busy. Cross-reference the
device id against AgentStatusTracker's current_assignment (already
tracked via mark_assignment_started/finished) to show "connected" unless
a task is actually running on that device.
2026-07-14 11:48:33 +08:00
q792602257andClaude Opus 4.6 bead6e58ac chore(openspec): archive host-agent-dependency-supervisor
Tests / Test failed: 2, passed: 691
Implementation verified on macOS (task 6.4 confirmed spawn + adoption
behavior). Archives the change under
openspec/changes/archive/2026-07-14-host-agent-dependency-supervisor/ and
syncs the delta spec into a new main capability at
openspec/specs/host-agent-dependency-supervisor/spec.md (5 baseline
requirements covering opt-in default, adopt-don't-fight, spawn, bounded
backoff restart, and lifecycle tied to Host Agent). openspec validate
--strict passes on the synced spec.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-14 10:07:07 +08:00
q792602257andClaude Opus 4.6 75879c8a52 feat(host-agent): add optional Appium/Runtime supervisor
Tests / Test failed: 2, passed: 691
Adds an opt-in dependency supervisor inside the Host Agent that probes,
spawns, and restarts the two local processes the macOS single-machine
real-device workflow depends on: the Appium server (gates Driver.connect())
and the local Runtime API (local inspection). Default-off; gated by
HOST_AGENT_DEPENDENCY_SUPERVISOR_ENABLED plus per-dependency *_SUPERVISED
flags.

Mitigates the live-incident failure mode where forgetting to start Appium
silently keeps devices offline and tasks queued forever with no error
surfaced in Host Agent logs.

Behavior (per openspec change):
- Adopt-don't-fight: probe (TCP + dependency-specific HTTP health check)
  before spawn. Healthy listener → adopted (never killed/restarted).
  Unhealthy listener → port-conflict error, skip. No listener → spawn.
- Only supervisor-spawned processes are restarted on crash, with capped
  exponential backoff (1s/2s/4s/8s, capped at 30s) and a per-process-lifetime
  attempt ceiling (HOST_AGENT_DEPENDENCY_RESTART_MAX_ATTEMPTS, default 5).
- Spawn failures (e.g. missing executable) logged distinctly from crashes.
- Graceful stop terminates only spawned children; adopted processes untouched.
- Supervisor starts before the heartbeat loop's first connect_devices() pass
  and stops alongside existing heartbeat/console teardown.

Validation: ruff check + format clean, compileall clean, openspec validate
--strict valid. Non-integration suite 503 passed / 44 deselected / 2 failed
(both failures pre-existing from unrelated 03c7c30 LLM_PROVIDER_ENC_KEY;
verified by stashing this change). macOS real-device manual verification
(task 6.4) deferred to a macOS host.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-14 09:28:57 +08:00
q792602257 03c7c30067 LLM_PROVIDER_ENC_KEY 2026-07-14 09:06:48 +08:00
q792602257 9250254dec feat(cloud): support Anthropic provider base URL
Tests / Test passed: 665
2026-07-14 07:58:38 +08:00
q792602257 8b7e5a2800 feat(host-agent): start local console by default
Tests / Test passed: 662
2026-07-14 07:31:31 +08:00
q792602257 a166ffd8a4 feat(cloud): manage LLM providers in database
Tests / Test passed: 664
2026-07-14 00:31:47 +08:00
q792602257 b613a315ff docs(cloud): document governance rollout
Tests / Test passed: 665
2026-07-13 23:22:53 +08:00
q792602257 5b64efab53 feat(cloud): report planner transport status 2026-07-13 23:15:46 +08:00
q792602257 40efa53411 feat(cloud): attribute planner token usage 2026-07-13 23:10:21 +08:00
q792602257 b4803f90e6 feat(cloud): enforce host governance budgets
Tests / Test passed: 662
2026-07-13 22:56:31 +08:00
q792602257 2cd314b183 feat(cloud): add targeted task governance foundation
Tests / Test passed: 659
2026-07-13 22:21:12 +08:00
q792602257 a3ba94be04 Merge feature/cloud-planner-proxy: cloud-controlled AI planner proxy
Tests / Test passed: 651
Merges the full implementation of the cloud-planner-proxy OpenSpec change
(19/19 tasks). See branch commits for details.
2026-07-13 21:28:00 +08:00
q792602257 a68f609453 Implement cloud-planner-proxy: AI planner routes through Cloud API
Implements all 19 tasks of the cloud-planner-proxy OpenSpec change:

- Cloud API: cloud.planner_config (CloudPlannerConfig, load/build helpers)
  reusing runtime.tool_calling_client provider clients (no new dependency
  needed -- device-cloud-platform already depends on device-agent-runtime).
- Cloud API: new host-scoped POST /internal/v1/hosts/{host_id}/planner/decide
  internal endpoint, reusing existing bearer auth; logs only metadata
  (host id, tool name, latency, error class), never prompt/screenshot
  content.
- Host Agent: new AI_PLANNER_TRANSPORT config (direct default | cloud) and
  host_agent/cloud_planner_client.py::CloudProxyToolCallingClient, a
  synchronous ToolCallingClient implementation (structural, not importing
  runtime) that calls the new endpoint via its own httpx.Client -- avoids
  bridging the async HostAgentClient across the worker-thread boundary
  that AIPlanner.plan() runs in (asyncio.to_thread in lease.py).
- Host Agent wiring: create_execution_factories()/_host_agent_planner()
  select the cloud-proxy client only when AI_PLANNER_TRANSPORT=cloud;
  direct/unset transport is unchanged (still the default).
- Tests: 22 new tests across Cloud API config, the new endpoint, the new
  client, and transport-selection wiring; full non-integration suite
  (492 tests) passes with no regressions.
- Docs: docs/CLOUD_DEPLOYMENT.md documents the cloud transport, its
  trade-offs, and the credential split between Host Agent and Cloud API.

proposal.md/design.md were corrected during implementation to reflect two
findings: no new anthropic/openai dependency is actually needed, and
CloudProxyToolCallingClient uses its own sync httpx.Client rather than a
new HostAgentClient method, per the thread-boundary reasoning above.
2026-07-13 21:27:48 +08:00
q792602257 1107ace89c Default-enable AI Planner in Host Agent; propose cloud-planner-proxy
Tests / Test passed: 626
- Host Agent now defaults AI_PLANNER_ENABLED=true (opt-out via env),
  scoped to apps/device-host-agent/host_agent/execution.py only; the
  shared runtime.planner_config default (disabled) is unchanged.
- Add openspec proposal for cloud-planner-proxy: centralize LLM
  provider config/credentials on the Cloud Control Plane and let the
  Host Agent proxy AI Planner decisions through it instead of holding
  provider API keys locally. Proposal only, no implementation yet.
2026-07-13 20:50:35 +08:00
q792602257 78ce788e2f chore(openspec): archive android-driver
Tests / Test passed: 624
Archives the completed android-driver change (16/16 tasks). Promotes
the driver_type="uiautomator2" scenario into the canonical
driver-registry spec and moves the change artifacts to
openspec/changes/archive/2026-07-13-android-driver/.
2026-07-13 20:39:27 +08:00
q792602257 938d97a2ad docs(openspec): complete android-driver command sanity check
Completes task 1.1: verified mobile: clickGesture, mobile: dragGesture,
mobile: pressKey (keycode=3 / KEYCODE_HOME), and the appium:systemPort
capability against the official appium-uiautomator2-driver README and
android-mobile-gestures.md (master, 2026-07), plus the installed
appium-python-client 5.3.1. All four match driver/android_driver.py.

16/16 tasks complete; openspec validate --strict passes.
2026-07-13 20:24:49 +08:00
q792602257 c162c2501b feat(cloud): remove static credentials and add host console
Tests / Test No test results found
2026-07-13 19:45:53 +08:00
q792602257 efeb3eb926 Implement edge-host-self-enrollment
Tests / Test passed: 581
Host Agent:
- One-time local operator account bootstrap (PBKDF2-HMAC-SHA256, atomic
  0600-permission write) gating the daemon's first unattended start via a
  new `setup` CLI subcommand.
- Default control-plane URL now https://amcp.home.jerryyan.top (env var
  override unchanged).
- Enrollment no longer requires a pre-issued token; falls back to
  zero-token self-service enrollment when none is configured.

Cloud control plane:
- CLOUD_SELF_SERVICE_ENROLLMENT_ENABLED (default false) opt-in flag.
- SelfServiceEnrollmentAuthProvider + ChainedEnrollmentAuthProvider:
  configured tokens still take priority; self-service only applies when
  no token matches, preserving edge-host-enrollment's token-bound path.
- Fixed a latent bug in sql_repository.py::enroll_host: the token-conflict
  lookup used `== enrollment_token_digest`, which SQLAlchemy compiles to
  `IS NULL` when the value is None, so every self-service enrollment after
  the first would have falsely collided with an existing NULL-digest host.
  Skipped that lookup entirely when the digest is None.

Docs/deploy: .env.example, compose.yaml, compose.deploy.yaml,
CLOUD_DEPLOYMENT.md, MACOS_IPHONE_SETUP.md updated for the new flag,
URL default, and required `device-host-agent setup` step.

Verification: 494 non-integration tests pass; openspec validate --strict
passes. PostgreSQL-backed contract tests and full manual end-to-end
verification were not run (no Postgres/Docker or reachable cloud-api in
this environment); noted as unchecked in tasks.md 7.2/7.4.
2026-07-13 18:30:49 +08:00
q792602257 a2802c6320 chore(openspec): add edge host self-enrollment proposal
Tests / Test passed: 558
2026-07-13 17:55:40 +08:00
q792602257 c72c31de04 docs(cloud-console): document user authentication 2026-07-13 17:55:21 +08:00
q792602257 cdef630e67 feat(cloud-console): add user authentication and administration 2026-07-13 17:54:53 +08:00
q792602257 035b177128 云端地址
Tests / Test passed: 545
2026-07-13 16:41:30 +08:00
q792602257 8f74eac50c Jenkins
Tests / Test passed: 545
2026-07-13 16:11:40 +08:00
q792602257 9f4641e4fc Jenkins
Tests / Test tests.test_workspace_packaging.test_workspace_distributions_own_expected_import_packages failed
2026-07-13 16:02:57 +08:00
q792602257 97f23be394 Jenkins
Tests / Test tests.test_workspace_packaging.test_workspace_distributions_own_expected_import_packages failed
2026-07-13 15:56:44 +08:00
q792602257andClaude Opus 4.6 e5ea32a44a build(pytest): use importlib import mode to fix app test collection
Tests / Test tests.test_workspace_packaging.test_workspace_distributions_own_expected_import_packages failed
apps/cloud-api/tests and apps/device-host-agent/tests both contain
test_app.py without __init__.py, so pytest's default prepend mode
collided on the bare module name and aborted collection when Jenkins
passed both paths together. Switching to importlib imports each file
by its full path and avoids the collision.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-13 15:24:13 +08:00
q792602257andClaude Opus 4.6 8507a5a508 build(workspace): pin Aliyun PyPI mirror and refresh uv.lock
Tests / Test .apps.device-host-agent.tests.test_app failed
Set `index-url` under `[tool.uv]` so all consumers (local devs and the
Jenkins image) resolve from `mirrors.aliyun.com/pypi/simple` by default
instead of relying on the per-stage `UV_INDEX_URL` env var in Jenkinsfile.
Local overrides remain available via `UV_INDEX_URL=... uv sync`.

Re-run `uv lock` to rewrite package sources/URLs from `pypi.org` to
the Aliyun mirror; versions, hashes, and resolution are unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-13 15:15:49 +08:00
459 changed files with 47455 additions and 5773 deletions
-1
View File
@@ -7,6 +7,5 @@ __pycache__
*.py[cod]
*.sqlite3
tasks
console/node_modules
cloud-console/node_modules
cloud-console/dist
+14 -24
View File
@@ -8,29 +8,19 @@ POSTGRES_USER=device_cloud
POSTGRES_PASSWORD=change-me-database-password
CLOUD_API_PORT=8001
CLOUD_PUBLIC_CREDENTIALS_JSON=[{"principal_id":"local-sdk","token":"change-me-public-token","scopes":["tasks:submit","tasks:read","pool:read","plugins:read","plugins:admin"]}]
CLOUD_HOST_CREDENTIALS_JSON=[{"principal_id":"local-host-agent","token":"change-me-host-token","scopes":[],"host_id":"host-local"}]
CLOUD_ENROLLMENT_TOKENS_JSON=[{"principal_id":"edge-installer","token":"change-me-enrollment-token"}]
CLOUD_SCHEDULER_INTERVAL_SECONDS=1
CLOUD_LEASE_REAPER_INTERVAL_SECONDS=5
CLOUD_LEASE_DURATION_SECONDS=60
CLOUD_MAX_TASK_ATTEMPTS=3
HOST_AGENT_HOST_ID=host-local
HOST_AGENT_TOKEN=change-me-host-token
HOST_AGENT_ENROLLMENT_TOKEN=
HOST_AGENT_IDENTITY_PATH=/app/tasks/host_identity.json
HOST_AGENT_DISPLAY_NAME=
HOST_AGENT_TASKS_PATH=./tasks
HOST_AGENT_HEARTBEAT_INTERVAL_SECONDS=30
HOST_AGENT_POLL_TIMEOUT_SECONDS=20
HOST_AGENT_RETRY_BACKOFF_SECONDS=1
HOST_AGENT_MAX_RETRY_BACKOFF_SECONDS=30
HOST_AGENT_MAX_RETRY_ATTEMPTS=5
# Fernet key protecting database-managed LLM Provider API keys. Generate once
# and preserve it with the database backups (rotating it makes stored keys
# undecryptable):
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
CLOUD_LLM_PROVIDER_ENCRYPTION_KEY=change-me-generate-a-fernet-key
AI_PLANNER_ENABLED=false
AI_PLANNER_PROVIDER=anthropic
AI_PLANNER_MODEL=
AI_PLANNER_TIMEOUT_SECONDS=30
ANTHROPIC_API_KEY=
OPENAI_API_KEY=
# Cloud-proxy planner budget reservations. These apply only to Hosts reporting
# AI_PLANNER_TRANSPORT=cloud.
CLOUD_PLANNER_TOKEN_RESERVATION_CEILING=4096
CLOUD_PLANNER_TOKEN_RESERVATION_TTL_SECONDS=300
# Successful Cloud-proxy planner decisions with task context retain prompt and
# tool-call history until their terminal task exceeds this retention window.
CLOUD_PLANNER_DECISION_LOG_PRUNE_INTERVAL_SECONDS=3600
CLOUD_PLANNER_DECISION_LOG_RETENTION_DAYS=7
+4
View File
@@ -12,3 +12,7 @@
__pycache__/
tasks/
*.egg-info/
*.sqlite
*.sqlite3
+10 -13
View File
@@ -1,21 +1,18 @@
# syntax=docker/dockerfile:1
#
# Build args let CI inject mirrors for faster builds in CN networks; the
# defaults keep the Dockerfile portable so anyone can `docker build .`
# without extra configuration.
# Base images are pinned to the project's registry mirrors so the build is
# deterministic and avoids Docker Hub / ghcr.io pull failures in CN networks.
# Trade-off: `docker build .` requires reachability of these registries.
#
# NODE_IMAGE – stage 1 base (Docker Hub library/node)
# NPM_REGISTRY – npm registry for `npm ci`
# UV_IMAGE – stage 2 base (ghcr.io/astral-sh/uv)
# APT_MIRROR – Debian apt mirror host (e.g. mirrors.aliyun.com); empty = official
# UV_INDEX_URL – PyPI index URL passed through to `uv sync`; empty = official
# NPM_REGISTRY – npm registry for `npm ci` (build-arg)
# APT_MIRROR – Debian apt mirror host (build-arg, empty = official)
# UV_INDEX_URL – PyPI index URL passed through to `uv sync` (build-arg, empty = official)
# Stage 1: build the cloud-console Vue 3 SPA.
# NOTE: do not set NODE_ENV=production here — vue-tsc and typescript are
# devDependencies required by `npm run build`.
ARG NODE_IMAGE=node:20-bookworm-slim
ARG NPM_REGISTRY=https://registry.npmjs.org
FROM ${NODE_IMAGE} AS frontend
FROM registry.jerryyan.top/library/node:20-bookworm-slim AS frontend
# Re-declare inside the stage so --build-arg values (or the global default)
# are visible to RUN. Without this, ARGs declared before FROM are inaccessible.
ARG NPM_REGISTRY
@@ -26,8 +23,7 @@ COPY cloud-console/ ./
RUN npm run build
# Stage 2: the existing Python image, now carrying the SPA build output.
ARG UV_IMAGE=ghcr.io/astral-sh/uv:python3.14-bookworm-slim
FROM ${UV_IMAGE}
FROM registry-ghcr.jerryyan.top/astral-sh/uv:python3.13-bookworm-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
@@ -58,6 +54,7 @@ ARG UV_INDEX_URL=
ENV UV_INDEX_URL=${UV_INDEX_URL}
RUN uv sync --locked --all-packages --no-dev
ENV PATH="/app/.venv/bin:$PATH"
ENV PATH="/app/.venv/bin:$PATH" \
CLOUD_CONSOLE_STATIC_DIR=/app/console-static
CMD ["device-cloud-api", "--host", "0.0.0.0", "--port", "8001"]
Vendored
+8 -9
View File
@@ -22,13 +22,12 @@ pipeline {
booleanParam(name: 'PUSH', defaultValue: true, description: 'Push the image to REGISTRY after a successful build')
// Mirror overrides passed to `docker build` as --build-arg. Defaults target
// CN networks so Jenkins builds don't time out pulling from Docker Hub /
// ghcr.io / npmjs.org / deb.debian.org. Override or blank any of these to
// build against the official upstreams.
string(name: 'NODE_IMAGE', defaultValue: 'registry.jerryyan.net/library/node:20-bookworm-slim', description: 'Stage 1 base image (Docker Hub library/node proxy)')
string(name: 'NPM_REGISTRY', defaultValue: 'https://registry.npmmirror.com', description: 'npm registry URL used by `npm ci`')
string(name: 'UV_IMAGE', defaultValue: 'registry-ghcr.jerryyan.top/astral-sh/uv:python3.14-bookworm-slim', description: 'Stage 2 base image (ghcr.io/astral-sh/uv proxy)')
string(name: 'APT_MIRROR', defaultValue: 'mirrors.aliyun.com', description: 'Debian apt mirror host (e.g. mirrors.aliyun.com). Empty = deb.debian.org')
// CN networks so Jenkins builds don't time out pulling from npmjs.org /
// deb.debian.org / PyPI. Override or blank any of these to build against
// the official upstreams. Base images (node, uv) are pinned in the
// Dockerfile FROM lines and no longer overridable here.
string(name: 'NPM_REGISTRY', defaultValue: 'https://registry.npmmirror.com', description: 'npm registry URL used by `npm ci`')
string(name: 'APT_MIRROR', defaultValue: 'mirrors.aliyun.com', description: 'Debian apt mirror host (e.g. mirrors.aliyun.com). Empty = deb.debian.org')
}
environment {
@@ -56,7 +55,7 @@ pipeline {
when { expression { return !params.SKIP_TESTS } }
agent {
docker {
image 'registry-ghcr.jerryyan.top/astral-sh/uv:python3.14-bookworm-slim'
image 'registry-ghcr.jerryyan.top/astral-sh/uv:python3.13-bookworm-slim'
reuseNode true
// The Docker Pipeline plugin runs the container as the Jenkins host
// user (non-root), so /root isn't writable. Cache under /tmp, which is
@@ -91,7 +90,7 @@ pipeline {
// Pass every mirror override through as --build-arg. Empty values
// are skipped so the Dockerfile ARG default applies.
def buildArgs = []
["NODE_IMAGE", "NPM_REGISTRY", "UV_IMAGE", "APT_MIRROR", "UV_INDEX_URL"].each { name ->
["NPM_REGISTRY", "APT_MIRROR", "UV_INDEX_URL"].each { name ->
def v = params[name]?.toString()?.trim()
if (v) {
buildArgs << "--build-arg ${name}=${v}"
+86 -6
View File
@@ -16,10 +16,10 @@ contracts.
- `driver/`: the `Driver` contract, concrete driver adapters, and driver-type
registry.
- `device/`: device lifecycle and active driver management.
- `tools/`: device capabilities exposed to runtime and API layers.
- `tools/`: device capabilities exposed to Runtime and adapter layers.
- `perception/`: screen-to-`Scene` perception behind `PerceptionProvider`.
- `runtime/`: planning and execution orchestration.
- `api/`: REST/MCP transport adapters.
- `api/`: MCP and supporting integration adapters.
- `storage/`: timeline, task, and device configuration persistence.
- `packages/cloud-platform/`: cloud scheduling, device pooling, plugins, and
the Python cloud SDK as the `device-cloud-platform` workspace member.
@@ -54,8 +54,88 @@ uv build --package device-agent-runtime
uv build --package device-cloud-platform
```
The Vue/Vite application under `console/` remains an independent npm project;
uv does not install or modify its JavaScript dependencies.
Runtime is an in-process execution library, not a standalone HTTP service. The
Host Agent console at `http://127.0.0.1:8765/tasks` is the authenticated
operator view for the tasks that actually execute on that Host, including
per-step screenshots, OCR observations, and UI-tree results. The Cloud Console
remains the fleet-level view for dispatch status and Cloud-proxy planner history.
## Local-only Host Agent
Run without a Cloud Control Plane by setting `HOST_AGENT_MODE=local`. Tasks
submitted in the Host console are queued and executed in the same process:
```bash
export HOST_AGENT_MODE=local
export AI_PLANNER_ENABLED=true
export AI_PLANNER_PROVIDER=openai-compatible
export AI_PLANNER_MODEL=qwen2.5
export AI_PLANNER_API_KEY=local-key
export AI_PLANNER_BASE_URL=http://127.0.0.1:11434/v1
export AI_PLANNER_MULTIMODAL=true
uv run --package device-host-agent device-host-agent setup
uv run --package device-host-agent device-host-agent
```
`openai-compatible` works with Ollama, LM Studio, vLLM, or another server that
implements OpenAI `/chat/completions`. Hosted `openai` and `anthropic` providers
also accept `AI_PLANNER_API_KEY` and their conventional API key variables.
The local Host console also exposes an authenticated conversational Agent API:
```text
POST http://127.0.0.1:8765/api/chat
```
Send a JSON body containing `device_id` and `messages` (`user`/`assistant` roles). The Agent can
return ordinary assistant text or call the same device-operation tool contracts
used by the Runtime; each tool result is fed back to the model before the final
reply is returned. The endpoint uses the Host console session cookie, so it is
not an unauthenticated device-control endpoint.
Every chat request is bound to exactly one registered `device_id`. Keep a
separate message history and client session for each phone; the Agent injects
the bound device into device tools and rejects cross-device tool arguments.
For vision-capable OpenAI models, a user message may contain standard OpenAI
multimodal blocks:
```json
{
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": "点击图片中的登录按钮"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}}
]
}]
}
```
The compact form `{ "role": "user", "text": "...", "image_base64": "..." }`
is also accepted. The model can inspect the supplied image and then call a
phone tool such as `tap` in the same conversation.
Set `AI_PLANNER_MULTIMODAL=true` for a vision model. The Planner sends the
screenshot and omits OCR-only elements and OCR metadata from the structured
scene payload, avoiding duplicate OCR text.
Local mode records LLM responses, reasoning fields, tool calls, tool results,
and final replies in a local SQLite database. View them at
`http://127.0.0.1:8765/conversations`; image bytes are excluded. Set
`HOST_AGENT_CONVERSATION_LOG_PATH` to change the database path.
The authenticated `Devices` page has an on-demand `Get screenshot` button for
each connected device. Screenshots are captured only after the operator clicks
the button; the page does not auto-refresh or capture screenshots as part of
heartbeat synchronization.
In local mode, Appium supervision is enabled by default. Host Agent probes
`/status`, adopts a healthy existing Appium instance, starts Appium when no
listener exists, restarts only processes it started if they crash, and stops
those child processes on shutdown. Override `HOST_AGENT_APPIUM_*` or set
`HOST_AGENT_DEPENDENCY_SUPERVISOR_ENABLED=false` when an external process
manager owns Appium.
## Project Direction
@@ -69,5 +149,5 @@ invariants future changes must preserve are in
or deployed PostgreSQL, configure credentials and Runtime AI planning, and
perform orderly shutdown or rollback.
- [macOS migration and real iPhone setup](docs/MACOS_IPHONE_SETUP.md): install
Xcode, Appium/XCUITest, sign WebDriverAgent, verify a real device, and start a
connected Runtime API.
Xcode, Appium/XCUITest, sign WebDriverAgent, verify a real device, and run a
connected Host Agent.
+27 -4
View File
@@ -4,7 +4,6 @@ import logging
from dataclasses import dataclass, replace
from agents.config import CollaborationConfig, load_config
from agents.models import Observation, VerificationVerdict
from agents.observer import Observer
from agents.reflector import Reflector
from agents.verifier import Verifier
@@ -21,7 +20,7 @@ logger = logging.getLogger(__name__)
@dataclass
class CollaborativeTaskRunnerConfig:
max_steps: int = 20
max_steps: int = 999999
max_recovery_attempts: int = 3
@@ -94,10 +93,21 @@ class CollaborativeTaskRunner:
for step in steps:
executable_step = self._step_for_device(step, task.device_id)
before_screenshot = self.task_runner._planning_screenshot(
task.device_id
)
result = self.executor.execute(executable_step, context=context)
after_screenshot = self.task_runner._planning_screenshot(task.device_id)
context.add_step_result(result)
self.task_runner._record_step_result(
world_handle, context, task, scene, step, result
world_handle,
context,
task,
scene,
step,
result,
before_screenshot=before_screenshot,
after_screenshot=after_screenshot,
)
post_scene = self._observe_scene(task.device_id)
@@ -146,13 +156,26 @@ class CollaborativeTaskRunner:
description=outcome.action.description,
args=outcome.action.args,
)
before_screenshot = self.task_runner._planning_screenshot(
task.device_id
)
recovery_result = self.executor.execute(
self._step_for_device(recovery_step, task.device_id),
context=context,
)
after_screenshot = self.task_runner._planning_screenshot(
task.device_id
)
context.add_step_result(recovery_result)
self.task_runner._record_step_result(
world_handle, context, task, post_scene, recovery_step, recovery_result
world_handle,
context,
task,
post_scene,
recovery_step,
recovery_result,
before_screenshot=before_screenshot,
after_screenshot=after_screenshot,
)
if not recovery_result.success:
return self._fail(
-148
View File
@@ -1,148 +0,0 @@
from __future__ import annotations
import base64
from pathlib import Path
from typing import Any
from uuid import uuid4
from device.manager import DeviceManager
from driver.registry import build_driver_factory
from pydantic import BaseModel, Field
from runtime.task import TaskRunner
from storage.device_config import DeviceConfigStore
from storage.task_metadata import TaskMetadataStore
from storage.timeline import Timeline
class RegisterDeviceRequest(BaseModel):
driver_type: str
connection_info: dict[str, Any] = Field(default_factory=dict)
name: str | None = None
class RuntimeConfigRequest(BaseModel):
max_steps: int
def create_console_router(
*,
device_manager: DeviceManager,
metadata_store: TaskMetadataStore,
timeline: Timeline,
config_store: DeviceConfigStore,
task_runner: TaskRunner,
) -> Any:
from fastapi import APIRouter, HTTPException, Response, status
router = APIRouter(prefix="/console", tags=["console"])
@router.get("/devices")
def devices() -> list[dict[str, Any]]:
return [device.to_dict() for device in device_manager.list_devices()]
@router.post("/devices", status_code=status.HTTP_201_CREATED)
def register_device(request: RegisterDeviceRequest) -> dict[str, Any]:
try:
driver_factory = build_driver_factory(
request.driver_type,
request.connection_info,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
device_id = uuid4().hex
device = device_manager.register_device(
device_id,
driver_factory,
name=request.name,
driver_type=request.driver_type,
connection_info=request.connection_info,
)
config_store.add(
device_id=device_id,
name=request.name,
driver_type=request.driver_type,
connection_info=request.connection_info,
)
return device.to_dict()
@router.delete(
"/devices/{device_id}",
status_code=status.HTTP_204_NO_CONTENT,
response_model=None,
)
def unregister_device(device_id: str) -> Response:
if not _has_device(device_manager, device_id):
raise HTTPException(status_code=404, detail="device not found")
device_manager.unregister_device(device_id)
config_store.remove(device_id)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/tasks")
def tasks(
device_id: str | None = None,
status: str | None = None,
) -> list[dict[str, Any]]:
rows = metadata_store.list_tasks()
if device_id is not None:
rows = [row for row in rows if row["device_id"] == device_id]
if status is not None:
rows = [row for row in rows if row["status"] == status]
return rows
@router.get("/tasks/{task_id}")
def task_detail(task_id: str) -> dict[str, Any]:
task = metadata_store.get_task(task_id)
if task is None:
raise HTTPException(status_code=404, detail="task not found")
return task
@router.get("/tasks/{task_id}/timeline")
def task_timeline(task_id: str) -> list[dict[str, Any]]:
if metadata_store.get_task(task_id) is None:
raise HTTPException(status_code=404, detail="task not found")
return [_inline_screenshot(record) for record in timeline.read(task_id)]
@router.get("/config")
def runtime_config() -> dict[str, int]:
return {"max_steps": _runner_max_steps(task_runner)}
@router.put("/config")
def update_runtime_config(request: RuntimeConfigRequest) -> dict[str, int]:
if request.max_steps <= 0:
raise HTTPException(status_code=400, detail="max_steps must be positive")
_set_runner_max_steps(task_runner, request.max_steps)
config_store.set_setting("max_steps", request.max_steps)
return {"max_steps": request.max_steps}
return router
def _inline_screenshot(record: dict[str, Any]) -> dict[str, Any]:
payload = dict(record)
screenshot_path = payload.get("screenshot_path")
if screenshot_path:
path = Path(str(screenshot_path))
if path.exists():
payload["image_base64"] = base64.b64encode(path.read_bytes()).decode(
"ascii"
)
return payload
def _has_device(device_manager: DeviceManager, device_id: str) -> bool:
return any(device.id == device_id for device in device_manager.list_devices())
def _runner_max_steps(task_runner: TaskRunner) -> int:
config = getattr(task_runner, "config", None)
if config is None or not hasattr(config, "max_steps"):
raise RuntimeError("task runner config unavailable")
return int(config.max_steps)
def _set_runner_max_steps(task_runner: TaskRunner, max_steps: int) -> None:
config = getattr(task_runner, "config", None)
if config is None or not hasattr(config, "max_steps"):
raise RuntimeError("task runner config unavailable")
config.max_steps = max_steps
+45 -30
View File
@@ -3,7 +3,7 @@ from collections.abc import Callable
from typing import Any
from api.errors import call_with_semantic_errors
from device.manager import DEFAULT_MANAGER, DeviceManager
from device.manager import DeviceManager
from tools.describe_screen import describe_screen
from tools.find_icon import find_icon_on_screen
from tools.find_text import find_text_on_screen
@@ -17,12 +17,11 @@ from tools.ui_tree import get_ui_tree
def tool_handlers(
*,
manager: DeviceManager | None = None,
manager: DeviceManager,
) -> dict[str, Callable[..., Any]]:
device_manager = manager or DEFAULT_MANAGER
def _screenshot(device_id: str | None = None) -> dict[str, Any]:
image = take_screenshot(device_id, manager=device_manager)
image = take_screenshot(device_id, manager=manager)
return {
"ok": True,
"image_base64": base64.b64encode(image).decode("ascii"),
@@ -39,70 +38,77 @@ def tool_handlers(
x,
y,
device_id=device_id,
manager=device_manager,
manager=manager,
),
"swipe": lambda start_x, start_y, end_x, end_y, duration_ms=500, device_id=None: call_with_semantic_errors(
swipe,
start_x,
start_y,
end_x,
end_y,
duration_ms=duration_ms,
device_id=device_id,
manager=device_manager,
"swipe": lambda start_x, start_y, end_x, end_y, duration_ms=500, device_id=None: (
call_with_semantic_errors(
swipe,
start_x,
start_y,
end_x,
end_y,
duration_ms=duration_ms,
device_id=device_id,
manager=manager,
)
),
"input_text": lambda text, device_id=None: call_with_semantic_errors(
input_text,
text,
device_id=device_id,
manager=device_manager,
manager=manager,
),
"launch_app": lambda app_id, device_id=None: call_with_semantic_errors(
launch_app,
app_id,
device_id=device_id,
manager=device_manager,
manager=manager,
),
"find_text": lambda query, device_id=None: call_with_semantic_errors(
find_text_on_screen,
query,
device_id=device_id,
manager=device_manager,
manager=manager,
),
"find_icon": lambda name, device_id=None: call_with_semantic_errors(
find_icon_on_screen,
name,
device_id=device_id,
manager=device_manager,
manager=manager,
),
"get_ui_tree": lambda device_id=None: call_with_semantic_errors(
get_ui_tree,
device_id,
manager=device_manager,
"get_ui_tree": lambda device_id=None, include_app_info=False: (
call_with_semantic_errors(
get_ui_tree,
device_id,
manager=manager,
include_app_info=include_app_info,
)
),
"describe_screen": lambda device_id=None: call_with_semantic_errors(
lambda: describe_screen(device_id, manager=device_manager).to_dict()
lambda: describe_screen(device_id, manager=manager).to_dict()
),
"list_devices": lambda: [
device.to_dict() for device in device_manager.list_devices()
],
"list_devices": lambda: [device.to_dict() for device in manager.list_devices()],
"device_status": lambda device_id: call_with_semantic_errors(
lambda: {"device_id": device_id, "status": device_manager.status(device_id)}
lambda: {"device_id": device_id, "status": manager.status(device_id)}
),
}
def create_mcp_server(
*,
manager: DeviceManager | None = None,
manager: DeviceManager,
skill_catalog_store: Any | None = None,
skill_active_subscriptions: set[str] | None = None,
skill_local_store: Any | None = None,
) -> Any:
try:
from mcp.server.fastmcp import FastMCP
except ImportError as exc:
raise RuntimeError("mcp SDK is not installed") from exc
if manager is None:
raise ValueError("create_mcp_server requires a non-None manager")
handlers = tool_handlers(manager=manager)
server = FastMCP("apex-agent")
@@ -149,8 +155,14 @@ def create_mcp_server(
return handlers["find_icon"](name=name, device_id=device_id)
@server.tool(name="get_ui_tree")
def _get_ui_tree(device_id: str | None = None) -> Any:
return handlers["get_ui_tree"](device_id=device_id)
def _get_ui_tree(
device_id: str | None = None,
include_app_info: bool = False,
) -> Any:
return handlers["get_ui_tree"](
device_id=device_id,
include_app_info=include_app_info,
)
@server.tool(name="describe_screen")
def _describe_screen(device_id: str | None = None) -> dict[str, Any]:
@@ -166,10 +178,13 @@ def create_mcp_server(
if skill_catalog_store is not None:
from api.skill_catalog_mcp import register_skill_catalog_tools
from storage.local_skills import LocalSkillStore
local_store = skill_local_store or LocalSkillStore()
register_skill_catalog_tools(
server,
store=skill_catalog_store,
local_store=local_store,
get_active_subscriptions=lambda: set(skill_active_subscriptions or set()),
get_registered_tools=lambda: set(handlers.keys()),
)
-170
View File
@@ -1,170 +0,0 @@
from typing import Any
from api.console import create_console_router
from api.errors import semantic_error
from core.models import Task
from device.manager import DEFAULT_MANAGER, DeviceManager
from driver.registry import build_driver_factory
from runtime.executor import Executor, default_tool_registry
from runtime.task import TaskRunner, TaskRunnerConfig
from storage.device_config import DEFAULT_MAX_STEPS, DeviceConfigStore
from storage.task_metadata import TaskMetadataStore
from storage.timeline import Timeline
from tools.launch_app import launch_app
from tools.screenshot import take_screenshot
from tools.tap import tap
def create_app(
*,
manager: DeviceManager | None = None,
task_runner: TaskRunner | None = None,
metadata_store: TaskMetadataStore | None = None,
device_config_store: DeviceConfigStore | None = None,
timeline: Timeline | None = None,
) -> Any:
from fastapi import BackgroundTasks, FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
device_manager = manager or DEFAULT_MANAGER
store = metadata_store or TaskMetadataStore()
config_store = device_config_store or DeviceConfigStore()
timeline_store = timeline or Timeline()
max_steps = _load_max_steps(config_store)
_reload_device_configs(device_manager, config_store)
runner = task_runner or TaskRunner(
metadata_store=store,
executor=Executor(tools=default_tool_registry(manager=device_manager)),
timeline=timeline_store,
config=TaskRunnerConfig(max_steps=max_steps),
)
_apply_max_steps(runner, max_steps)
app = FastAPI(title="Apex Agent API")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
class TapRequest(BaseModel):
x: float
y: float
class ScreenshotResponse(BaseModel):
image_base64: str
mime_type: str = "image/png"
class LaunchRequest(BaseModel):
app_id: str
class AgentTaskRequest(BaseModel):
goal: str
device_id: str
@app.get("/devices")
def devices() -> list[dict[str, Any]]:
return [device.to_dict() for device in device_manager.list_devices()]
@app.post("/devices/{device_id}/tap")
def tap_device(device_id: str, request: TapRequest) -> dict[str, Any]:
return _raise_semantic(
lambda: tap(
request.x,
request.y,
device_id=device_id,
manager=device_manager,
)
)
@app.post("/devices/{device_id}/screenshot")
def screenshot_device(device_id: str) -> dict[str, str]:
import base64
image = _raise_semantic(
lambda: take_screenshot(device_id, manager=device_manager)
)
response = ScreenshotResponse(
image_base64=base64.b64encode(image).decode("ascii")
)
return response.model_dump()
@app.post("/devices/{device_id}/launch")
def launch_device(device_id: str, request: LaunchRequest) -> dict[str, Any]:
return _raise_semantic(
lambda: launch_app(
request.app_id,
device_id=device_id,
manager=device_manager,
)
)
@app.post("/agent/task")
def start_task(
request: AgentTaskRequest,
background_tasks: BackgroundTasks,
) -> dict[str, str]:
task = Task(goal=request.goal, device_id=request.device_id)
store.create_task(task)
background_tasks.add_task(runner.run, task)
return {"task_id": task.id, "status": task.status}
@app.get("/task/{task_id}")
def get_task(task_id: str) -> dict[str, Any]:
task = store.get_task(task_id)
if task is None:
raise HTTPException(status_code=404, detail="task not found")
return task
app.include_router(
create_console_router(
device_manager=device_manager,
metadata_store=store,
timeline=timeline_store,
config_store=config_store,
task_runner=runner,
)
)
return app
def _raise_semantic(func: Any) -> Any:
from fastapi import HTTPException
try:
return func()
except Exception as exc:
raise HTTPException(status_code=400, detail=semantic_error(exc)) from exc
def _load_max_steps(config_store: DeviceConfigStore) -> int:
raw_value = config_store.get_setting("max_steps")
try:
max_steps = int(raw_value) if raw_value is not None else DEFAULT_MAX_STEPS
except ValueError:
return DEFAULT_MAX_STEPS
if max_steps <= 0:
return DEFAULT_MAX_STEPS
return max_steps
def _apply_max_steps(task_runner: Any, max_steps: int) -> None:
config = getattr(task_runner, "config", None)
if config is not None and hasattr(config, "max_steps"):
config.max_steps = max_steps
def _reload_device_configs(
device_manager: DeviceManager,
config_store: DeviceConfigStore,
) -> None:
for config in config_store.list():
device_manager.register_device(
config["device_id"],
build_driver_factory(config["driver_type"], config["connection_info"]),
name=config["name"],
driver_type=config["driver_type"],
connection_info=config["connection_info"],
)
+168 -26
View File
@@ -1,22 +1,31 @@
"""MCP tool surface for the Skill Catalog.
API layer per CONSTITUTION.md: MCP dependencies (FastMCP) live here.
Reads from :mod:`storage.skill_catalog`; flow-template parameter resolution
delegates to the existing :func:`workflow.skill_exec.resolve_skill_steps`.
Reads go through the unified merge surface in :mod:`api.skill_catalog_view`
(synced + local, with override precedence); flow-template parameter
resolution delegates to the existing :func:`workflow.skill_exec.resolve_skill_steps`.
No server-side execution primitive is exposed — flow templates are resolved
here but executed step-by-step by the LLM via the existing device-capability
tools (design D5).
Authoring tools (``create_skill``/``update_skill``/``delete_skill``) dispatch by
origin (design D10): they edit/delete local skills and create/update/remove
local overrides for cloud skills, never writing to the synced store.
"""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from skills_learning.models import (
FlowStep,
FlowTemplateSkill,
KnowledgeSkill,
SkillMetadata,
)
from api.skill_catalog_view import SkillCatalogView, SkillSummary, SkillView
from storage.local_skills import LocalSkillStore
from storage.skill_catalog import SkillCatalogStore
from workflow.skill_exec import SkillExecutionError, resolve_skill_steps
@@ -27,6 +36,9 @@ SKILL_TOOL_NAMES = (
"search_skills",
"get_skill",
"resolve_flow_template",
"create_skill",
"update_skill",
"delete_skill",
)
@@ -51,9 +63,15 @@ class MissingParameterError(SkillCatalogError):
"""Raised when required flow-template parameters are missing/invalid."""
class SkillAuthoringError(SkillCatalogError):
"""Raised when an authoring operation cannot be applied (e.g., deleting a
cloud skill that has no local override)."""
def skill_tool_handlers(
*,
store: SkillCatalogStore,
local_store: LocalSkillStore,
get_active_subscriptions: Callable[[], set[str]],
get_registered_tools: Callable[[], set[str]] | None = None,
) -> dict[str, Callable[..., dict[str, Any]]]:
@@ -62,44 +80,41 @@ def skill_tool_handlers(
Decoupled from FastMCP so handlers can be tested directly without
standing up a server (mirrors :func:`api.mcp.tool_handlers`).
"""
view = SkillCatalogView(store, local_store)
local = local_store
tools_getter = get_registered_tools or (lambda: set())
def _list_skills() -> dict[str, Any]:
metas = store.list_skills(get_active_subscriptions())
return {
"ok": True,
"skills": [_metadata_to_summary(m) for m in metas],
}
summaries = view.list_skills(get_active_subscriptions())
return {"ok": True, "skills": [_summary_to_dict(s) for s in summaries]}
def _search_skills(query: str) -> dict[str, Any]:
metas = store.search_skills(query, get_active_subscriptions())
return {
"ok": True,
"skills": [_metadata_to_summary(m) for m in metas],
}
summaries = view.search_skills(query, get_active_subscriptions())
return {"ok": True, "skills": [_summary_to_dict(s) for s in summaries]}
def _get_skill(skill_id: str) -> dict[str, Any]:
skill = store.get_skill(
result = view.get_skill(
skill_id,
get_active_subscriptions(),
registered_tools=tools_getter(),
)
if skill is None:
if result is None:
return _error_response(SkillNotFoundError(skill_id))
return {"ok": True, "skill": _skill_to_full_dict(skill)}
return {"ok": True, "skill": _view_to_dict(result)}
def _resolve_flow_template(
skill_id: str,
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
params = params or {}
skill = store.get_skill(
result = view.get_skill(
skill_id,
get_active_subscriptions(),
registered_tools=tools_getter(),
)
if skill is None:
if result is None:
return _error_response(SkillNotFoundError(skill_id))
skill = result.skill
if not isinstance(skill, FlowTemplateSkill):
return _error_response(
InvalidFlowTemplateError(
@@ -112,11 +127,60 @@ def skill_tool_handlers(
return _error_response(MissingParameterError(str(exc)))
return {"ok": True, "steps": steps}
def _create_skill(payload: dict[str, Any]) -> dict[str, Any]:
try:
skill = _build_skill(payload)
except ValueError as exc:
return _error_response(SkillAuthoringError(str(exc)))
stored = local.create_local(skill)
return {"ok": True, "skill": _skill_to_full_dict(stored), "origin": "local"}
def _update_skill(skill_id: str, payload: dict[str, Any]) -> dict[str, Any]:
try:
skill = _build_skill(payload)
except ValueError as exc:
return _error_response(SkillAuthoringError(str(exc)))
if view.is_local_skill(skill_id):
stored = local.update_local(_with_id(skill, skill_id))
return {
"ok": True,
"skill": _skill_to_full_dict(stored),
"origin": "local",
}
# Cloud skill id (or anticipated one): create/update a local override.
stored = local.upsert_override(skill_id, skill)
return {
"ok": True,
"skill": _skill_to_full_dict(stored),
"origin": "cloud",
"locally_overridden": True,
}
def _delete_skill(skill_id: str) -> dict[str, Any]:
if view.is_local_skill(skill_id):
local.delete_local(skill_id)
return {"ok": True, "deleted": skill_id, "origin": "local"}
if view.has_override(skill_id):
local.remove_override(skill_id)
return {
"ok": True,
"deleted_override": skill_id,
"origin": "cloud",
}
return _error_response(
SkillAuthoringError(
f"skill {skill_id} is a cloud skill with no local override to remove"
)
)
return {
"list_skills": _list_skills,
"search_skills": _search_skills,
"get_skill": _get_skill,
"resolve_flow_template": _resolve_flow_template,
"create_skill": _create_skill,
"update_skill": _update_skill,
"delete_skill": _delete_skill,
}
@@ -124,18 +188,20 @@ def register_skill_catalog_tools(
server: Any,
*,
store: SkillCatalogStore,
local_store: LocalSkillStore,
get_active_subscriptions: Callable[[], set[str]],
get_registered_tools: Callable[[], set[str]] | None = None,
) -> Any:
"""Register ``list_skills``/``search_skills``/``get_skill``/
``resolve_flow_template`` as MCP tools on ``server``.
"""Register the skill MCP tools (read + authoring) on ``server``.
Returns the server so the caller can chain. No batch-execute tool is
registered (design D5): the LLM issues each resulting device-capability
tool call itself, preserving the Observe-Think-Act loop.
tool call itself, preserving the Observe-Think-Act loop. Authoring tools
are always registered (design D6): there is no enable/disable gate.
"""
handlers = skill_tool_handlers(
store=store,
local_store=local_store,
get_active_subscriptions=get_active_subscriptions,
get_registered_tools=get_registered_tools,
)
@@ -159,25 +225,58 @@ def register_skill_catalog_tools(
) -> dict[str, Any]:
return handlers["resolve_flow_template"](skill_id=skill_id, params=params)
@server.tool(name="create_skill")
def _create_skill(payload: dict[str, Any]) -> dict[str, Any]:
return handlers["create_skill"](payload=payload)
@server.tool(name="update_skill")
def _update_skill(skill_id: str, payload: dict[str, Any]) -> dict[str, Any]:
return handlers["update_skill"](skill_id=skill_id, payload=payload)
@server.tool(name="delete_skill")
def _delete_skill(skill_id: str) -> dict[str, Any]:
return handlers["delete_skill"](skill_id=skill_id)
return server
def _metadata_to_summary(meta: SkillMetadata) -> dict[str, Any]:
"""Compact metadata for list/search — no Subscription-Platform-specific fields."""
# ----------------------------------------------------------------------
# Serialization helpers
# ----------------------------------------------------------------------
def _summary_to_dict(summary: SkillSummary) -> dict[str, Any]:
meta = summary.metadata
return {
"id": meta.id,
"name": meta.name,
"description": meta.description,
"kind": meta.kind,
"tags": list(meta.tags),
"origin": summary.origin,
"locally_overridden": summary.locally_overridden,
}
def _view_to_dict(view: SkillView) -> dict[str, Any]:
base = _skill_to_full_dict(view.skill)
base["origin"] = view.origin
base["locally_overridden"] = view.locally_overridden
return base
def _skill_to_full_dict(skill: Any) -> dict[str, Any]:
"""Full skill payload for ``get_skill``."""
base = _metadata_to_summary(skill.metadata)
base["version"] = skill.metadata.version
base["updated_at"] = skill.metadata.updated_at.isoformat()
"""Full skill payload for ``get_skill`` / authoring responses."""
meta: SkillMetadata = skill.metadata
base = {
"id": meta.id,
"name": meta.name,
"description": meta.description,
"kind": meta.kind,
"tags": list(meta.tags),
"version": meta.version,
"updated_at": meta.updated_at.isoformat(),
}
if isinstance(skill, KnowledgeSkill):
base["content"] = skill.content
elif isinstance(skill, FlowTemplateSkill):
@@ -188,6 +287,46 @@ def _skill_to_full_dict(skill: Any) -> dict[str, Any]:
return base
def _build_skill(payload: dict[str, Any]) -> KnowledgeSkill | FlowTemplateSkill:
"""Construct a Skill from an authoring payload."""
kind = str(payload.get("kind") or "").strip()
name = str(payload.get("name") or "").strip()
if not name:
raise ValueError("skill name must not be empty")
if kind not in ("knowledge", "flow_template"):
raise ValueError(f"unsupported skill kind: {kind!r}")
tags = [str(tag) for tag in payload.get("tags") or []]
meta = SkillMetadata(name=name, kind=kind, tags=tags) # type: ignore[arg-type]
if kind == "knowledge":
return KnowledgeSkill(metadata=meta, content=str(payload.get("content") or ""))
steps = [
FlowStep(
tool_name=str(step.get("tool_name") or step.get("action") or ""),
args=dict(step.get("args") or {}),
)
for step in (payload.get("steps") or [])
]
parameters = {
str(name): dict(schema)
for name, schema in (payload.get("parameters") or {}).items()
}
return FlowTemplateSkill(metadata=meta, steps=steps, parameters=parameters)
def _with_id(skill: KnowledgeSkill | FlowTemplateSkill, skill_id: str):
meta = skill.metadata
from dataclasses import replace
new_meta = replace(meta, id=skill_id)
if isinstance(skill, KnowledgeSkill):
return KnowledgeSkill(metadata=new_meta, content=skill.content)
return FlowTemplateSkill(
metadata=new_meta,
steps=list(skill.steps),
parameters={n: dict(s) for n, s in skill.parameters.items()},
)
def _error_response(exc: Exception) -> dict[str, Any]:
return {"ok": False, "error": _semantic_skill_error(exc)}
@@ -200,4 +339,7 @@ def _semantic_skill_error(exc: Exception) -> str:
if isinstance(exc, MissingParameterError):
message = str(exc)
return f"missing parameter: {message}" if message else "invalid parameter"
if isinstance(exc, SkillAuthoringError):
message = str(exc)
return f"authoring error: {message}" if message else "authoring error"
return "operation failed"
+159
View File
@@ -0,0 +1,159 @@
"""Unified read-merge surface over the synced catalog and the local skill store.
API layer per CONSTITUTION.md: this module reads from both
:mod:`storage.skill_catalog` (cloud-synced, read-only-except-sync) and
:mod:`storage.local_skills` (agent-authored + overrides) and presents a single
origin-discriminated catalog. It is the only module that reads both stores.
Override semantics (design D8/D9/D11): a local override shadows the cloud
skill at read time, wins against sync updates, and is reported with
``origin = "cloud"`` and ``locally_overridden = true`` while it shadows.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
from skills_learning.models import (
FlowTemplateSkill,
KnowledgeSkill,
SkillMetadata,
)
from storage.local_skills import LocalSkillStore
from storage.skill_catalog import SkillCatalogStore
Origin = Literal["cloud", "local"]
@dataclass(frozen=True)
class SkillSummary:
"""Metadata + origin for list/search results."""
metadata: SkillMetadata
origin: Origin
locally_overridden: bool
@dataclass(frozen=True)
class SkillView:
"""Full skill + origin for get_skill results."""
skill: KnowledgeSkill | FlowTemplateSkill
origin: Origin
locally_overridden: bool
class SkillCatalogView:
"""Merged read surface over synced + local skills, with override precedence."""
def __init__(self, synced: SkillCatalogStore, local: LocalSkillStore) -> None:
self._synced = synced
self._local = local
# ------------------------------------------------------------------
# Merged reads
# ------------------------------------------------------------------
def list_skills(
self,
active_subscriptions: set[str],
) -> list[SkillSummary]:
override_ids = self._local.list_override_cloud_ids()
summaries: list[SkillSummary] = []
# Local authored skills.
for meta in self._local.list_local():
summaries.append(SkillSummary(meta, "local", False))
# Cloud-synced skills, applying overrides where present.
for meta in self._synced.list_skills(active_subscriptions):
if meta.id in override_ids:
override = self._local.get_override(meta.id)
if override is not None:
summaries.append(SkillSummary(override.metadata, "cloud", True))
continue
summaries.append(SkillSummary(meta, "cloud", False))
summaries.sort(key=lambda s: s.metadata.name)
return summaries
def search_skills(
self,
query: str,
active_subscriptions: set[str],
) -> list[SkillSummary]:
override_ids = self._local.list_override_cloud_ids()
summaries: list[SkillSummary] = []
for meta in self._local.search_local(query):
summaries.append(SkillSummary(meta, "local", False))
for meta in self._synced.search_skills(query, active_subscriptions):
if meta.id in override_ids:
override = self._local.get_override(meta.id)
if override is not None:
summaries.append(SkillSummary(override.metadata, "cloud", True))
continue
summaries.append(SkillSummary(meta, "cloud", False))
return summaries
def get_skill(
self,
skill_id: str,
active_subscriptions: set[str],
*,
registered_tools: set[str] | None = None,
) -> SkillView | None:
# 1. Local authored skill.
local_skill = self._local.get_local(skill_id)
if local_skill is not None:
return SkillView(local_skill, "local", False)
# 2. Override shadowing a cloud skill id.
override = self._local.get_override(skill_id)
if override is not None:
if (
isinstance(override, FlowTemplateSkill)
and registered_tools is not None
and not _tools_valid(override, registered_tools)
):
return None
return SkillView(override, "cloud", True)
# 3. Cloud-synced skill (None for unknown AND not-visible — no leak).
cloud_skill = self._synced.get_skill(
skill_id,
active_subscriptions,
registered_tools=registered_tools,
)
if cloud_skill is not None:
return SkillView(cloud_skill, "cloud", False)
return None
# ------------------------------------------------------------------
# Origin classification for authoring dispatch (D10)
# ------------------------------------------------------------------
def is_local_skill(self, skill_id: str) -> bool:
return self._local.get_local(skill_id) is not None
def has_override(self, cloud_skill_id: str) -> bool:
return self._local.has_override(cloud_skill_id)
def _tools_valid(skill: FlowTemplateSkill, registered_tools: set[str]) -> bool:
return all(step.tool_name in registered_tools for step in skill.steps)
def make_view_from_stores(
synced: SkillCatalogStore,
local: LocalSkillStore,
) -> SkillCatalogView:
return SkillCatalogView(synced, local)
def default_local_store() -> LocalSkillStore:
"""Lazy default local store (created on first use)."""
return LocalSkillStore()
+106 -4
View File
@@ -7,6 +7,7 @@ truth" contract is enforced by that import boundary.
Push (webhook) is optional; the baseline pull loop is correct standalone.
"""
from __future__ import annotations
import logging
@@ -21,6 +22,7 @@ from skills_learning.models import (
KnowledgeSkill,
Skill,
)
from storage.local_skills import LocalSkillStore
from storage.skill_catalog import SkillCatalogStore
log = logging.getLogger(__name__)
@@ -144,6 +146,98 @@ def _parse_sync_payload(payload: dict[str, Any]) -> SyncDelta:
)
class CloudApiSkillClient:
"""Concrete client for this project's Cloud API per-host skill sync endpoint.
The ``subscription_id`` passed to :meth:`fetch_entitled_skills` is the
agent's host identifier; the endpoint is
``GET /internal/v1/hosts/{host_id}/skills/sync`` authenticated with the
same host-scoped bearer used for heartbeat/planner-decision.
"""
def __init__(
self,
base_url: str,
host_token: str,
*,
timeout: float = 30.0,
client: httpx.Client | None = None,
) -> None:
self.base_url = base_url.rstrip("/")
self.host_token = host_token
self.timeout = timeout
self._client = client
def fetch_entitled_skills(
self,
subscription_id: str,
since_version: int | None = None,
) -> SyncDelta:
url = f"{self.base_url}/internal/v1/hosts/{subscription_id}/skills/sync"
params: dict[str, Any] = {}
if since_version is not None:
params["since_version"] = str(since_version)
response = self._ensure_client().get(
url,
params=params or None,
headers={"Authorization": f"Bearer {self.host_token}"},
timeout=self.timeout,
)
response.raise_for_status()
return _parse_cloud_sync_payload(response.json())
def report_inventory(self, host_id: str, inventory: list[dict[str, Any]]) -> None:
"""Best-effort local-skill inventory report to the Cloud (design D7)."""
response = self._ensure_client().post(
f"{self.base_url}/internal/v1/hosts/{host_id}/skills/inventory",
json={"skills": inventory},
headers={"Authorization": f"Bearer {self.host_token}"},
timeout=self.timeout,
)
response.raise_for_status()
def close(self) -> None:
if self._client is not None:
self._client.close()
self._client = None
def _ensure_client(self) -> httpx.Client:
if self._client is None:
self._client = httpx.Client()
return self._client
def _parse_cloud_sync_payload(payload: dict[str, Any]) -> SyncDelta:
"""Parse the Cloud API sync response into a SyncDelta.
The Cloud skill payloads carry ``revision`` (mapped to the local
``version``) and kind-specific ``content``/``steps``/``parameters`` fields
that line up with :class:`skills_learning.models` ``from_dict``.
"""
skills: list[Skill] = []
for item in payload.get("skills") or []:
normalized = dict(item)
if "version" not in normalized and "revision" in normalized:
normalized["version"] = normalized["revision"]
source = "cloud"
kind = normalized.get("kind", "knowledge")
normalized["source"] = source
if kind == "flow_template":
skills.append(FlowTemplateSkill.from_dict(normalized))
else:
skills.append(KnowledgeSkill.from_dict(normalized))
removed_ids = [str(rid) for rid in payload.get("removed_ids") or []]
latest_raw = payload.get("latest_version")
latest_version = int(latest_raw) if latest_raw is not None else None
is_full_replace = bool(payload.get("is_full_replace", True))
return SyncDelta(
skills=skills,
removed_ids=removed_ids,
latest_version=latest_version,
is_full_replace=is_full_replace,
)
class SkillSyncRunner:
"""Drives periodic sync between the Subscription Platform and local catalog.
@@ -160,11 +254,13 @@ class SkillSyncRunner:
client: SubscriptionClient,
subscriptions: list[str],
poll_interval: float = 300.0,
local_store: LocalSkillStore | None = None,
) -> None:
self.store = store
self.client = client
self.subscriptions = list(subscriptions)
self.poll_interval = poll_interval
self.local_store = local_store
self._stop = threading.Event()
self._thread: threading.Thread | None = None
self._tick_lock = threading.Lock()
@@ -209,12 +305,13 @@ class SkillSyncRunner:
self._stop.wait(self.poll_interval)
def _sync_one(self, subscription_id: str) -> SyncOutcome:
since_version = self.store._get_subscription_version(subscription_id)
try:
delta = self.client.fetch_entitled_skills(subscription_id)
except Exception as exc:
log.warning(
"skill sync fetch failed for %s: %s", subscription_id, exc
delta = self.client.fetch_entitled_skills(
subscription_id, since_version=since_version
)
except Exception as exc:
log.warning("skill sync fetch failed for %s: %s", subscription_id, exc)
self.store._set_subscription_state(
subscription_id,
last_error=f"{type(exc).__name__}: {exc}",
@@ -233,6 +330,11 @@ class SkillSyncRunner:
for skill in delta.skills:
self.store._apply_sync_upsert(skill, subscription_id)
for skill_id in delta.removed_ids:
# Fork-on-revocation (design D9): if a local override shadows
# this cloud skill, promote it to a standalone local skill
# before the cloud id disappears from the synced store.
if self.local_store is not None:
self.local_store.fork_override_to_local(skill_id)
self.store._apply_sync_remove(skill_id)
self.store._set_subscription_state(
subscription_id,
+122
View File
@@ -0,0 +1,122 @@
from __future__ import annotations
import argparse
from collections.abc import Sequence
from datetime import timedelta
from getpass import getpass
from cloud.control_config import load_control_config
from cloud.database import CloudDatabase
from cloud.schema import require_current_schema
from cloud.user_auth import UserAuthService, UserAuthSettings, normalize_username, utc_now
def main(argv: Sequence[str] | None = None) -> None:
parser = argparse.ArgumentParser(description="Administer Cloud Console user accounts")
commands = parser.add_subparsers(dest="command", required=True)
users = commands.add_parser("users", help="manage user accounts")
user_commands = users.add_subparsers(dest="user_command", required=True)
create = user_commands.add_parser("create", help="create a user interactively")
create.add_argument("--username", required=True)
create.add_argument("--display-name")
create.add_argument("--role", choices=("viewer", "operator", "admin"), required=True)
reset = user_commands.add_parser("reset-password", help="reset a user password")
reset.add_argument("--username", required=True)
enable = user_commands.add_parser("enable", help="enable a disabled user")
enable.add_argument("--username", required=True)
revoke = user_commands.add_parser("revoke-sessions", help="revoke a user's sessions")
revoke.add_argument("--username", required=True)
args = parser.parse_args(argv)
config = load_control_config()
require_current_schema(config.database_url)
database = CloudDatabase(config.database_url, create_schema=False)
service = UserAuthService(
database.repository,
settings=UserAuthSettings(
session_idle_ttl=timedelta(seconds=config.user_session_idle_seconds),
session_absolute_ttl=timedelta(
seconds=config.user_session_absolute_seconds
),
login_failure_limit=config.login_failure_limit,
login_failure_window=timedelta(
seconds=config.login_failure_window_seconds
),
login_block_duration=timedelta(seconds=config.login_block_seconds),
cookie_secure=config.session_cookie_secure,
),
)
try:
_run_user_command(args, service)
finally:
database.close()
def _run_user_command(args: argparse.Namespace, service: UserAuthService) -> None:
username = normalize_username(args.username)
if args.user_command == "create":
password = _read_password()
user = service.create_user(
username=args.username,
display_name=args.display_name or args.username,
role=args.role,
password=password,
)
service.record_admin_action(
actor_principal_id="deployment-cli",
target_user_id=user.id,
action="user_create",
metadata={"role": user.role},
)
print(f"created user {user.username!r} with role {user.role}")
return
user = service.repository.get_user_by_normalized_username(username) # type: ignore[attr-defined]
if user is None:
raise SystemExit("user not found")
if args.user_command == "reset-password":
service.reset_password(
user_id=user.id,
new_password=_read_password(),
actor_principal_id="deployment-cli",
)
print(f"reset password for {user.username!r}")
return
if args.user_command == "enable":
updated = service.repository.update_user( # type: ignore[attr-defined]
user.id,
enabled=True,
updated_at=utc_now(),
)
service.record_admin_action(
actor_principal_id="deployment-cli",
target_user_id=updated.id,
action="user_enable",
)
print(f"enabled user {updated.username!r}")
return
if args.user_command == "revoke-sessions":
service.repository.revoke_user_sessions( # type: ignore[attr-defined]
user.id,
revoked_at=utc_now(),
)
service.record_admin_action(
actor_principal_id="deployment-cli",
target_user_id=user.id,
action="session_revoke",
)
print(f"revoked sessions for {user.username!r}")
return
raise AssertionError(f"unsupported command {args.user_command!r}")
def _read_password() -> str:
password = getpass("Password: ")
confirmation = getpass("Confirm password: ")
if password != confirmation:
raise SystemExit("password confirmation did not match")
return password
+166 -6
View File
@@ -5,6 +5,7 @@ import logging
from collections.abc import Callable
from contextlib import asynccontextmanager
from dataclasses import dataclass
from datetime import timedelta
from pathlib import Path
from typing import Any
@@ -17,8 +18,8 @@ from starlette.types import Scope
from cloud.auth import (
ChainedAuthProvider,
ConfiguredEnrollmentTokenProvider,
RepositoryHostAuthProvider,
UserSessionAuthProvider,
create_auth_provider,
)
from cloud.config import CloudConfig
@@ -30,6 +31,8 @@ from cloud.control_config import (
)
from cloud.database import CloudDatabase
from cloud.internal_api.api import create_internal_router
from cloud.llm_providers import LlmProviderService
from cloud.skills import CloudSkillService
from cloud.plugins import PluginRegistry
from cloud.observability import (
CORRELATION_HEADER,
@@ -42,6 +45,11 @@ from cloud.pool import DevicePool
from cloud.scheduler import TaskScheduler
from cloud.schema import require_current_schema
from cloud.sdk.api import create_cloud_router
from cloud.sdk.governance_api import create_governance_router
from cloud.sdk.llm_provider_api import create_llm_provider_router
from cloud.sdk.skill_api import create_skill_host_router, create_skill_management_router
from cloud.sdk.user_api import create_user_auth_router
from cloud.user_auth import USER_CSRF_COOKIE, USER_SESSION_COOKIE, UserAuthService, UserAuthSettings
from core.models import utc_now
@@ -72,6 +80,8 @@ class CloudApplicationServices:
scheduler: TaskScheduler
plugin_registry: PluginRegistry
auth_provider: Any
user_auth_service: UserAuthService
llm_provider_service: LlmProviderService
class SpaStaticFiles(StaticFiles):
@@ -103,19 +113,33 @@ def create_app(
validate_control_config(control_config)
build_database = database_factory or _default_database_factory
configured_auth_provider = create_auth_provider(
control_config.credentials,
allow_insecure_anonymous=control_config.allow_insecure_anonymous,
)
repository = _RepositoryProxy()
user_auth_service = UserAuthService(
repository,
settings=UserAuthSettings(
session_idle_ttl=timedelta(seconds=control_config.user_session_idle_seconds),
session_absolute_ttl=timedelta(
seconds=control_config.user_session_absolute_seconds
),
login_failure_limit=control_config.login_failure_limit,
login_failure_window=timedelta(
seconds=control_config.login_failure_window_seconds
),
login_block_duration=timedelta(seconds=control_config.login_block_seconds),
cookie_secure=control_config.session_cookie_secure,
),
)
llm_provider_service = LlmProviderService(repository)
cloud_skill_service = CloudSkillService(repository)
auth_provider = ChainedAuthProvider(
(
configured_auth_provider,
RepositoryHostAuthProvider(repository), # type: ignore[arg-type]
UserSessionAuthProvider(user_auth_service),
)
)
enrollment_auth_provider = ConfiguredEnrollmentTokenProvider(
control_config.enrollment_credentials
)
domain_config = CloudConfig(
lease_duration_seconds=control_config.lease_duration_seconds,
)
@@ -128,6 +152,8 @@ def create_app(
scheduler=scheduler,
plugin_registry=plugin_registry,
auth_provider=auth_provider,
user_auth_service=user_auth_service,
llm_provider_service=llm_provider_service,
)
@asynccontextmanager
@@ -164,6 +190,19 @@ def create_app(
),
name="cloud-lease-reaper",
),
asyncio.create_task(
_run_planner_decision_log_pruner_loop(
services,
stop_workers,
interval_seconds=(
control_config.planner_decision_log_prune_interval_seconds
),
retention_days=(
control_config.planner_decision_log_retention_days
),
),
name="cloud-planner-decision-log-pruner",
),
]
app.state.worker_tasks = tuple(worker_tasks)
app.state.startup_complete = True
@@ -201,6 +240,12 @@ def create_app(
correlation_token = bind_correlation_id(correlation_id)
try:
response = await call_next(request)
if (
request.cookies.get(USER_SESSION_COOKIE)
and not request.headers.get("authorization")
and response.status_code == status.HTTP_401_UNAUTHORIZED
):
_clear_user_auth_cookies(response, control_config)
logger.info(
"cloud request completed",
extra={
@@ -251,14 +296,69 @@ def create_app(
scheduler=scheduler,
plugin_registry=plugin_registry,
auth_provider=auth_provider,
csrf_validator=lambda request, principal: _valid_csrf_request(
request,
principal,
user_auth_service,
),
)
)
app.include_router(
create_user_auth_router(
user_auth_service=user_auth_service,
auth_provider=auth_provider,
config=control_config,
)
)
app.include_router(
create_governance_router(
repository=repository,
auth_provider=auth_provider,
)
)
app.include_router(
create_llm_provider_router(
service=llm_provider_service,
repository=repository,
auth_provider=auth_provider,
csrf_validator=lambda request, principal: _valid_csrf_request(
request,
principal,
user_auth_service,
),
)
)
app.include_router(
create_skill_management_router(
service=cloud_skill_service,
repository=repository,
auth_provider=auth_provider,
csrf_validator=lambda request, principal: _valid_csrf_request(
request,
principal,
user_auth_service,
),
)
)
app.include_router(
create_skill_host_router(
service=cloud_skill_service,
auth_provider=auth_provider,
)
)
app.include_router(
create_internal_router(
pool=pool,
auth_provider=auth_provider,
enrollment_auth_provider=enrollment_auth_provider,
lease_duration_seconds=control_config.lease_duration_seconds,
scheduler=scheduler,
planner_token_reservation_ceiling=(
control_config.planner_token_reservation_ceiling
),
planner_token_reservation_ttl_seconds=(
control_config.planner_token_reservation_ttl_seconds
),
planner_provider_service=llm_provider_service,
)
)
@@ -289,6 +389,37 @@ def _default_database_factory(config: CloudControlConfig) -> CloudDatabase:
)
def _valid_csrf_request(
request: Request,
principal: Any,
user_auth_service: UserAuthService,
) -> bool:
if principal.session_id is None:
return True
return user_auth_service.validate_csrf(
session_token=request.cookies.get(USER_SESSION_COOKIE),
csrf_cookie=request.cookies.get(USER_CSRF_COOKIE),
csrf_header=request.headers.get("x-csrf-token"),
)
def _clear_user_auth_cookies(response: Any, config: CloudControlConfig) -> None:
response.delete_cookie(
USER_SESSION_COOKIE,
path="/",
secure=config.session_cookie_secure,
httponly=True,
samesite="lax",
)
response.delete_cookie(
USER_CSRF_COOKIE,
path="/",
secure=config.session_cookie_secure,
httponly=False,
samesite="lax",
)
async def _run_scheduler_loop(
services: CloudApplicationServices,
stop: asyncio.Event,
@@ -324,6 +455,10 @@ async def _run_lease_reaper_loop(
now=utc_now(),
max_attempts=max_attempts,
)
services.repository.cleanup_expired_token_reservations(
now=utc_now(),
limit=100,
)
except Exception:
logger.exception(
"cloud lifecycle iteration failed",
@@ -341,3 +476,28 @@ async def _wait_for_stop(stop: asyncio.Event, interval_seconds: float) -> bool:
except TimeoutError:
return False
return True
async def _run_planner_decision_log_pruner_loop(
services: CloudApplicationServices,
stop: asyncio.Event,
*,
interval_seconds: float,
retention_days: int,
) -> None:
while not stop.is_set():
correlation_token = bind_correlation_id(new_correlation_id())
try:
services.repository.prune_planner_decision_log(
now=utc_now(),
prune_after_terminal_seconds=retention_days * 86_400,
)
except Exception:
logger.exception(
"planner decision log prune failed",
extra={"worker": "planner_decision_log_pruner"},
)
finally:
reset_correlation_id(correlation_token)
if await _wait_for_stop(stop, interval_seconds):
return
+2 -1
View File
@@ -2,7 +2,7 @@
name = "device-cloud-api"
version = "0.1.0"
description = "Deployable Cloud Control Plane API for Device Agent Runtime."
requires-python = ">=3.14"
requires-python = ">=3.13,<3.14"
dependencies = [
"device-cloud-platform==0.1.0",
"fastapi>=0.115.0",
@@ -11,6 +11,7 @@ dependencies = [
[project.scripts]
device-cloud-api = "cloud_api.cli:main"
device-cloud-admin = "cloud_api.admin_cli:main"
[build-system]
requires = ["setuptools>=69"]
+65
View File
@@ -0,0 +1,65 @@
from __future__ import annotations
from cloud.control_config import CloudControlConfig
from cloud.database import CloudDatabase
from cloud.schema import upgrade_database
from cloud_api import admin_cli
def test_admin_cli_creates_user_with_interactive_password(monkeypatch, tmp_path, capsys) -> None:
database_url = f"sqlite:///{(tmp_path / 'cloud.sqlite3').as_posix()}"
upgrade_database(database_url)
monkeypatch.setattr(
admin_cli,
"load_control_config",
lambda: CloudControlConfig(database_url=database_url),
)
answers = iter(("correct-horse-battery-staple", "correct-horse-battery-staple"))
monkeypatch.setattr(admin_cli, "getpass", lambda _: next(answers))
admin_cli.main(
[
"users",
"create",
"--username",
"admin",
"--display-name",
"Administrator",
"--role",
"admin",
]
)
database = CloudDatabase(database_url, create_schema=False)
try:
user = database.repository.get_user_by_normalized_username("admin")
assert user is not None
assert user.role == "admin"
assert "correct-horse-battery-staple" not in capsys.readouterr().out
finally:
database.close()
def test_admin_cli_rejects_password_confirmation_mismatch(monkeypatch, tmp_path) -> None:
database_url = f"sqlite:///{(tmp_path / 'cloud.sqlite3').as_posix()}"
upgrade_database(database_url)
monkeypatch.setattr(
admin_cli,
"load_control_config",
lambda: CloudControlConfig(database_url=database_url),
)
answers = iter(("correct-horse-battery-staple", "different-password"))
monkeypatch.setattr(admin_cli, "getpass", lambda _: next(answers))
try:
admin_cli.main(["users", "create", "--username", "admin", "--role", "admin"])
except SystemExit as error:
assert str(error) == "password confirmation did not match"
else:
raise AssertionError("expected password confirmation failure")
database = CloudDatabase(database_url, create_schema=False)
try:
assert database.repository.get_user_by_normalized_username("admin") is None
finally:
database.close()
+15 -30
View File
@@ -9,7 +9,7 @@ from fastapi.testclient import TestClient
import cloud_api.app as app_module
from cloud_api.app import create_app
from cloud.auth import BearerCredential, EnrollmentCredential, digest_token
from cloud.auth import digest_token
from cloud.control_config import CloudConfigurationError, CloudControlConfig
from cloud.database import CloudDatabase
from cloud.pool import PooledDevice
@@ -33,24 +33,8 @@ def test_managed_host_enrollment_device_mapping_and_restart_authentication(
tmp_path,
) -> None:
database_url = f"sqlite:///{(tmp_path / 'enrollment.sqlite3').as_posix()}"
enrollment_token = "one-time-enrollment-token"
host_token = "host-token-" + ("x" * 40)
config = CloudControlConfig(
database_url=database_url,
credentials=(
BearerCredential(
principal_id="operator",
token="operator-token",
scopes=frozenset({"pool:read"}),
),
),
enrollment_credentials=(
EnrollmentCredential(
principal_id="installer-a",
token=enrollment_token,
),
),
)
config = CloudControlConfig(database_url=database_url)
enrollment_payload = {
"agent_instance_id": "agent-instance-a",
"host_token": host_token,
@@ -61,7 +45,6 @@ def test_managed_host_enrollment_device_mapping_and_restart_authentication(
with TestClient(app) as client:
enrolled = client.post(
"/internal/v1/enrollments",
headers={"Authorization": f"Bearer {enrollment_token}"},
json=enrollment_payload,
)
assert enrolled.status_code == 201
@@ -70,21 +53,21 @@ def test_managed_host_enrollment_device_mapping_and_restart_authentication(
retried = client.post(
"/internal/v1/enrollments",
headers={"Authorization": f"Bearer {enrollment_token}"},
json=enrollment_payload,
)
assert retried.status_code == 201
assert retried.json()["host_id"] == host_id
reused = client.post(
another_host = client.post(
"/internal/v1/enrollments",
headers={"Authorization": f"Bearer {enrollment_token}"},
json={
**enrollment_payload,
"agent_instance_id": "agent-instance-b",
"host_token": "host-token-" + ("y" * 40),
},
)
assert reused.status_code == 409
assert another_host.status_code == 201
assert another_host.json()["host_id"] != host_id
device = client.post(
f"/internal/v1/hosts/{host_id}/devices/enroll",
@@ -601,14 +584,16 @@ def _wait_until(predicate, timeout_seconds: float = 1.0) -> bool:
return False
def test_production_app_rejects_missing_credentials() -> None:
with pytest.raises(CloudConfigurationError, match="credential"):
create_app(
config=CloudControlConfig(
environment="production",
database_url="postgresql://db/cloud",
)
def test_production_app_allows_no_static_credentials() -> None:
app = create_app(
config=CloudControlConfig(
environment="production",
database_url="postgresql://db/cloud",
session_cookie_secure=True,
)
)
assert app.title == "Device Cloud API"
def test_cors_headers_are_absent_when_allow_list_is_empty() -> None:
@@ -0,0 +1,60 @@
from __future__ import annotations
from datetime import UTC, datetime
from cloud.llm_providers import LlmProviderProfile, ResolvedLlmProviderProfile
from cloud.planner_config import build_cloud_planner_client
from runtime.tool_calling_client import (
AnthropicToolCallingClient,
OpenAIToolCallingClient,
)
def _resolved_profile(
provider_type: str, *, base_url: str | None = None
) -> ResolvedLlmProviderProfile:
now = datetime.now(UTC)
profile = LlmProviderProfile(
id="provider-1",
name="Managed provider",
name_normalized="managed provider",
provider_type=provider_type, # type: ignore[arg-type]
model="test-model",
base_url=base_url,
timeout_seconds=15,
api_key_ciphertext="ciphertext",
key_last_rotated_at=now,
enabled=True,
revision=1,
created_at=now,
updated_at=now,
)
return ResolvedLlmProviderProfile(profile=profile, api_key="managed-api-key")
def test_build_cloud_planner_client_uses_managed_anthropic_key() -> None:
client = build_cloud_planner_client(_resolved_profile("anthropic"))
assert isinstance(client, AnthropicToolCallingClient)
assert client.model == "test-model"
assert client._api_key == "managed-api-key"
def test_build_cloud_planner_client_uses_anthropic_base_url() -> None:
client = build_cloud_planner_client(
_resolved_profile("anthropic", base_url="https://anthropic-proxy.example")
)
assert isinstance(client, AnthropicToolCallingClient)
assert client._base_url == "https://anthropic-proxy.example"
def test_build_cloud_planner_client_uses_openai_compatible_base_url() -> None:
client = build_cloud_planner_client(
_resolved_profile("openai-compatible", base_url="https://compat.example/v1")
)
assert isinstance(client, OpenAIToolCallingClient)
assert client.model == "test-model"
assert client._api_key == "managed-api-key"
assert client._base_url == "https://compat.example/v1"
@@ -0,0 +1,143 @@
"""Repository + service tests for Cloud-managed skills and per-host sync.
Uses an in-memory SQLite engine. Covers skill CRUD, per-host entitlement, the
monotonic entitlement_version bump, and incremental vs full-replace
fetch_host_delta semantics (design D2/D3).
"""
from __future__ import annotations
import json
import pytest
from sqlalchemy import create_engine
from sqlalchemy.pool import StaticPool
from cloud.db_models import Base
from cloud.skills import (
CloudSkillConflictError,
CloudSkillValidationError,
CloudSkillService,
)
from cloud.sql_repository import SQLAlchemyCloudRepository
from core.models import utc_now
@pytest.fixture
def service():
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(engine)
repo = SQLAlchemyCloudRepository(engine=engine, create_schema=False)
return CloudSkillService(repo)
def _knowledge_payload(name: str, content: str = "body") -> dict:
return dict(
name=name,
kind="knowledge",
description="d",
tags=["t"],
content=content,
steps_json="[]",
parameters_json="{}",
)
def test_create_list_get_skill(service):
now = utc_now()
created = service.create_skill(now=now, **_knowledge_payload("Alpha"))
assert created.name == "Alpha"
assert created.kind == "knowledge"
[fetched] = service.list_skills()
assert fetched.id == created.id
assert service.get_skill(created.id).content == "body"
def test_create_rejects_duplicate_name(service):
service.create_skill(now=utc_now(), **_knowledge_payload("Alpha"))
with pytest.raises(CloudSkillConflictError):
service.create_skill(now=utc_now(), **_knowledge_payload("Alpha"))
def test_create_rejects_blank_content_for_knowledge(service):
payload = _knowledge_payload("Alpha", content=" ")
with pytest.raises(CloudSkillValidationError):
service.create_skill(now=utc_now(), **payload)
def test_grant_revoke_entitlement_drives_delta(service):
now = utc_now()
skill = service.create_skill(now=now, **_knowledge_payload("Alpha"))
host = "host-1"
# First sync: no entitlements yet -> empty full replace.
delta = service.fetch_host_delta(host, since_version=None)
assert delta.is_full_replace is True
assert delta.skills == []
assert delta.latest_version == 0
# Grant -> version bumps, next full sync sees the skill.
service.grant_entitlement(skill.id, host, now=now)
full = service.fetch_host_delta(host, since_version=None)
assert [s.id for s in full.skills] == [skill.id]
assert full.latest_version == 1
# Incremental from 0 returns the grant.
incr = service.fetch_host_delta(host, since_version=0)
assert incr.is_full_replace is False
assert [s.id for s in incr.skills] == [skill.id]
assert incr.removed_ids == []
# Revoke -> version bumps, incremental reports removal.
service.revoke_entitlement(skill.id, host, now=now)
after = service.fetch_host_delta(host, since_version=incr.latest_version)
assert after.removed_ids == [skill.id]
assert after.skills == []
def test_skill_content_update_notifies_entitled_hosts(service):
now = utc_now()
skill = service.create_skill(now=now, **_knowledge_payload("Alpha", "v1"))
service.grant_entitlement(skill.id, "host-1", now=now)
baseline = service.fetch_host_delta("host-1", since_version=None).latest_version
updated = service.update_skill(
skill.id, now=utc_now(), **_knowledge_payload("Alpha", "v2")
)
assert updated.revision == 2
incr = service.fetch_host_delta("host-1", since_version=baseline)
assert [s.id for s in incr.skills] == [skill.id]
assert incr.skills[0].content == "v2"
def test_delete_skill_removes_and_notifies_entitled_hosts(service):
now = utc_now()
skill = service.create_skill(now=now, **_knowledge_payload("Alpha"))
service.grant_entitlement(skill.id, "host-1", now=now)
baseline = service.fetch_host_delta("host-1", since_version=None).latest_version
service.delete_skill(skill.id)
assert service.get_skill(skill.id) is None
after = service.fetch_host_delta("host-1", since_version=baseline)
assert after.removed_ids == [skill.id]
def test_stale_since_version_falls_back_to_full_replace(service):
now = utc_now()
skill = service.create_skill(now=now, **_knowledge_payload("Alpha"))
service.grant_entitlement(skill.id, "host-1", now=now)
# A version older than anything in the changelog must yield a full replace.
delta = service.fetch_host_delta("host-1", since_version=-5)
assert delta.is_full_replace is True
def test_inventory_record_and_readback(service):
now = utc_now()
payload = json.dumps([{"id": "local-1", "name": "My Note", "origin": "local"}])
service.record_host_inventory("host-1", payload, now=now)
entry = service.get_host_inventory("host-1")
assert entry is not None
assert json.loads(entry.payload_json)[0]["name"] == "My Note"
@@ -0,0 +1,174 @@
from __future__ import annotations
from fastapi.testclient import TestClient
from cloud.control_config import CloudControlConfig
from cloud_api.app import create_app
def _create_admin(client: TestClient) -> None:
client.app.state.cloud_services.user_auth_service.create_user(
username="admin",
display_name="Administrator",
role="admin",
password="correct-horse-battery-staple",
must_change_password=False,
)
response = client.post(
"/v1/auth/login",
json={"username": "admin", "password": "correct-horse-battery-staple"},
)
assert response.status_code == 200
def _csrf_headers(client: TestClient) -> dict[str, str]:
token = client.cookies.get("amcp_csrf")
assert token is not None
return {"X-CSRF-Token": token}
def _profile_payload(**overrides: object) -> dict[str, object]:
payload = {
"name": "OpenAI Compatible",
"provider_type": "openai-compatible",
"model": "gpt-compatible",
"base_url": "https://compat.example/v1",
"timeout_seconds": 20,
"api_key": "provider-secret-value",
}
payload.update(overrides)
return payload
def test_provider_profiles_are_encrypted_redacted_and_activated(monkeypatch) -> None:
from cryptography.fernet import Fernet
monkeypatch.setenv(
"CLOUD_LLM_PROVIDER_ENCRYPTION_KEY", Fernet.generate_key().decode()
)
app = create_app(config=CloudControlConfig(database_url="sqlite:///:memory:"))
with TestClient(app) as client:
_create_admin(client)
headers = _csrf_headers(client)
missing_csrf = client.post("/v1/planner/providers", json=_profile_payload())
assert missing_csrf.status_code == 403
empty_key = client.post(
"/v1/planner/providers",
headers=headers,
json=_profile_payload(api_key=""),
)
assert empty_key.status_code == 422
created = client.post(
"/v1/planner/providers", headers=headers, json=_profile_payload()
)
assert created.status_code == 201, created.text
profile = created.json()
assert profile["has_api_key"] is True
assert "api_key" not in profile
assert "ciphertext" not in profile
repository = client.app.state.cloud_services.repository
stored = repository.get_llm_provider_profile(profile["id"])
assert stored is not None
assert stored.api_key_ciphertext != "provider-secret-value"
assert "provider-secret-value" not in stored.api_key_ciphertext
listed = client.get("/v1/planner/providers")
assert listed.status_code == 200
assert listed.json()["settings"]["active_profile_id"] is None
assert "provider-secret-value" not in listed.text
assert stored.api_key_ciphertext not in listed.text
activated = client.post(
f"/v1/planner/providers/{profile['id']}/activate",
headers=headers,
json={"expected_settings_revision": 0},
)
assert activated.status_code == 200, activated.text
assert activated.json()["active_profile_id"] == profile["id"]
disable_active = client.patch(
f"/v1/planner/providers/{profile['id']}",
headers=headers,
json={"enabled": False, "expected_revision": profile["revision"]},
)
assert disable_active.status_code == 409
def test_provider_profile_requires_admin_scope(monkeypatch) -> None:
from cryptography.fernet import Fernet
monkeypatch.setenv(
"CLOUD_LLM_PROVIDER_ENCRYPTION_KEY", Fernet.generate_key().decode()
)
app = create_app(config=CloudControlConfig(database_url="sqlite:///:memory:"))
with TestClient(app) as client:
service = client.app.state.cloud_services.user_auth_service
service.create_user(
username="operator",
display_name="Operator",
role="operator",
password="correct-horse-battery-staple",
must_change_password=False,
)
assert (
client.post(
"/v1/auth/login",
json={
"username": "operator",
"password": "correct-horse-battery-staple",
},
).status_code
== 200
)
assert client.get("/v1/planner/providers").status_code == 403
assert (
client.post(
"/v1/planner/providers",
headers=_csrf_headers(client),
json=_profile_payload(),
).status_code
== 403
)
def test_anthropic_profile_accepts_custom_base_url(monkeypatch) -> None:
from cryptography.fernet import Fernet
monkeypatch.setenv(
"CLOUD_LLM_PROVIDER_ENCRYPTION_KEY", Fernet.generate_key().decode()
)
app = create_app(config=CloudControlConfig(database_url="sqlite:///:memory:"))
with TestClient(app) as client:
_create_admin(client)
response = client.post(
"/v1/planner/providers",
headers=_csrf_headers(client),
json=_profile_payload(
name="Anthropic proxy",
provider_type="anthropic",
base_url="https://anthropic-proxy.example/",
),
)
assert response.status_code == 201, response.text
assert response.json()["base_url"] == "https://anthropic-proxy.example"
def test_profile_write_requires_encryption_key(monkeypatch) -> None:
monkeypatch.delenv("CLOUD_LLM_PROVIDER_ENCRYPTION_KEY", raising=False)
app = create_app(config=CloudControlConfig(database_url="sqlite:///:memory:"))
with TestClient(app) as client:
_create_admin(client)
response = client.post(
"/v1/planner/providers",
headers=_csrf_headers(client),
json=_profile_payload(),
)
assert response.status_code == 503
assert "provider-secret-value" not in response.text
@@ -0,0 +1,170 @@
from __future__ import annotations
from fastapi.testclient import TestClient
import cloud.internal_api.api as internal_api
from cloud.control_config import CloudControlConfig
from runtime.tool_calling_client import ToolCallDecision
from cloud_api.app import create_app
class _FakePlannerClient:
def __init__(self) -> None:
self.calls: list[dict[str, object]] = []
def decide(self, **kwargs) -> ToolCallDecision:
self.calls.append(kwargs)
return ToolCallDecision(tool_name="tap", arguments={"x": 1, "y": 2})
def _login_admin(client: TestClient) -> dict[str, str]:
client.app.state.cloud_services.user_auth_service.create_user(
username="admin",
display_name="Administrator",
role="admin",
password="correct-horse-battery-staple",
must_change_password=False,
)
assert (
client.post(
"/v1/auth/login",
json={"username": "admin", "password": "correct-horse-battery-staple"},
).status_code
== 200
)
csrf = client.cookies.get("amcp_csrf")
assert csrf is not None
return {"X-CSRF-Token": csrf}
def _create_profile(
client: TestClient,
headers: dict[str, str],
*,
name: str,
model: str,
timeout_seconds: float,
) -> dict:
response = client.post(
"/v1/planner/providers",
headers=headers,
json={
"name": name,
"provider_type": "openai-compatible",
"model": model,
"base_url": "https://compat.example/v1",
"timeout_seconds": timeout_seconds,
"api_key": f"key-for-{name}",
},
)
assert response.status_code == 201, response.text
return response.json()
def _enroll_host(client: TestClient) -> tuple[str, dict[str, str]]:
enrollment = client.post(
"/internal/v1/enrollments",
json={"agent_instance_id": "agent-a", "host_token": "host-token-" + ("a" * 40)},
)
assert enrollment.status_code == 201, enrollment.text
return enrollment.json()["host_id"], {
"Authorization": "Bearer host-token-" + ("a" * 40)
}
def _decision_payload(host_id: str) -> dict:
return {
"host_id": host_id,
"system_prompt": "system",
"user_prompt": "user",
"tools": [{"name": "tap", "description": "tap", "parameters": {}}],
"timeout_seconds": 10,
}
def test_planner_uses_the_newly_activated_database_profile(monkeypatch) -> None:
from cryptography.fernet import Fernet
monkeypatch.setenv(
"CLOUD_LLM_PROVIDER_ENCRYPTION_KEY", Fernet.generate_key().decode()
)
resolved_profiles = []
fake = _FakePlannerClient()
def build(resolved):
resolved_profiles.append(resolved)
return fake
monkeypatch.setattr(internal_api, "build_cloud_planner_client", build)
app = create_app(config=CloudControlConfig(database_url="sqlite:///:memory:"))
with TestClient(app) as client:
admin_headers = _login_admin(client)
first = _create_profile(
client,
admin_headers,
name="First",
model="first-model",
timeout_seconds=41,
)
assert (
client.post(
f"/v1/planner/providers/{first['id']}/activate",
headers=admin_headers,
json={"expected_settings_revision": 0},
).status_code
== 200
)
host_id, host_headers = _enroll_host(client)
first_decision = client.post(
f"/internal/v1/hosts/{host_id}/planner/decide",
headers=host_headers,
json=_decision_payload(host_id),
)
assert first_decision.status_code == 200, first_decision.text
assert resolved_profiles[-1].profile.model == "first-model"
assert resolved_profiles[-1].api_key == "key-for-First"
assert fake.calls[-1]["timeout"] == 41
second = _create_profile(
client,
admin_headers,
name="Second",
model="second-model",
timeout_seconds=57,
)
settings = client.get("/v1/planner/providers").json()["settings"]
activated = client.post(
f"/v1/planner/providers/{second['id']}/activate",
headers=admin_headers,
json={"expected_settings_revision": settings["revision"]},
)
assert activated.status_code == 200, activated.text
second_decision = client.post(
f"/internal/v1/hosts/{host_id}/planner/decide",
headers=host_headers,
json=_decision_payload(host_id),
)
assert second_decision.status_code == 200, second_decision.text
assert resolved_profiles[-1].profile.model == "second-model"
assert fake.calls[-1]["timeout"] == 57
assert len(fake.calls) == 2
def test_planner_fails_closed_without_an_active_database_profile(monkeypatch) -> None:
monkeypatch.delenv("CLOUD_LLM_PROVIDER_ENCRYPTION_KEY", raising=False)
app = create_app(config=CloudControlConfig(database_url="sqlite:///:memory:"))
with TestClient(app) as client:
host_id, host_headers = _enroll_host(client)
response = client.post(
f"/internal/v1/hosts/{host_id}/planner/decide",
headers=host_headers,
json=_decision_payload(host_id),
)
assert response.status_code == 502
assert response.json() == {
"code": "planner_unavailable",
"detail": "no active database Provider profile",
}
@@ -0,0 +1,128 @@
"""HTTP tests for the Cloud skill management admin router.
Mirrors the llm-provider management test setup (in-memory DB, admin login,
CSRF). Covers skill CRUD, per-host entitlement grant/revoke, authorization
(non-admin rejected), and a basic sync-endpoint auth guard.
"""
from __future__ import annotations
from fastapi.testclient import TestClient
from cloud.control_config import CloudControlConfig
from cloud_api.app import create_app
def _create_admin(client: TestClient) -> None:
client.app.state.cloud_services.user_auth_service.create_user(
username="admin",
display_name="Administrator",
role="admin",
password="correct-horse-battery-staple",
must_change_password=False,
)
response = client.post(
"/v1/auth/login",
json={"username": "admin", "password": "correct-horse-battery-staple"},
)
assert response.status_code == 200
def _csrf_headers(client: TestClient) -> dict[str, str]:
token = client.cookies.get("amcp_csrf")
assert token is not None
return {"X-CSRF-Token": token}
def _skill_payload(**overrides: object) -> dict[str, object]:
payload: dict[str, object] = {
"name": "Search Notes",
"kind": "knowledge",
"description": "how to search",
"tags": ["search"],
"content": "type and press enter",
"steps": [],
"parameters": {},
}
payload.update(overrides)
return payload
def _client() -> TestClient:
app = create_app(config=CloudControlConfig(database_url="sqlite:///:memory:"))
return TestClient(app)
def test_admin_can_create_list_get_update_delete_skill():
with _client() as client:
_create_admin(client)
headers = _csrf_headers(client)
created = client.post("/v1/skills", json=_skill_payload(), headers=headers)
assert created.status_code == 201, created.text
skill_id = created.json()["id"]
listed = client.get("/v1/skills", headers=headers)
assert listed.status_code == 200
assert any(s["id"] == skill_id for s in listed.json()["items"])
fetched = client.get(f"/v1/skills/{skill_id}", headers=headers)
assert fetched.status_code == 200
assert fetched.json()["content"] == "type and press enter"
updated = client.patch(
f"/v1/skills/{skill_id}",
json=_skill_payload(content="new content"),
headers=headers,
)
assert updated.status_code == 200, updated.text
assert updated.json()["content"] == "new content"
deleted = client.delete(f"/v1/skills/{skill_id}", headers=headers)
assert deleted.status_code == 204
assert client.get(f"/v1/skills/{skill_id}", headers=headers).status_code == 404
def test_duplicate_skill_name_conflicts():
with _client() as client:
_create_admin(client)
headers = _csrf_headers(client)
first = client.post("/v1/skills", json=_skill_payload(), headers=headers)
assert first.status_code == 201
second = client.post("/v1/skills", json=_skill_payload(), headers=headers)
assert second.status_code == 409
def test_entitlement_grant_revoke_lists_hosts():
with _client() as client:
_create_admin(client)
headers = _csrf_headers(client)
skill_id = client.post(
"/v1/skills", json=_skill_payload(), headers=headers
).json()["id"]
grant = client.post(
f"/v1/skills/{skill_id}/entitlements/host-1", headers=headers
)
assert grant.status_code == 204
listed = client.get(f"/v1/skills/{skill_id}/entitlements", headers=headers)
assert listed.json()["host_ids"] == ["host-1"]
revoke = client.delete(
f"/v1/skills/{skill_id}/entitlements/host-1", headers=headers
)
assert revoke.status_code == 204
listed = client.get(f"/v1/skills/{skill_id}/entitlements", headers=headers)
assert listed.json()["host_ids"] == []
def test_unauthenticated_request_is_rejected():
with _client() as client:
response = client.get("/v1/skills")
assert response.status_code == 401
def test_sync_endpoint_requires_host_credentials():
with _client() as client:
# No host credentials -> 401 (no skill content leaked).
response = client.get("/internal/v1/hosts/host-1/skills/sync")
assert response.status_code == 401
+279 -43
View File
@@ -1,22 +1,46 @@
from __future__ import annotations
import asyncio
import logging
from contextlib import suppress
from dataclasses import dataclass
from dataclasses import dataclass, replace
import uvicorn
from cloud.internal_api.models import AssignmentModel
from device.manager import DeviceManager
from driver.registry import build_driver_factory
from host_agent.assignment import AssignmentExecutor
from host_agent.client import HostAgentClient, HostAgentEnrollmentClient
from host_agent.config import HostAgentConfig, load_host_agent_config
from host_agent.conversation import ConversationAgent
from host_agent.conversation_log import ConversationLogStore
from host_agent.dependency_supervisor import DependencySupervisor
from host_agent.devices import register_local_device
from host_agent.enrollment import resolve_host_identity
from host_agent.execution import create_execution_factories
from host_agent.heartbeat import HeartbeatSynchronizer
from host_agent.history import ConsoleHistoryStore
from host_agent.identity import HostIdentityStore
from host_agent.instance_lock import InstanceLock
from host_agent.lease import ActiveAssignmentRunner
from host_agent.local_account import LocalAccountStore
from host_agent.local_client import LocalHostAgentClient
from host_agent.mcp_lock import McpBusyTracker
from host_agent.mcp_token import McpTokenStore
from host_agent.policy_cache import HostPolicyCacheStore
from host_agent.processor import AssignmentProcessingResult, AssignmentProcessor
from host_agent.retention import prune_task_history
from host_agent.skill_sync import HostAgentSkillSync
from host_agent.status import AgentStatusTracker
from host_agent.web.app import create_console_app
from host_agent.web.auth import SessionManager
from host_agent.web.mcp import build_mcp_server
from runtime.executor import default_tool_registry
from runtime.planner_config import load_config as load_planner_config
from storage.artifact_store import ArtifactStore
from storage.device_config import DeviceConfigStore
from storage.task_metadata import TaskMetadataStore
from storage.timeline import Timeline
@dataclass
@@ -24,14 +48,36 @@ class HostAgentApplication:
client: HostAgentClient
heartbeat: HeartbeatSynchronizer
processor: AssignmentProcessor
console_server: uvicorn.Server | None = None
console_enrollment_client: HostAgentEnrollmentClient | None = None
dependency_supervisor: DependencySupervisor | None = None
instance_lock: InstanceLock | None = None
skill_sync: HostAgentSkillSync | None = None
def run(self) -> None:
asyncio.run(self.run_async())
async def run_async(self, stop: asyncio.Event | None = None) -> None:
stop_requested = stop or asyncio.Event()
supervisor_stop = asyncio.Event()
supervisor_task: asyncio.Task[None] | None = None
if self.dependency_supervisor is not None:
# Bring up supervised dependencies (probe + spawn/adopt + readiness
# wait) before the heartbeat loop's first connect_devices() pass,
# so a supervised Appium is ready before any Driver.connect().
await self.dependency_supervisor.start()
supervisor_task = asyncio.create_task(
self.dependency_supervisor.run(supervisor_stop)
)
heartbeat_stop = asyncio.Event()
heartbeat_task = asyncio.create_task(self.heartbeat.run(heartbeat_stop))
if self.skill_sync is not None:
self.skill_sync.start()
console_task = (
asyncio.create_task(self.console_server.serve())
if self.console_server is not None
else None
)
active_processing: asyncio.Task[AssignmentProcessingResult] | None = None
try:
while not stop_requested.is_set():
@@ -60,12 +106,28 @@ class HostAgentApplication:
with suppress(Exception):
await asyncio.shield(active_processing)
heartbeat_stop.set()
supervisor_stop.set()
if self.console_server is not None:
self.console_server.should_exit = True
try:
await asyncio.gather(heartbeat_task, return_exceptions=True)
with suppress(Exception):
await self.heartbeat.sync_once()
if supervisor_task is not None:
await asyncio.gather(supervisor_task, return_exceptions=True)
with suppress(Exception):
await self.dependency_supervisor.stop()
if console_task is not None:
with suppress(asyncio.CancelledError):
await asyncio.gather(console_task, return_exceptions=True)
finally:
if self.console_enrollment_client is not None:
self.console_enrollment_client.close()
if self.skill_sync is not None:
self.skill_sync.stop()
await self.client.aclose()
if self.instance_lock is not None:
self.instance_lock.release()
async def _claim_until_stopped(
self,
@@ -94,6 +156,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,
@@ -103,62 +170,231 @@ def create_application(
enrollment_client: HostAgentEnrollmentClient | None = None,
) -> HostAgentApplication:
startup_config = config or load_host_agent_config()
config_store = device_config_store or DeviceConfigStore()
owned_enrollment_client = enrollment_client is None
bootstrap_client = enrollment_client or HostAgentEnrollmentClient(startup_config)
instance_lock = InstanceLock(startup_config.identity_path.parent)
instance_lock.acquire()
try:
resolved_config = resolve_host_identity(
startup_config,
identity_store=identity_store
or HostIdentityStore(startup_config.identity_path),
client=bootstrap_client,
config_store = device_config_store or DeviceConfigStore()
resolved_identity_store = identity_store or HostIdentityStore(
startup_config.identity_path
)
bootstrap_client.config = resolved_config
resolved_manager = manager or _configured_device_manager(
config_store,
owned_enrollment_client = enrollment_client is None
bootstrap_client = enrollment_client or HostAgentEnrollmentClient(startup_config)
try:
if startup_config.mode == "local":
resolved_config = replace(
startup_config, control_plane_url="", host_id="local-host"
)
resolved_manager = manager or _configured_device_manager(
config_store, config=resolved_config, enrollment_client=None
)
else:
resolved_config = resolve_host_identity(
startup_config,
identity_store=resolved_identity_store,
client=bootstrap_client,
)
bootstrap_client.config = resolved_config
resolved_manager = manager or _configured_device_manager(
config_store,
config=resolved_config,
enrollment_client=bootstrap_client,
)
finally:
if owned_enrollment_client:
bootstrap_client.close()
if resolved_config.mode == "local":
client = LocalHostAgentClient(
host_id="local-host",
device_ids=lambda: [device.id for device in resolved_manager.list_devices()],
)
else:
client = HostAgentClient(resolved_config)
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.enrollment_managed:
console_enrollment_client = HostAgentEnrollmentClient(resolved_config)
metadata_store = TaskMetadataStore(
db_path=resolved_config.task_progress_db_path
)
timeline = Timeline(ArtifactStore(root=resolved_config.task_artifact_dir))
mcp_token_path = resolved_config.identity_path.parent / "host_mcp_token.json"
mcp_token_existed = mcp_token_path.exists()
mcp_token_store = McpTokenStore(mcp_token_path)
mcp_token_store.load_or_create()
if not mcp_token_existed:
logging.getLogger(__name__).info(
"MCP token generated at %s", mcp_token_path
)
mcp_busy_tracker = McpBusyTracker(ttl_seconds=20.0)
conversation_log = ConversationLogStore(resolved_config.conversation_log_path) if resolved_config.mode == "local" else None
executor = AssignmentExecutor(
create_execution_factories(
resolved_manager,
metadata_store=metadata_store,
timeline=timeline,
host_agent_config=resolved_config,
conversation_log=conversation_log,
),
mcp_busy_tracker=mcp_busy_tracker,
)
mcp_server = build_mcp_server(
manager=resolved_manager,
mcp_busy_tracker=mcp_busy_tracker,
status_tracker=status_tracker,
)
planner_config = load_planner_config()
conversation_agent = (
ConversationAgent(
config=planner_config,
tools=default_tool_registry(manager=resolved_manager),
event_logger=conversation_log.append if conversation_log else None,
)
if resolved_config.ai_planner_transport == "direct"
else None
)
console_app = create_console_app(
config=resolved_config,
enrollment_client=bootstrap_client,
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,
host_client=client,
metadata_store=metadata_store,
timeline=timeline,
executor=executor,
mcp_server=mcp_server,
mcp_token_store=mcp_token_store,
mcp_busy_tracker=mcp_busy_tracker,
conversation_agent=conversation_agent,
conversation_log=conversation_log,
)
finally:
if owned_enrollment_client:
bootstrap_client.close()
client = HostAgentClient(resolved_config)
heartbeat = HeartbeatSynchronizer(resolved_manager, client, resolved_config)
executor = AssignmentExecutor(create_execution_factories(resolved_manager))
active_runner = ActiveAssignmentRunner(client, executor)
return HostAgentApplication(
client=client,
heartbeat=heartbeat,
processor=AssignmentProcessor(client, active_runner),
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,
mcp_busy_tracker=mcp_busy_tracker,
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
),
)
active_runner = ActiveAssignmentRunner(client, executor)
processor = AssignmentProcessor(
client,
active_runner,
status_tracker=status_tracker,
on_result=lambda assignment, result: _on_assignment_finished(
history_store,
metadata_store,
timeline,
resolved_config,
assignment,
result,
),
)
dependency_supervisor: DependencySupervisor | None = None
if resolved_config.dependency_supervisor_enabled:
dependency_supervisor = DependencySupervisor.from_host_agent_config(
resolved_config
)
skill_sync: HostAgentSkillSync | None = None
if resolved_config.host_id and resolved_config.token:
skill_sync = HostAgentSkillSync(resolved_config)
return HostAgentApplication(
client=client,
heartbeat=heartbeat,
processor=processor,
console_server=console_server,
console_enrollment_client=console_enrollment_client,
dependency_supervisor=dependency_supervisor,
instance_lock=instance_lock,
skill_sync=skill_sync,
)
except BaseException:
instance_lock.release()
raise
def _on_assignment_finished(
history_store: ConsoleHistoryStore,
metadata_store: TaskMetadataStore,
timeline: Timeline,
config: HostAgentConfig,
assignment: AssignmentModel,
result: AssignmentProcessingResult,
) -> None:
_record_assignment_history(history_store, assignment, result)
_prune_task_history(metadata_store, timeline, config)
def _record_assignment_history(
history_store: ConsoleHistoryStore,
assignment: AssignmentModel,
result: AssignmentProcessingResult,
) -> None:
status = "done" if result.execution.status == "done" else "failed"
history_store.record_assignment(
task_id=assignment.task_id,
attempt=assignment.attempt,
status=status,
failure_reason=result.execution.failure_reason if status == "failed" else None,
device_id=assignment.device_id,
)
def _prune_task_history(
metadata_store: TaskMetadataStore,
timeline: Timeline,
config: HostAgentConfig,
) -> None:
try:
prune_task_history(metadata_store, timeline, config=config)
except Exception:
pass
def _configured_device_manager(
config_store: DeviceConfigStore,
*,
config: HostAgentConfig,
enrollment_client: HostAgentEnrollmentClient,
enrollment_client: HostAgentEnrollmentClient | None,
) -> DeviceManager:
manager = DeviceManager()
for device in config_store.list():
runtime_device_id = device["device_id"]
if config.enrollment_managed:
enrollment = enrollment_client.enroll_device(
local_device_id=device["device_id"],
driver_type=device["driver_type"],
name=device["name"],
capability_tags=[],
)
runtime_device_id = enrollment.device_id
config_store.set_cloud_device_id(device["device_id"], runtime_device_id)
manager.register_device(
runtime_device_id,
build_driver_factory(
device["driver_type"],
device["connection_info"],
),
name=device["name"],
register_local_device(
config_store,
manager,
device_id=device["device_id"],
driver_type=device["driver_type"],
connection_info=device["connection_info"],
name=device["name"],
config=config,
enrollment_client=enrollment_client,
)
return manager
+69 -15
View File
@@ -2,11 +2,17 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
from typing import TYPE_CHECKING, Any
from cloud.internal_api.models import AssignmentModel
from core.models import Task
from host_agent.execution import ExecutionFactories
from host_agent.planner_context import bind_planner_execution_context
from host_agent.progress import TaskProgressHolder, TaskProgressSnapshot
from runtime.task import is_cancellation_reason
if TYPE_CHECKING:
from host_agent.mcp_lock import McpBusyTracker
@dataclass(frozen=True)
@@ -17,43 +23,81 @@ class AssignmentExecutionResult:
class AssignmentExecutor:
def __init__(self, factories: ExecutionFactories) -> None:
def __init__(
self,
factories: ExecutionFactories,
*,
mcp_busy_tracker: McpBusyTracker | None = None,
) -> None:
self.factories = factories
self._progress = TaskProgressHolder()
self._mcp_busy_tracker = mcp_busy_tracker
def latest_progress(self) -> TaskProgressSnapshot | None:
"""Latest step progress reported by the currently-running assignment."""
return self._progress.snapshot()
def execute(
self,
assignment: AssignmentModel,
*,
should_stop: Callable[[], bool] | None = None,
stop_reason: Callable[[], str | None] | None = None,
) -> AssignmentExecutionResult:
if should_stop is not None and should_stop():
self._progress.clear()
if self._mcp_busy_tracker is not None and (
assignment.device_id in self._mcp_busy_tracker.busy_device_ids()
):
return AssignmentExecutionResult(
status="failed",
failure_reason="execution interrupted",
failure_reason=(
f"device {assignment.device_id} is held by an active MCP session"
),
)
with bind_planner_execution_context(assignment):
if should_stop is not None and should_stop():
reason = stop_reason() if stop_reason is not None else None
return AssignmentExecutionResult(
status="cancelled" if is_cancellation_reason(reason) else "failed",
failure_reason=reason or "execution interrupted",
)
if assignment.workflow_definition_id is not None:
return self._execute_workflow(
assignment, should_stop=should_stop, stop_reason=stop_reason
)
if assignment.goal is not None:
return self._execute_goal(
assignment, should_stop=should_stop, stop_reason=stop_reason
)
return AssignmentExecutionResult(
status="failed",
failure_reason="assignment has neither goal nor workflow definition",
)
if assignment.workflow_definition_id is not None:
return self._execute_workflow(assignment, should_stop=should_stop)
if assignment.goal is not None:
return self._execute_goal(assignment, should_stop=should_stop)
return AssignmentExecutionResult(
status="failed",
failure_reason="assignment has neither goal nor workflow definition",
)
def _execute_goal(
self,
assignment: AssignmentModel,
*,
should_stop: Callable[[], bool] | None,
stop_reason: Callable[[], str | None] | None,
) -> AssignmentExecutionResult:
task = Task(goal=assignment.goal or "", device_id=assignment.device_id)
if self.factories.metadata_store is not None:
self.factories.metadata_store.create_task(
task,
source_task_id=assignment.task_id,
source_attempt=assignment.attempt,
)
runner = self.factories.task_runner_factory()
runner.on_step_progress = self._progress.update
if should_stop is None:
completed = runner.run(task)
else:
completed = runner.run(task, should_stop=should_stop)
completed = runner.run(
task, should_stop=should_stop, stop_reason=stop_reason
)
return AssignmentExecutionResult(
status="done" if completed.status == "completed" else "failed",
status=_terminal_status(completed.status),
failure_reason=completed.failure_reason,
metadata={
"runtime_task_id": completed.id,
@@ -66,6 +110,7 @@ class AssignmentExecutor:
assignment: AssignmentModel,
*,
should_stop: Callable[[], bool] | None,
stop_reason: Callable[[], str | None] | None,
) -> AssignmentExecutionResult:
definition_id = assignment.workflow_definition_id or ""
definition = self.factories.workflow_store.get_definition(definition_id)
@@ -82,9 +127,10 @@ class AssignmentExecutor:
definition,
device_id=assignment.device_id,
should_stop=should_stop,
stop_reason=stop_reason,
)
return AssignmentExecutionResult(
status="done" if run.status == "completed" else "failed",
status=_terminal_status(run.status),
failure_reason=(
None if run.status == "completed" else f"workflow ended as {run.status}"
),
@@ -93,3 +139,11 @@ class AssignmentExecutor:
"workflow_status": run.status,
},
)
def _terminal_status(runtime_status: str) -> str:
if runtime_status == "completed":
return "done"
if runtime_status == "cancelled":
return "cancelled"
return "failed"
+107 -2
View File
@@ -1,12 +1,117 @@
from __future__ import annotations
import argparse
import getpass
import os
import sys
from pathlib import Path
from collections.abc import Sequence
from dataclasses import replace
from host_agent.app import create_application
from host_agent.config import load_host_agent_config
from host_agent.instance_lock import InstanceAlreadyRunningError
from host_agent.local_account import LocalAccountStore
from host_agent.mcp_token import McpTokenStore
class LocalAccountSetupError(RuntimeError):
"""Raised when local account bootstrap cannot proceed."""
def main(argv: Sequence[str] | None = None) -> None:
_load_dotenv()
parser = argparse.ArgumentParser(description="Run the Device Host Agent")
parser.parse_args(argv)
create_application().run()
subparsers = parser.add_subparsers(dest="command")
subparsers.add_parser("setup", help="Create the local operator account")
subparsers.add_parser(
"mcp-token",
help="Print the MCP server bearer token (generating if missing)",
)
args = parser.parse_args(argv)
if args.command == "mcp-token":
_print_mcp_token()
return
try:
if args.command == "setup":
_run_setup()
return
config = _resolve_config_with_local_account()
except LocalAccountSetupError as exc:
print(f"error: {exc}", file=sys.stderr)
raise SystemExit(1) from exc
try:
create_application(config=config).run()
except InstanceAlreadyRunningError as exc:
print(f"error: {exc}", file=sys.stderr)
raise SystemExit(1) from exc
def _run_setup() -> None:
config = load_host_agent_config()
store = LocalAccountStore(config.local_account_path)
if store.load() is not None:
confirm = (
input("A local account already exists. Overwrite it? [y/N] ")
.strip()
.lower()
)
if confirm != "y":
print("Setup cancelled; existing account left unchanged.")
return
account = _prompt_and_create(store)
print(f"Local account '{account.username}' created.")
def _print_mcp_token() -> None:
config = load_host_agent_config()
store = McpTokenStore(config.identity_path.parent / "host_mcp_token.json")
print(store.load_or_create().token)
def _resolve_config_with_local_account():
config = load_host_agent_config()
store = LocalAccountStore(config.local_account_path)
account = store.load()
if account is None:
if not sys.stdin.isatty():
raise LocalAccountSetupError(
"no local account configured; run `device-host-agent setup` "
"on an interactive terminal to create one"
)
account = _prompt_and_create(store)
if not config.display_name:
config = replace(config, display_name=account.username)
return config
def _prompt_and_create(store: LocalAccountStore):
username = input("Username: ").strip()
if not username:
raise LocalAccountSetupError("username must not be empty")
password = getpass.getpass("Password: ")
confirm = getpass.getpass("Confirm password: ")
if not password:
raise LocalAccountSetupError("password must not be empty")
if password != confirm:
raise LocalAccountSetupError("passwords do not match")
return store.create(username, password)
def _load_dotenv() -> None:
"""Load a simple repository-root .env without overriding shell values."""
path = Path.cwd() / ".env"
if not path.is_file():
return
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
name, value = line.split("=", 1)
name = name.strip()
value = value.strip()
if name and name not in os.environ:
os.environ[name] = value
+106 -16
View File
@@ -14,10 +14,16 @@ from cloud.internal_api.models import (
DeviceSnapshotModel,
HeartbeatResponse,
HostEnrollmentResponse,
HostTaskCancellationResponse,
HostTaskSubmissionResponse,
LeaseRenewalResponse,
TaskProgressModel,
TerminalResultResponse,
)
from host_agent.config import HostAgentConfig
from host_agent.progress import TaskProgressSnapshot
_VALID_STEP_STATUSES = frozenset({"running", "completed", "failed"})
class HostAgentAPIError(RuntimeError):
@@ -33,6 +39,21 @@ class StaleLeaseError(HostAgentAPIError):
pass
class HostTaskSubmissionUnknownError(RuntimeError):
"""Raised when a Host self-submission request's Cloud outcome is uncertain.
This is distinct from :class:`HostAgentAPIError` because the request may
have reached the control plane but the response was lost, the server
returned a 5xx, or the success payload was malformed. Retrying would
duplicate the create, so the caller must treat the task as unknown and
surface that to the operator.
"""
def __init__(self, reason: str) -> None:
super().__init__(f"host task submission outcome is unknown: {reason}")
self.reason = reason
class HostAgentEnrollmentClient:
def __init__(
self,
@@ -53,12 +74,10 @@ class HostAgentEnrollmentClient:
host_token: str,
display_name: str | None,
) -> HostEnrollmentResponse:
if not self.config.enrollment_token:
raise HostAgentAPIError(0, "Host enrollment token is unavailable")
response = self._request(
"POST",
"/internal/v1/enrollments",
token=self.config.enrollment_token,
token=None,
json={
"agent_instance_id": agent_instance_id,
"host_token": host_token,
@@ -99,9 +118,10 @@ class HostAgentEnrollmentClient:
method: str,
path: str,
*,
token: str,
token: str | None,
json: dict[str, Any],
) -> httpx.Response:
headers = {"Authorization": f"Bearer {token}"} if token else {}
backoff = self.config.retry_backoff_seconds
for attempt in range(1, self.config.max_retry_attempts + 1):
try:
@@ -109,7 +129,7 @@ class HostAgentEnrollmentClient:
method,
path,
json=json,
headers={"Authorization": f"Bearer {token}"},
headers=headers,
)
except httpx.TransportError:
if attempt == self.config.max_retry_attempts:
@@ -147,18 +167,74 @@ class HostAgentClient:
devices: list[DeviceSnapshotModel],
*,
address: str | None = None,
policy_revision: int = 0,
mcp_busy_device_ids: list[str] | None = None,
) -> HeartbeatResponse:
payload: dict[str, Any] = {
"host_id": self.config.host_id,
"address": address,
"devices": [device.model_dump(mode="json") for device in devices],
"policy_revision": policy_revision,
"planner_transport": self.config.ai_planner_transport,
}
if mcp_busy_device_ids:
payload["mcp_busy_device_ids"] = list(mcp_busy_device_ids)
response = await self._request(
"PUT",
f"/internal/v1/hosts/{self.config.host_id}/heartbeat",
json={
"host_id": self.config.host_id,
"address": address,
"devices": [device.model_dump(mode="json") for device in devices],
},
json=payload,
)
return HeartbeatResponse.model_validate(response.json())
async def submit_self_task(
self,
*,
goal: str,
device_id: str | None = None,
) -> HostTaskSubmissionResponse:
try:
response = await self._client.request(
"POST",
f"/internal/v1/hosts/{self.config.host_id}/tasks",
json={
"host_id": self.config.host_id,
"goal": goal,
"device_id": device_id,
},
headers={"Authorization": f"Bearer {self.config.token}"},
)
except httpx.TransportError as exc:
raise HostTaskSubmissionUnknownError(str(exc)) from exc
if response.status_code >= 500:
raise HostTaskSubmissionUnknownError(
f"control plane returned status {response.status_code}"
)
if not response.is_success:
_raise_api_error(response, stale_lease=False)
try:
payload = response.json()
except ValueError as exc:
raise HostTaskSubmissionUnknownError(
"control plane returned malformed success payload"
) from exc
try:
return HostTaskSubmissionResponse.model_validate(payload)
except Exception as exc:
raise HostTaskSubmissionUnknownError(
"control plane returned malformed success payload"
) from exc
async def cancel_task(self, task_id: str) -> HostTaskCancellationResponse:
response = await self._client.request(
"POST",
f"/internal/v1/hosts/{self.config.host_id}/tasks/{task_id}/cancel",
json={"host_id": self.config.host_id},
headers={"Authorization": f"Bearer {self.config.token}"},
)
if not response.is_success:
_raise_api_error(response)
return HostTaskCancellationResponse.model_validate(response.json())
async def claim(self) -> AssignmentModel | None:
response = await self._request(
"POST",
@@ -174,19 +250,33 @@ class HostAgentClient:
async def renew(
self,
assignment: AssignmentModel,
*,
progress: TaskProgressSnapshot | None = None,
) -> LeaseRenewalResponse:
payload: dict[str, Any] = {
"host_id": self.config.host_id,
"task_id": assignment.task_id,
"attempt": assignment.attempt,
"lease_id": assignment.lease_id,
}
if progress is not None:
step_status = (
progress.step_status
if progress.step_status in _VALID_STEP_STATUSES
else "running"
)
payload["progress"] = TaskProgressModel(
step_index=max(progress.step_index, 0),
step_status=step_status,
summary=progress.summary[:500],
).model_dump(mode="json")
response = await self._request(
"POST",
(
f"/internal/v1/hosts/{self.config.host_id}/assignments/"
f"{assignment.task_id}/renew"
),
json={
"host_id": self.config.host_id,
"task_id": assignment.task_id,
"attempt": assignment.attempt,
"lease_id": assignment.lease_id,
},
json=payload,
)
return LeaseRenewalResponse.model_validate(response.json())
@@ -0,0 +1,154 @@
"""``ToolCallingClient`` implementation that proxies AI Planner decisions
through the Cloud Control Plane instead of calling an LLM provider directly.
Lives in ``host_agent``, not ``runtime`` (design decision D3): the shared
Runtime package's boundary test
(``apps/device-host-agent/tests/test_execution.py::test_runtime_owned_packages_do_not_import_host_or_cloud_concerns``)
forbids ``runtime`` from importing ``cloud`` or ``host_agent``, so this class
satisfies ``runtime.tool_calling_client.ToolCallingClient`` structurally
from outside that package instead of living inside it.
Uses its own synchronous ``httpx.Client`` (mirroring
``HostAgentEnrollmentClient``'s pattern) rather than wrapping the async
``HostAgentClient``: ``ToolCallingClient.decide()`` is a synchronous Protocol
method invoked from a worker thread via ``asyncio.to_thread`` (see
``host_agent/lease.py``), off the main event loop, so reusing an
``httpx.AsyncClient`` bound to that loop would require event-loop bridging
for no real benefit over a plain synchronous client with the same
host-scoped bearer auth.
"""
from __future__ import annotations
import base64
from typing import Any
import httpx
from cloud.internal_api.models import PlannerDecisionError, PlannerDecisionResponse
from host_agent.config import HostAgentConfig
from host_agent.planner_context import current_planner_execution_context
from runtime.tool_calling_client import (
ToolCallDecision,
ToolCallUnavailable,
ToolCallUsage,
)
from runtime.tool_specs import ToolSpec
_CLOUD_PROVIDER_MAX_TIMEOUT_SECONDS = 120.0
_CLOUD_PROXY_TRANSPORT_GRACE_SECONDS = 5.0
_CLOUD_PROXY_HTTP_TIMEOUT_SECONDS = (
_CLOUD_PROVIDER_MAX_TIMEOUT_SECONDS + _CLOUD_PROXY_TRANSPORT_GRACE_SECONDS
)
class CloudProxyToolCallingClient:
def __init__(
self,
config: HostAgentConfig,
*,
http_client: httpx.Client | None = None,
) -> None:
self.config = config
self._owns_client = http_client is None
self._client = http_client or httpx.Client(base_url=config.control_plane_url)
def decide(
self,
*,
system_prompt: str,
user_prompt: str,
screenshot: bytes | None,
tools: list[ToolSpec],
timeout: float,
history: list[dict[str, Any]] | None = None,
) -> ToolCallDecision:
payload: dict[str, Any] = {
"host_id": self.config.host_id,
"system_prompt": system_prompt,
"user_prompt": user_prompt,
"history": history or [],
"screenshot_base64": (
base64.b64encode(screenshot).decode("ascii")
if screenshot is not None
else None
),
"tools": [
{
"name": spec.name,
"description": spec.description,
"parameters": spec.parameters,
}
for spec in tools
],
# Legacy Cloud API versions use this field. Current Cloud API versions
# resolve the provider timeout from the active Provider profile.
"timeout_seconds": min(timeout, _CLOUD_PROVIDER_MAX_TIMEOUT_SECONDS),
}
context = current_planner_execution_context()
if context is not None:
payload.update(
{
"task_id": context.task_id,
"attempt": context.attempt,
"lease_id": context.lease_id,
}
)
try:
response = self._client.post(
f"/internal/v1/hosts/{self.config.host_id}/planner/decide",
json=payload,
headers={"Authorization": f"Bearer {self.config.token}"},
timeout=_CLOUD_PROXY_HTTP_TIMEOUT_SECONDS,
)
except httpx.HTTPError as exc:
raise ToolCallUnavailable(str(exc)) from exc
if response.is_success:
decoded = PlannerDecisionResponse.model_validate(response.json())
return ToolCallDecision(
tool_name=decoded.tool_name,
arguments=dict(decoded.arguments),
usage=(
ToolCallUsage(
input_tokens=decoded.input_tokens,
output_tokens=decoded.output_tokens,
total_tokens=decoded.total_tokens,
)
if any(
value is not None
for value in (
decoded.input_tokens,
decoded.output_tokens,
decoded.total_tokens,
)
)
else None
),
text_output=decoded.rationale,
thinking=decoded.thinking,
purpose=decoded.purpose,
expected_outcome=decoded.expected_outcome,
)
raise ToolCallUnavailable(_error_detail(response))
def close(self) -> None:
if self._owns_client:
self._client.close()
def _error_detail(response: httpx.Response) -> str:
try:
payload = response.json()
except ValueError:
return f"planner-decision request failed with status {response.status_code}"
try:
error = PlannerDecisionError.model_validate(payload)
except Exception:
detail = payload.get("detail") if isinstance(payload, dict) else None
return (
detail
or f"planner-decision request failed with status {response.status_code}"
)
return error.detail
+142 -20
View File
@@ -11,13 +11,24 @@ class HostAgentConfigurationError(ValueError):
"""Raised when Host Agent process configuration is invalid."""
_LOOPBACK_BIND_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
_AI_PLANNER_TRANSPORTS = frozenset({"direct", "cloud"})
_HOST_AGENT_MODES = frozenset({"cloud", "local"})
_REMOVED_RUNTIME_SUPERVISION_SETTINGS = (
"HOST_AGENT_RUNTIME_SUPERVISED",
"HOST_AGENT_RUNTIME_HOST",
"HOST_AGENT_RUNTIME_PORT",
)
@dataclass(frozen=True)
class HostAgentConfig:
control_plane_url: str
control_plane_url: str = ""
mode: str = "cloud"
host_id: str = ""
token: str = field(default="", repr=False)
enrollment_token: str = field(default="", repr=False)
identity_path: Path = Path("tasks/host_identity.json")
local_account_path: Path = Path("tasks/host_local_account.json")
enrollment_managed: bool = False
display_name: str | None = None
heartbeat_interval_seconds: float = 30.0
@@ -25,49 +36,62 @@ class HostAgentConfig:
retry_backoff_seconds: float = 1.0
max_retry_backoff_seconds: float = 30.0
max_retry_attempts: int = 5
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
console_history_limit: int = 200
ai_planner_transport: str = "cloud"
dependency_supervisor_enabled: bool = False
appium_supervised: bool = False
appium_host: str = "127.0.0.1"
appium_port: int = 4723
dependency_restart_max_attempts: int = 5
task_progress_db_path: Path = Path("host_agent_data/task_progress.sqlite3")
task_artifact_dir: Path = Path("host_agent_data/history")
conversation_log_path: Path = Path("host_agent_data/conversations.sqlite3")
task_retention_max_count: int = 50
task_retention_max_age_days: int = 7
skill_sync_interval_seconds: float = 300.0
def load_host_agent_config(
env: Mapping[str, str] | None = None,
) -> HostAgentConfig:
values = os.environ if env is None else env
_reject_removed_runtime_supervision_settings(values)
mode = values.get("HOST_AGENT_MODE", "cloud").strip().lower()
if mode not in _HOST_AGENT_MODES:
raise HostAgentConfigurationError("HOST_AGENT_MODE must be cloud or local")
control_plane_url = (
values.get(
"HOST_AGENT_CONTROL_PLANE_URL",
"http://127.0.0.1:8001",
"https://amcp.home.jerryyan.top" if mode == "cloud" else "",
)
.strip()
.rstrip("/")
)
parsed_url = urlparse(control_plane_url)
if parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc:
if mode == "cloud" and (parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc):
raise HostAgentConfigurationError(
"HOST_AGENT_CONTROL_PLANE_URL must be an HTTP(S) URL"
)
host_id = values.get("HOST_AGENT_HOST_ID", "").strip()
token = values.get("HOST_AGENT_TOKEN", "").strip()
if bool(host_id) != bool(token):
raise HostAgentConfigurationError(
"HOST_AGENT_HOST_ID and HOST_AGENT_TOKEN must be configured together"
)
enrollment_token = values.get("HOST_AGENT_ENROLLMENT_TOKEN", "").strip()
identity_path = Path(
values.get("HOST_AGENT_IDENTITY_PATH", "tasks/host_identity.json").strip()
)
if not host_id and not enrollment_token and not identity_path.is_file():
raise HostAgentConfigurationError(
"explicit Host credentials, an enrollment token, or existing identity state "
"is required"
)
local_account_path = Path(
values.get(
"HOST_AGENT_LOCAL_ACCOUNT_PATH", "tasks/host_local_account.json"
).strip()
)
config = HostAgentConfig(
control_plane_url=control_plane_url,
host_id=host_id,
token=token,
enrollment_token=enrollment_token,
mode=mode,
identity_path=identity_path,
enrollment_managed=not bool(host_id),
local_account_path=local_account_path,
enrollment_managed=mode == "cloud",
display_name=values.get("HOST_AGENT_DISPLAY_NAME") or None,
heartbeat_interval_seconds=_positive_float(
values,
@@ -94,14 +118,101 @@ def load_host_agent_config(
"HOST_AGENT_MAX_RETRY_ATTEMPTS",
5,
),
console_bind_host=values.get(
"HOST_AGENT_CONSOLE_BIND_HOST", "127.0.0.1"
).strip(),
console_port=_positive_int(values, "HOST_AGENT_CONSOLE_PORT", 8765),
console_allow_non_loopback=_truthy(
values, "HOST_AGENT_CONSOLE_ALLOW_NON_LOOPBACK", False
),
console_session_ttl_seconds=_positive_float(
values,
"HOST_AGENT_CONSOLE_SESSION_TTL_SECONDS",
43200.0,
),
console_history_limit=_positive_int(
values,
"HOST_AGENT_CONSOLE_HISTORY_LIMIT",
200,
),
ai_planner_transport=("direct" if mode == "local" else _parse_ai_planner_transport(values.get("AI_PLANNER_TRANSPORT"))),
dependency_supervisor_enabled=_truthy(
values,
"HOST_AGENT_DEPENDENCY_SUPERVISOR_ENABLED",
mode == "local",
),
appium_supervised=_truthy(
values, "HOST_AGENT_APPIUM_SUPERVISED", mode == "local"
),
appium_host=values.get("HOST_AGENT_APPIUM_HOST", "127.0.0.1").strip(),
appium_port=_positive_int(values, "HOST_AGENT_APPIUM_PORT", 4723),
dependency_restart_max_attempts=_positive_int(
values, "HOST_AGENT_DEPENDENCY_RESTART_MAX_ATTEMPTS", 5
),
task_progress_db_path=Path(
values.get(
"HOST_AGENT_TASK_PROGRESS_DB_PATH",
"host_agent_data/task_progress.sqlite3",
).strip()
),
task_artifact_dir=Path(
values.get(
"HOST_AGENT_TASK_ARTIFACT_DIR", "host_agent_data/history"
).strip()
),
conversation_log_path=Path(values.get("HOST_AGENT_CONVERSATION_LOG_PATH", "host_agent_data/conversations.sqlite3").strip()),
task_retention_max_count=_positive_int(
values, "HOST_AGENT_TASK_RETENTION_MAX_COUNT", 50
),
task_retention_max_age_days=_positive_int(
values, "HOST_AGENT_TASK_RETENTION_MAX_AGE_DAYS", 7
),
skill_sync_interval_seconds=_positive_float(
values, "HOST_AGENT_SKILL_SYNC_INTERVAL_SECONDS", 300.0
),
)
if config.max_retry_backoff_seconds < config.retry_backoff_seconds:
raise HostAgentConfigurationError(
"maximum retry backoff must not be less than initial backoff"
)
if (
config.console_bind_host not in _LOOPBACK_BIND_HOSTS
and not config.console_allow_non_loopback
):
raise HostAgentConfigurationError(
"HOST_AGENT_CONSOLE_BIND_HOST must be a loopback address "
f"({', '.join(sorted(_LOOPBACK_BIND_HOSTS))}) unless "
"HOST_AGENT_CONSOLE_ALLOW_NON_LOOPBACK is set"
)
return config
def _reject_removed_runtime_supervision_settings(values: Mapping[str, str]) -> None:
configured = [
setting
for setting in _REMOVED_RUNTIME_SUPERVISION_SETTINGS
if setting in values
]
if configured:
raise HostAgentConfigurationError(
f"{', '.join(configured)} has been removed with the standalone "
"Runtime service. Use the Host Agent console for task evidence "
"and HOST_AGENT_APPIUM_SUPERVISED for optional Appium supervision."
)
def _parse_ai_planner_transport(value: str | None) -> str:
if value is None:
return "cloud"
transport = value.strip().lower()
if transport not in _AI_PLANNER_TRANSPORTS:
raise HostAgentConfigurationError(
"AI_PLANNER_TRANSPORT must be one of "
f"{', '.join(sorted(_AI_PLANNER_TRANSPORTS))}"
)
return transport
def _positive_float(
values: Mapping[str, str],
name: str,
@@ -134,3 +245,14 @@ def _positive_int(
if value <= 0:
raise HostAgentConfigurationError(f"{name} must be greater than zero")
return value
def _truthy(
values: Mapping[str, str],
name: str,
default: bool,
) -> bool:
raw_value = values.get(name)
if raw_value is None:
return default
return raw_value.strip().lower() in {"true", "1"}
@@ -0,0 +1,291 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any, Callable
from runtime.planner_config import PlannerConfig
from runtime.tool_specs import ACTION_TOOL_SPECS, ToolSpec
READ_TOOL_SPECS = [
ToolSpec("take_screenshot", "Capture the current device screen.", {"type": "object", "properties": {"device_id": {"type": "string"}}}),
ToolSpec("describe_screen", "Inspect the current screen and UI.", {"type": "object", "properties": {"device_id": {"type": "string"}}}),
ToolSpec("find_text", "Find visible text on the current screen.", {"type": "object", "required": ["query"], "properties": {"query": {"type": "string"}, "device_id": {"type": "string"}}}),
ToolSpec("get_ui_tree", "Read the current accessibility/UI tree.", {"type": "object", "properties": {"device_id": {"type": "string"}, "include_app_info": {"type": "boolean"}}}),
ToolSpec("list_devices", "List configured devices.", {"type": "object", "properties": {}}),
ToolSpec("device_status", "Read one device status.", {"type": "object", "required": ["device_id"], "properties": {"device_id": {"type": "string"}}}),
]
@dataclass(frozen=True)
class ChatResult:
content: str
tool_calls: int
class ConversationAgent:
"""Small OpenAI-compatible agent loop backed by Host Agent tools."""
def __init__(
self,
*,
config: PlannerConfig,
tools: dict[str, Callable[..., Any]],
max_rounds: int = 8,
event_logger: Callable[[dict[str, Any]], None] | None = None,
) -> None:
self.config = config
self.tools = tools
self.max_rounds = max_rounds
self.event_logger = event_logger
self._client: Any | None = None
def chat(self, messages: list[dict[str, Any]], *, tools: dict[str, Callable[..., Any]] | None = None) -> ChatResult:
if self.config.provider == "anthropic":
return self._chat_anthropic(messages, tools=tools)
if self.config.provider not in {"openai", "openai_compatible"}:
raise RuntimeError("unsupported conversation provider")
client = self._get_client()
active_tools = tools or self.tools
history: list[dict[str, Any]] = [
{
"role": "system",
"content": (
"You are a mobile device assistant. Reply naturally when no "
"device action is needed. Call a tool when the user asks you "
"to inspect or operate a device. Never claim an action was "
"completed unless the tool result confirms it."
),
},
*[_normalize_message(message) for message in messages],
]
calls = 0
for _ in range(self.max_rounds):
response = client.chat.completions.create(
model=self.config.resolved_model(),
messages=history,
tools=[_openai_tool(spec) for spec in [*ACTION_TOOL_SPECS, *READ_TOOL_SPECS]],
tool_choice="auto",
parallel_tool_calls=False,
timeout=self.config.timeout,
)
message = response.choices[0].message
text = getattr(message, "content", None)
thinking = getattr(message, "reasoning_content", None) or getattr(message, "reasoning", None)
tool_calls = getattr(message, "tool_calls", None) or []
self._log({"type": "llm_response", "content": text, "thinking": thinking,
"tool_calls": [{"id": c.id, "name": c.function.name, "arguments": c.function.arguments} for c in tool_calls]})
if not tool_calls:
self._log({"type": "final_reply", "content": str(text or "")})
return ChatResult(content=str(text or ""), tool_calls=calls)
history.append(_message_dict(message))
for tool_call in tool_calls:
name = tool_call.function.name
arguments: dict[str, Any] = {}
try:
decoded_arguments = json.loads(tool_call.function.arguments or "{}")
if not isinstance(decoded_arguments, dict):
raise ValueError("tool arguments must be an object")
arguments = decoded_arguments
arguments.pop("purpose", None)
arguments.pop("expected_outcome", None)
result = active_tools[name](**arguments)
except Exception as exc:
result = {"ok": False, "error": str(exc)}
calls += 1
self._log({"type": "tool_result", "tool_call_id": tool_call.id,
"tool_name": name, "arguments": arguments, "result": result})
history.append(
{
"role": "tool",
"tool_call_id": tool_call.id,
"content": json.dumps(result, ensure_ascii=True, default=str),
}
)
raise RuntimeError("conversation exceeded the maximum tool-call rounds")
def _chat_anthropic(self, messages: list[dict[str, Any]], *, tools: dict[str, Callable[..., Any]] | None = None) -> ChatResult:
import anthropic
kwargs: dict[str, Any] = {}
if self.config.api_key:
kwargs["api_key"] = self.config.api_key
if self.config.base_url:
kwargs["base_url"] = self.config.base_url
client = anthropic.Anthropic(**kwargs)
history = [_normalize_anthropic_message(message) for message in messages]
active_tools = tools or self.tools
calls = 0
for _ in range(self.max_rounds):
response = client.messages.create(
model=self.config.resolved_model(),
max_tokens=2048,
system="You are a mobile device assistant. Reply naturally, or call one device tool when needed. Never claim an action succeeded unless its tool result confirms it.",
messages=history,
tools=[_anthropic_tool(spec) for spec in [*ACTION_TOOL_SPECS, *READ_TOOL_SPECS]],
timeout=self.config.timeout,
)
blocks = getattr(response, "content", []) or []
text_parts = [getattr(block, "text", "") for block in blocks if getattr(block, "type", None) == "text"]
thinking_parts = [getattr(block, "thinking", "") for block in blocks if getattr(block, "type", None) == "thinking"]
uses = [block for block in blocks if getattr(block, "type", None) == "tool_use"]
self._log({"type": "llm_response", "content": "\n".join(x for x in text_parts if x), "thinking": "\n".join(x for x in thinking_parts if x), "tool_calls": [{"id": u.id, "name": u.name, "arguments": u.input} for u in uses]})
if not uses:
content = "\n".join(x for x in text_parts if x)
self._log({"type": "final_reply", "content": content})
return ChatResult(content=content, tool_calls=calls)
history.append({"role": "assistant", "content": [_anthropic_block_dict(block) for block in blocks if getattr(block, "type", None) != "thinking"]})
results = []
for use in uses:
arguments = dict(use.input) if isinstance(use.input, dict) else {}
try:
result = active_tools[use.name](**arguments)
except Exception as exc:
result = {"ok": False, "error": str(exc)}
calls += 1
self._log({"type": "tool_result", "tool_call_id": use.id, "tool_name": use.name, "arguments": arguments, "result": result})
results.append({"type": "tool_result", "tool_use_id": use.id, "content": json.dumps(result, ensure_ascii=True, default=str)})
history.append({"role": "user", "content": results})
raise RuntimeError("conversation exceeded the maximum tool-call rounds")
def chat_for_device(self, device_id: str, messages: list[dict[str, Any]]) -> ChatResult:
"""Run a conversation bound to exactly one device."""
device_tools = {
name: _bind_device(tool, device_id, name=name)
for name, tool in self.tools.items()
}
return self.chat(messages, tools=device_tools)
def _log(self, event: dict[str, Any]) -> None:
if self.event_logger is not None:
try:
self.event_logger(event)
except Exception:
pass
def _get_client(self) -> Any:
if self._client is None:
from openai import OpenAI
kwargs: dict[str, Any] = {}
if self.config.api_key:
kwargs["api_key"] = self.config.api_key
if self.config.base_url:
kwargs["base_url"] = self.config.base_url
self._client = OpenAI(**kwargs)
return self._client
def _openai_tool(spec: ToolSpec) -> dict[str, Any]:
parameters = dict(spec.parameters)
properties = dict(parameters.get("properties") or {})
properties.setdefault(
"device_id",
{"type": "string", "description": "Target device ID when needed."},
)
parameters["properties"] = properties
return {
"type": "function",
"function": {
"name": spec.name,
"description": spec.description,
"parameters": parameters,
},
}
def _anthropic_tool(spec: ToolSpec) -> dict[str, Any]:
return {"name": spec.name, "description": spec.description, "input_schema": spec.parameters}
def _anthropic_block_dict(block: Any) -> dict[str, Any]:
block_type = getattr(block, "type", "")
if block_type == "text":
return {"type": "text", "text": getattr(block, "text", "")}
if block_type == "thinking":
return {"type": "thinking", "thinking": getattr(block, "thinking", "")}
return {"type": "tool_use", "id": block.id, "name": block.name, "input": block.input}
def _normalize_anthropic_message(message: dict[str, Any]) -> dict[str, Any]:
normalized = _normalize_message(message)
content = normalized["content"]
if isinstance(content, str):
return normalized
blocks = []
for block in content:
if block.get("type") == "text":
blocks.append(block)
elif block.get("type") == "image_url":
url = block.get("image_url", {}).get("url", "")
if isinstance(url, str) and url.startswith("data:"):
header, data = url.split(",", 1)
media_type = header[5:].split(";", 1)[0]
blocks.append({"type": "image", "source": {"type": "base64", "media_type": media_type, "data": data}})
return {"role": normalized["role"], "content": blocks}
def _message_dict(message: Any) -> dict[str, Any]:
result: dict[str, Any] = {"role": "assistant"}
content = getattr(message, "content", None)
if content is not None:
result["content"] = content
calls = getattr(message, "tool_calls", None) or []
result["tool_calls"] = [
{
"id": call.id,
"type": "function",
"function": {
"name": call.function.name,
"arguments": call.function.arguments,
},
}
for call in calls
]
return result
def _normalize_message(message: dict[str, Any]) -> dict[str, Any]:
"""Accept OpenAI text/image content blocks and a compact image_base64 form."""
role = message.get("role")
content = message.get("content")
if isinstance(content, str):
return {"role": role, "content": content}
if isinstance(content, list):
blocks: list[dict[str, Any]] = []
for block in content:
if not isinstance(block, dict):
continue
if block.get("type") == "text" and isinstance(block.get("text"), str):
blocks.append({"type": "text", "text": block["text"]})
elif block.get("type") == "image_url":
image_url = block.get("image_url")
if isinstance(image_url, str):
image_url = {"url": image_url}
if isinstance(image_url, dict) and isinstance(image_url.get("url"), str):
blocks.append({"type": "image_url", "image_url": {"url": image_url["url"]}})
if blocks:
return {"role": role, "content": blocks}
image = message.get("image_base64")
if isinstance(image, str) and image:
mime = str(message.get("mime_type") or "image/png")
text = message.get("text")
blocks = []
if isinstance(text, str) and text:
blocks.append({"type": "text", "text": text})
blocks.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{image}"}})
return {"role": role, "content": blocks}
raise ValueError("message content must be text, image blocks, or image_base64")
def _bind_device(tool: Callable[..., Any], device_id: str, *, name: str) -> Callable[..., Any]:
def bound(**arguments: Any) -> Any:
if name == "list_devices":
return tool(**arguments)
requested = arguments.get("device_id")
if requested is not None and requested != device_id:
raise ValueError(f"conversation is bound to device {device_id}")
arguments["device_id"] = device_id
return tool(**arguments)
return bound
@@ -0,0 +1,48 @@
from __future__ import annotations
import json
import sqlite3
from datetime import UTC, datetime
from pathlib import Path
from threading import Lock
from typing import Any
class ConversationLogStore:
"""SQLite-backed local audit log for chat and tool activity."""
def __init__(self, path: str | Path) -> None:
self.path = Path(path)
self._lock = Lock()
self.path.parent.mkdir(parents=True, exist_ok=True)
with self._connect() as connection:
connection.execute("CREATE TABLE IF NOT EXISTS conversation_events (id INTEGER PRIMARY KEY AUTOINCREMENT, occurred_at TEXT NOT NULL, event_type TEXT NOT NULL, payload_json TEXT NOT NULL)")
def append(self, event: dict[str, Any]) -> None:
with self._lock, self._connect() as connection:
connection.execute("INSERT INTO conversation_events (occurred_at, event_type, payload_json) VALUES (?, ?, ?)", (datetime.now(UTC).isoformat(), str(event.get("type") or "event"), json.dumps(_safe(event), ensure_ascii=True, default=str)))
def list_recent(self, *, limit: int = 200) -> list[dict[str, Any]]:
with self._connect() as connection:
rows = connection.execute("SELECT id, occurred_at, event_type, payload_json FROM conversation_events ORDER BY id DESC LIMIT ?", (max(1, min(limit, 1000)),)).fetchall()
result = []
for row in rows:
try:
payload = json.loads(row["payload_json"])
except (TypeError, ValueError):
payload = {}
result.append({"id": row["id"], "occurred_at": row["occurred_at"], "event_type": row["event_type"], **payload})
return result
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self.path)
connection.row_factory = sqlite3.Row
return connection
def _safe(value: Any) -> Any:
if isinstance(value, dict):
return {str(k): _safe(v) for k, v in value.items()}
if isinstance(value, list):
return [_safe(v) for v in value]
return value if isinstance(value, (str, int, float, bool)) or value is None else str(value)
@@ -0,0 +1,403 @@
"""Optional supervisor for Appium in the macOS single-machine real-device
workflow. Appium gates real driver connections; task inspection is provided by
the Host Agent's own console.
Lives in ``host_agent`` because it spawns and monitors host-level processes
alongside the heartbeat/claim loop. Off by default; see ``HostAgentConfig``.
Design (``openspec/changes/host-agent-dependency-supervisor/design.md``):
- Default off. Each dependency also has its own opt-in flag.
- Before spawn: TCP connect + dependency-specific HTTP health probe. A healthy
listener is *adopted* (never killed or restarted). An unhealthy listener is
a port conflict logged and skipped. No listener spawn.
- Only supervisor-spawned processes are restarted on unexpected exit, with
capped exponential backoff and a per-process-lifetime attempt ceiling.
- Adopted processes are never touched by ``stop()``.
- Spawns use stdlib ``subprocess.Popen`` (no new dependency). Child stdout/stderr
is forwarded into this module's logger, tagged per dependency.
"""
from __future__ import annotations
import asyncio
import logging
import socket
import subprocess
import threading
from collections.abc import Awaitable, Callable, Sequence
from dataclasses import dataclass
from enum import Enum
from typing import TYPE_CHECKING
import httpx
if TYPE_CHECKING:
from host_agent.config import HostAgentConfig
_LOGGER = logging.getLogger("host_agent.dependency_supervisor")
_PROBE_TCP_TIMEOUT_SECONDS = 1.0
_PROBE_HTTP_TIMEOUT_SECONDS = 2.0
class ProbeResult(str, Enum):
"""Outcome of probing a dependency's configured address."""
HEALTHY = "healthy"
UNHEALTHY_LISTENER = "unhealthy_listener"
NO_LISTENER = "no_listener"
def probe_appium(host: str, port: int) -> ProbeResult:
"""Probe Appium at ``host:port``. Healthy iff ``GET /status`` returns 200
with a JSON body (the documented shape per ``docs/MACOS_IPHONE_SETUP.md``
§6 contains ``ready``/build info)."""
return _probe_http(host, port, path="/status")
def _probe_http(host: str, port: int, *, path: str) -> ProbeResult:
# Step 1: plain TCP connect — distinguish "nothing listening" (→ spawn)
# from "something is there but wrong" (→ port conflict, skip).
try:
with socket.create_connection((host, port), timeout=_PROBE_TCP_TIMEOUT_SECONDS):
pass
except OSError:
return ProbeResult.NO_LISTENER
# Step 2: dependency-specific HTTP health check.
try:
response = httpx.get(
f"http://{host}:{port}{path}",
timeout=_PROBE_HTTP_TIMEOUT_SECONDS,
)
except httpx.HTTPError:
return ProbeResult.UNHEALTHY_LISTENER
if response.status_code != 200:
return ProbeResult.UNHEALTHY_LISTENER
try:
response.json()
except ValueError:
return ProbeResult.UNHEALTHY_LISTENER
return ProbeResult.HEALTHY
def appium_argv_factory(host: str, port: int) -> list[str]:
return ["appium", "--address", host, "--port", str(port)]
@dataclass
class SupervisedDependency:
"""Config + mutable runtime state for one supervised external process."""
name: str
host: str
port: int
argv_factory: Callable[[str, int], Sequence[str]]
probe: Callable[[str, int], ProbeResult]
# Mutable state:
process: subprocess.Popen | None = None
adopted: bool = False
given_up: bool = False
restart_attempts: int = 0
ready: bool = False
@dataclass
class _SupervisorKnobs:
"""Tunables exposed for tests; production uses the defaults."""
startup_timeout_seconds: float = 30.0
readiness_poll_interval_seconds: float = 0.5
crash_poll_interval_seconds: float = 1.0
initial_backoff_seconds: float = 1.0
max_backoff_seconds: float = 30.0
terminate_grace_period_seconds: float = 5.0
class DependencySupervisor:
"""Manage zero or more supervised local dependencies.
Each dependency is either *adopted* (an existing healthy instance was
detected never killed or restarted) or *spawned* (a child ``Popen``
handle the supervisor owns restart-on-crash with capped exponential
backoff up to ``max_attempts`` per process lifetime).
"""
def __init__(
self,
dependencies: list[SupervisedDependency],
*,
max_attempts: int,
knobs: _SupervisorKnobs | None = None,
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
popen_factory: Callable[[Sequence[str]], subprocess.Popen] = subprocess.Popen,
logger: logging.Logger = _LOGGER,
) -> None:
self._dependencies = dependencies
self._max_attempts = max_attempts
self._knobs = knobs or _SupervisorKnobs()
self._sleep = sleep
self._popen_factory = popen_factory
self._logger = logger
self._reader_threads: list[threading.Thread] = []
@classmethod
def from_host_agent_config(
cls,
ha_config: HostAgentConfig,
**kwargs: object,
) -> DependencySupervisor:
"""Build a supervisor reflecting ``HostAgentConfig`` flags.
Caller is responsible for only invoking this when
``dependency_supervisor_enabled`` is true; the resulting supervisor
will contain only the dependencies whose individual ``*_supervised``
flag is also true (possibly an empty list).
"""
deps: list[SupervisedDependency] = []
if ha_config.appium_supervised:
deps.append(
SupervisedDependency(
name="appium",
host=ha_config.appium_host,
port=ha_config.appium_port,
argv_factory=appium_argv_factory,
probe=probe_appium,
)
)
return cls(
deps,
max_attempts=ha_config.dependency_restart_max_attempts,
**kwargs, # type: ignore[arg-type]
)
@property
def dependencies(self) -> list[SupervisedDependency]:
return list(self._dependencies)
async def start(self) -> None:
"""Probe + spawn/adopt + readiness wait.
Call before the heartbeat loop's first ``connect_devices()`` pass so
a supervised Appium is up before any ``Driver.connect()`` attempt.
"""
for dep in self._dependencies:
await self._start_one(dep)
async def _start_one(self, dep: SupervisedDependency) -> None:
result = await asyncio.to_thread(dep.probe, dep.host, dep.port)
if result is ProbeResult.HEALTHY:
dep.adopted = True
dep.ready = True
self._logger.info(
"dependency-supervisor: %s adopted existing instance at %s:%s",
dep.name,
dep.host,
dep.port,
)
return
if result is ProbeResult.UNHEALTHY_LISTENER:
self._logger.error(
"dependency-supervisor: %s port %s occupied by an unhealthy "
"listener; leaving un-started to avoid a port conflict",
dep.name,
dep.port,
)
dep.given_up = True
return
await self._spawn_with_readiness_wait(dep)
async def _spawn_with_readiness_wait(
self,
dep: SupervisedDependency,
) -> bool:
"""Spawn ``dep`` and wait for it to become healthy.
Returns True on readiness, False otherwise. On spawn failure (missing
executable) or readiness timeout while the process is still running,
marks the dependency as ``given_up`` (no crash-restart loop entered).
If the process exits during startup, returns False without giving up
the crash-restart loop in ``run()`` handles subsequent restarts.
"""
argv = list(dep.argv_factory(dep.host, dep.port))
try:
proc = self._popen_factory(
argv,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
bufsize=1,
text=True,
)
except FileNotFoundError:
self._logger.error(
"dependency-supervisor: %s spawn failed — executable not "
"found on PATH (tried: %s)",
dep.name,
argv[0],
)
dep.given_up = True
return False
except OSError as exc:
self._logger.error(
"dependency-supervisor: %s spawn failed: %s",
dep.name,
exc,
)
dep.given_up = True
return False
dep.process = proc
dep.adopted = False
dep.ready = False
self._start_reader_thread(dep, proc)
self._logger.info(
"dependency-supervisor: %s spawned (pid %s) at %s:%s",
dep.name,
proc.pid,
dep.host,
dep.port,
)
loop = asyncio.get_running_loop()
deadline = loop.time() + self._knobs.startup_timeout_seconds
while True:
if proc.poll() is not None:
self._logger.error(
"dependency-supervisor: %s exited during startup with code %s",
dep.name,
proc.returncode,
)
dep.ready = False
return False
probe_result = await asyncio.to_thread(dep.probe, dep.host, dep.port)
if probe_result is ProbeResult.HEALTHY:
dep.ready = True
self._logger.info(
"dependency-supervisor: %s ready at %s:%s",
dep.name,
dep.host,
dep.port,
)
return True
if loop.time() >= deadline:
self._logger.error(
"dependency-supervisor: %s started but did not become "
"healthy within %ss; process still running, not entering "
"crash-restart loop",
dep.name,
self._knobs.startup_timeout_seconds,
)
dep.ready = False
return False
await self._sleep(self._knobs.readiness_poll_interval_seconds)
def _start_reader_thread(
self,
dep: SupervisedDependency,
proc: subprocess.Popen,
) -> None:
def reader_loop() -> None:
stdout = proc.stdout
if stdout is None:
return
for raw_line in stdout:
line = raw_line.rstrip()
if line:
self._logger.info("%s: %s", dep.name, line)
thread = threading.Thread(
target=reader_loop,
name=f"dep-sup-{dep.name}",
daemon=True,
)
thread.start()
self._reader_threads.append(thread)
async def run(self, stop: asyncio.Event) -> None:
"""Background monitor: detect crashes and apply backoff restart.
Returns when ``stop`` is set. Crashes are only detected for
spawned processes (not adopted ones).
"""
if not self._dependencies:
await stop.wait()
return
while not stop.is_set():
for dep in self._dependencies:
await self._check_one(dep)
try:
await asyncio.wait_for(
stop.wait(),
timeout=self._knobs.crash_poll_interval_seconds,
)
except TimeoutError:
continue
async def _check_one(self, dep: SupervisedDependency) -> None:
if dep.given_up or dep.adopted or dep.process is None:
return
if dep.process.poll() is None:
return
self._logger.warning(
"dependency-supervisor: %s exited unexpectedly with code %s",
dep.name,
dep.process.returncode,
)
dep.process = None
dep.ready = False
dep.restart_attempts += 1
if dep.restart_attempts > self._max_attempts:
self._logger.error(
"dependency-supervisor: %s restart attempts exhausted "
"(crashes: %s, limit: %s); giving up for the rest of this "
"process lifetime",
dep.name,
dep.restart_attempts,
self._max_attempts,
)
dep.given_up = True
return
backoff = min(
self._knobs.initial_backoff_seconds * (2 ** (dep.restart_attempts - 1)),
self._knobs.max_backoff_seconds,
)
self._logger.info(
"dependency-supervisor: %s restarting in %.1fs (crash %s/%s)",
dep.name,
backoff,
dep.restart_attempts,
self._max_attempts,
)
await self._sleep(backoff)
await self._spawn_with_readiness_wait(dep)
async def stop(self) -> None:
"""Terminate spawned children. Adopted processes are left untouched."""
for dep in self._dependencies:
if dep.adopted or dep.process is None:
continue
proc = dep.process
self._logger.info(
"dependency-supervisor: stopping %s (pid %s)",
dep.name,
proc.pid,
)
proc.terminate()
try:
await asyncio.to_thread(
proc.wait, self._knobs.terminate_grace_period_seconds
)
except subprocess.TimeoutExpired:
self._logger.warning(
"dependency-supervisor: %s did not exit within %ss, killing",
dep.name,
self._knobs.terminate_grace_period_seconds,
)
proc.kill()
await asyncio.to_thread(proc.wait)
except OSError:
pass
finally:
dep.process = None
@@ -0,0 +1,66 @@
from __future__ import annotations
from typing import Any
from device.manager import DeviceManager
from driver.registry import build_driver_factory
from host_agent.client import HostAgentEnrollmentClient
from host_agent.config import HostAgentConfig
from storage.device_config import DeviceConfigStore
def register_local_device(
config_store: DeviceConfigStore,
manager: DeviceManager,
*,
device_id: str,
driver_type: str,
connection_info: dict[str, Any],
name: str | None,
config: HostAgentConfig,
enrollment_client: HostAgentEnrollmentClient | None,
) -> None:
previous = config_store.get(device_id)
previous_runtime_id = (
previous["cloud_device_id"] or previous["device_id"] if previous else None
)
config_store.add(
device_id=device_id,
name=name,
driver_type=driver_type,
connection_info=connection_info,
)
runtime_device_id = device_id
if config.enrollment_managed:
assert enrollment_client is not None
enrollment = enrollment_client.enroll_device(
local_device_id=device_id,
driver_type=driver_type,
name=name,
capability_tags=[],
)
runtime_device_id = enrollment.device_id
config_store.set_cloud_device_id(device_id, runtime_device_id)
if previous_runtime_id is not None and previous_runtime_id != runtime_device_id:
manager.unregister_device(previous_runtime_id)
manager.register_device(
runtime_device_id,
build_driver_factory(driver_type, connection_info),
name=name,
driver_type=driver_type,
connection_info=connection_info,
)
def unregister_local_device(
config_store: DeviceConfigStore,
manager: DeviceManager,
*,
device_id: str,
) -> None:
record = config_store.get(device_id)
if record is None:
return
runtime_device_id = record["cloud_device_id"] or record["device_id"]
manager.unregister_device(runtime_device_id)
config_store.remove(device_id)
@@ -3,7 +3,7 @@ from __future__ import annotations
from dataclasses import replace
from host_agent.client import HostAgentEnrollmentClient
from host_agent.config import HostAgentConfig, HostAgentConfigurationError
from host_agent.config import HostAgentConfig
from host_agent.identity import HostIdentityStore
@@ -17,10 +17,6 @@ def resolve_host_identity(
return config
state = identity_store.load_or_create()
if state.host_id is None:
if not config.enrollment_token:
raise HostAgentConfigurationError(
"HOST_AGENT_ENROLLMENT_TOKEN is required to complete enrollment"
)
response = client.enroll_host(
agent_instance_id=state.agent_instance_id,
host_token=state.token,
+81 -1
View File
@@ -1,13 +1,22 @@
from __future__ import annotations
import os
from collections.abc import Callable
from dataclasses import dataclass
from dataclasses import dataclass, replace
from typing import Any
from device.manager import DeviceManager
from host_agent.cloud_planner_client import CloudProxyToolCallingClient
from host_agent.config import HostAgentConfig, load_host_agent_config
from runtime.ai_planner import AIPlanner
from runtime.executor import Executor, default_tool_registry
from runtime.planner import Planner
from runtime.planner_config import PlannerConfig, load_config as load_planner_config
from runtime.task import TaskRunner
from storage.task_metadata import TaskMetadataStore
from storage.timeline import Timeline
from tools.describe_screen import describe_screen
from tools.screenshot import take_screenshot
from workflow.runner import WorkflowRunner
from workflow.store import WorkflowStore
@@ -17,6 +26,7 @@ class ExecutionFactories:
task_runner_factory: Callable[[], TaskRunner]
workflow_runner_factory: Callable[[], WorkflowRunner]
workflow_store: WorkflowStore
metadata_store: TaskMetadataStore | None = None
def create_execution_factories(
@@ -25,14 +35,29 @@ def create_execution_factories(
workflow_store: WorkflowStore | None = None,
metadata_store: TaskMetadataStore | None = None,
timeline: Timeline | None = None,
host_agent_config: HostAgentConfig | None = None,
conversation_log: Any | None = None,
) -> ExecutionFactories:
shared_workflow_store = workflow_store or WorkflowStore()
resolved_host_agent_config = host_agent_config
def create_task_runner() -> TaskRunner:
return TaskRunner(
executor=Executor(tools=default_tool_registry(manager=manager)),
observer=lambda device_id: describe_screen(device_id, manager=manager),
screenshot_provider=lambda device_id: take_screenshot(
device_id, manager=manager
),
metadata_store=metadata_store,
timeline=timeline,
planner=_host_agent_planner(
resolved_host_agent_config,
event_logger=conversation_log.append if conversation_log is not None else None,
),
planner_config=_host_agent_planner_config(),
device_platform_provider=lambda device_id: _device_platform(
manager, device_id
),
)
def create_workflow_runner() -> WorkflowRunner:
@@ -45,4 +70,59 @@ def create_execution_factories(
task_runner_factory=create_task_runner,
workflow_runner_factory=create_workflow_runner,
workflow_store=shared_workflow_store,
metadata_store=metadata_store,
)
def _host_agent_planner_config() -> PlannerConfig:
"""Host Agent defaults to the AI planner unless an operator opts out.
`runtime.planner_config` defaults `enabled=False` for the shared Runtime
library (local dev/tests/cloud dispatcher keep the deterministic stub
planner unless asked). The Host Agent is the actual device-control path,
so it flips that default on here -- an explicit `AI_PLANNER_ENABLED=false`
still disables it.
"""
config = load_planner_config()
if os.environ.get("AI_PLANNER_ENABLED") is None:
config = replace(config, enabled=True)
return config
def _host_agent_planner(
host_agent_config: HostAgentConfig | None,
*,
event_logger: Callable[[dict[str, Any]], None] | None = None,
) -> Planner | None:
"""Build the `AIPlanner` explicitly when the cloud-proxy transport is
selected, so its `ToolCallingClient` is a `CloudProxyToolCallingClient`
instead of a local Anthropic/OpenAI SDK client.
Returns `None` (letting `TaskRunner` fall back to its own
`_default_planner()`) only for the explicit `direct` transport.
"""
planner_config = _host_agent_planner_config()
if not planner_config.enabled:
return None
resolved_config = host_agent_config or load_host_agent_config()
if resolved_config.ai_planner_transport != "cloud":
return AIPlanner(config=planner_config, event_logger=event_logger)
return AIPlanner(
client=CloudProxyToolCallingClient(resolved_config),
config=planner_config,
event_logger=event_logger,
)
def _device_platform(manager: DeviceManager, device_id: str) -> str | None:
for device in manager.list_devices():
if device.id != device_id:
continue
if device.driver_type == "wda":
return "ios"
if device.driver_type == "uiautomator2":
return "android"
return None
return None
+63 -4
View File
@@ -8,10 +8,14 @@ from core.errors import DeviceRuntimeError
from device.manager import DeviceManager
from host_agent.client import HostAgentClient
from host_agent.config import HostAgentConfig
from host_agent.policy_cache import HostPolicyCacheStore
from host_agent.status import AgentStatusTracker
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from host_agent.mcp_lock import McpBusyTracker
def build_device_snapshot(manager: DeviceManager) -> list[DeviceSnapshotModel]:
return [
@@ -34,18 +38,63 @@ class HeartbeatSynchronizer:
*,
address: str | None = None,
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
status_tracker: AgentStatusTracker | None = None,
on_sync: Callable[[int], None] | None = None,
policy_cache: HostPolicyCacheStore | None = None,
on_policy_sync: Callable[[int], None] | None = None,
mcp_busy_tracker: McpBusyTracker | None = None,
) -> None:
self.manager = manager
self.client = client
self.config = config
self.address = address
self._sleep = sleep
self.status_tracker = status_tracker
self.on_sync = on_sync
self.policy_cache = policy_cache
self.on_policy_sync = on_policy_sync
self.mcp_busy_tracker = mcp_busy_tracker
self.policy = policy_cache.load() if policy_cache is not None else None
self.policy_revision = self.policy.revision if self.policy is not None else 0
if self.status_tracker is not None:
self.status_tracker.mark_host_policy(self.policy)
async def sync_once(self) -> HeartbeatResponse:
return await self.client.heartbeat(
build_device_snapshot(self.manager),
address=self.address,
self.probe_connected_devices()
self.connect_devices()
snapshot = build_device_snapshot(self.manager)
mcp_busy_ids = (
self.mcp_busy_tracker.busy_device_ids()
if self.mcp_busy_tracker is not None
else []
)
response = await self.client.heartbeat(
snapshot,
address=self.address,
policy_revision=self.policy_revision,
mcp_busy_device_ids=mcp_busy_ids,
)
self.policy_revision = response.policy_revision
if response.policy is not None:
self.policy = response.policy
if self.policy_cache is not None:
self.policy_cache.save(response.policy)
if self.on_policy_sync is not None:
self.on_policy_sync(response.policy.revision)
elif response.policy_revision == 0:
self.policy = None
if self.policy_cache is not None:
self.policy_cache.clear()
if self.status_tracker is not None:
self.status_tracker.mark_host_policy(self.policy)
if self.status_tracker is not None:
self.status_tracker.mark_heartbeat(ok=True, device_count=len(snapshot))
if self.on_sync is not None:
try:
self.on_sync(len(snapshot))
except Exception:
pass
return response
async def run(self, stop: asyncio.Event) -> None:
self.connect_devices()
@@ -61,9 +110,19 @@ class HeartbeatSynchronizer:
def connect_devices(self) -> None:
for device in self.manager.list_devices():
if device.status != "idle":
if device.status not in {"idle", "offline", "error"}:
continue
try:
self.manager.connect(device.id)
except DeviceRuntimeError:
continue
def probe_connected_devices(self) -> None:
active_id: str | None = None
if self.status_tracker is not None:
current = self.status_tracker.snapshot().get("current_assignment")
if isinstance(current, dict) and isinstance(current.get("device_id"), str):
active_id = current["device_id"]
for device in self.manager.list_devices():
if device.id != active_id and device.status == "busy":
self.manager.probe(device.id)
@@ -0,0 +1,127 @@
from __future__ import annotations
import json
import sqlite3
from collections.abc import Callable
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
class ConsoleHistoryStore:
def __init__(
self,
db_path: str | Path = "tasks/host_console_history.sqlite3",
*,
limit: int = 200,
now: Callable[[], datetime] | None = None,
) -> None:
self.db_path = Path(db_path)
self.limit = limit
self._now = now or (lambda: datetime.now(UTC))
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._ensure_schema()
def record_assignment(
self,
*,
task_id: str,
attempt: int,
status: str,
failure_reason: str | None,
device_id: str,
) -> None:
summary = f"{task_id} attempt {attempt} on {device_id}: {status}"
if failure_reason:
summary = f"{summary} ({failure_reason})"
detail = {
"task_id": task_id,
"attempt": attempt,
"status": status,
"failure_reason": failure_reason,
"device_id": device_id,
}
self._insert("assignment", summary, detail)
def record_heartbeat(self, *, device_count: int) -> None:
summary = f"heartbeat: {device_count} devices"
detail = {"device_count": device_count}
self._insert("heartbeat", summary, detail)
def record_policy_sync(self, *, revision: int) -> None:
self._insert(
"host_policy",
f"host policy synchronized: revision {revision}",
{"revision": revision},
)
def record_task_submission(
self,
*,
task_id: str,
device_id: str | None,
) -> None:
if device_id:
summary = f"task submitted: {task_id} on {device_id}"
else:
summary = f"task submitted: {task_id} (automatic device)"
detail: dict[str, Any] = {"task_id": task_id}
if device_id is not None:
detail["device_id"] = device_id
self._insert("task_submission", summary, detail)
def list_recent(self, limit: int | None = None) -> list[dict[str, Any]]:
effective_limit = limit if limit is not None else self.limit
with self._connect() as connection:
rows = connection.execute(
"select kind, occurred_at, summary, detail_json"
" from history_entries order by id desc limit ?",
(effective_limit,),
).fetchall()
return [
{
"kind": row["kind"],
"occurred_at": row["occurred_at"],
"summary": row["summary"],
"detail": json.loads(row["detail_json"]),
}
for row in rows
]
def _insert(self, kind: str, summary: str, detail: dict[str, Any]) -> None:
occurred_at = self._now().isoformat()
with self._connect() as connection:
connection.execute(
"""
insert into history_entries (kind, occurred_at, summary, detail_json)
values (?, ?, ?, ?)
""",
(kind, occurred_at, summary, json.dumps(detail, ensure_ascii=False)),
)
connection.execute(
"""
delete from history_entries where id not in (
select id from history_entries order by id desc limit ?
)
""",
(self.limit,),
)
def _ensure_schema(self) -> None:
with self._connect() as connection:
connection.execute(
"""
create table if not exists history_entries (
id integer primary key autoincrement,
kind text not null,
occurred_at text not null,
summary text not null,
detail_json text not null
)
"""
)
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self.db_path)
connection.row_factory = sqlite3.Row
return connection
@@ -0,0 +1,71 @@
"""Per-installation exclusive-execution lock for the Host Agent process.
Backed by ``filelock.FileLock`` (OS-level advisory file locking) so that
the lock is released automatically when the holding process exits for
any reason clean shutdown, unhandled exception, or ``SIGKILL`` without
requiring any stale-lock cleanup step. See
``openspec/changes/host-agent-single-instance-lock/design.md`` for the
rationale behind choosing ``filelock`` over hand-rolled ``fcntl``/``msvcrt``
branching.
"""
from __future__ import annotations
from pathlib import Path
from filelock import FileLock, Timeout
_LOCK_FILENAME = "host_agent.lock"
class InstanceAlreadyRunningError(RuntimeError):
"""Raised when another live process already holds the instance lock."""
def __init__(self, lock_path: Path) -> None:
self.lock_path = lock_path
super().__init__(
"another Host Agent instance is already running for this "
f"identity state directory (lock held at {lock_path}); "
"stop the other process before starting a new one"
)
class InstanceLock:
"""Exclusive, non-blocking lock scoped to a Host Agent state directory.
The state directory (typically ``identity_path.parent``) is created on
construction to match the colocated-file convention already used for
``host_console_history.sqlite3`` and ``host_governance_policy.json``.
"""
def __init__(self, state_dir: Path) -> None:
state_dir.mkdir(parents=True, exist_ok=True)
self._lock_path = state_dir / _LOCK_FILENAME
self._lock = FileLock(str(self._lock_path), timeout=0)
self._acquired = False
@property
def lock_path(self) -> Path:
return self._lock_path
def acquire(self) -> None:
try:
self._lock.acquire()
except Timeout as exc:
raise InstanceAlreadyRunningError(self._lock_path) from exc
self._acquired = True
def release(self) -> None:
if not self._acquired:
return
try:
self._lock.release()
finally:
self._acquired = False
def __enter__(self) -> InstanceLock:
self.acquire()
return self
def __exit__(self, *exc_info: object) -> None:
self.release()
@@ -0,0 +1,79 @@
from __future__ import annotations
import json
import subprocess
import tempfile
from pathlib import Path
from typing import Any
class IOSDiscoveryError(RuntimeError):
pass
def discover_connected_ios_devices(*, timeout_seconds: int = 10) -> list[dict[str, str]]:
"""Return paired, currently connected physical iOS devices from CoreDevice."""
with tempfile.TemporaryDirectory(prefix="ios-device-discovery-") as temp_dir:
output_path = Path(temp_dir) / "devices.json"
try:
completed = subprocess.run(
[
"xcrun",
"devicectl",
"list",
"devices",
"--json-output",
str(output_path),
"--timeout",
str(timeout_seconds),
"--quiet",
],
capture_output=True,
text=True,
timeout=timeout_seconds + 2,
check=False,
)
except FileNotFoundError as exc:
raise IOSDiscoveryError("xcrun is unavailable; install Xcode command line tools") from exc
except subprocess.TimeoutExpired as exc:
raise IOSDiscoveryError("iOS device discovery timed out") from exc
if completed.returncode != 0:
detail = completed.stderr.strip() or completed.stdout.strip()
raise IOSDiscoveryError(detail or "devicectl failed to discover iOS devices")
try:
payload = json.loads(output_path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
raise IOSDiscoveryError("devicectl returned invalid device data") from exc
raw_devices = payload.get("result", {}).get("devices", [])
devices: list[dict[str, str]] = []
for item in raw_devices if isinstance(raw_devices, list) else []:
if not isinstance(item, dict):
continue
hardware = item.get("hardwareProperties", {})
properties = item.get("deviceProperties", {})
connection = item.get("connectionProperties", {})
if not all(isinstance(value, dict) for value in (hardware, properties, connection)):
continue
udid = hardware.get("udid")
if (
hardware.get("platform") != "iOS"
or hardware.get("reality") != "physical"
or connection.get("pairingState") != "paired"
or connection.get("tunnelState") != "connected"
or not isinstance(udid, str)
or not udid
):
continue
devices.append(
{
"udid": udid,
"name": str(properties.get("name") or hardware.get("marketingName") or "iPhone"),
"model": str(hardware.get("marketingName") or hardware.get("productType") or "iPhone"),
"os_version": str(properties.get("osVersionNumber") or ""),
"transport": str(connection.get("transportType") or "unknown"),
}
)
return sorted(devices, key=lambda device: (device["name"], device["udid"]))
+16 -1
View File
@@ -11,6 +11,8 @@ import httpx
from cloud.internal_api.models import AssignmentModel
from host_agent.assignment import AssignmentExecutionResult
from host_agent.client import HostAgentAPIError, HostAgentClient, StaleLeaseError
from host_agent.progress import TaskProgressSnapshot
from runtime.task import is_cancellation_reason
class InterruptibleAssignmentExecutor(Protocol):
@@ -19,8 +21,11 @@ class InterruptibleAssignmentExecutor(Protocol):
assignment: AssignmentModel,
*,
should_stop: Callable[[], bool] | None = None,
stop_reason: Callable[[], str | None] | None = None,
) -> AssignmentExecutionResult: ...
def latest_progress(self) -> TaskProgressSnapshot | None: ...
class LeaseGuard:
def __init__(self) -> None:
@@ -33,6 +38,10 @@ class LeaseGuard:
with self._lock:
return self._reason
@property
def is_cancellation(self) -> bool:
return is_cancellation_reason(self.reason)
def is_lost(self) -> bool:
return self._lost.is_set()
@@ -66,6 +75,7 @@ class ActiveAssignmentRunner:
self.executor.execute,
assignment,
should_stop=lambda: guard.is_lost() or self._stop_requested.is_set(),
stop_reason=lambda: guard.reason,
)
)
renewal = asyncio.create_task(
@@ -92,7 +102,9 @@ class ActiveAssignmentRunner:
if done:
return
try:
response = await self.client.renew(assignment)
response = await self.client.renew(
assignment, progress=self.executor.latest_progress()
)
except StaleLeaseError:
guard.mark_lost("lease rejected by control plane")
return
@@ -103,4 +115,7 @@ class ActiveAssignmentRunner:
guard.mark_lost("lease renewal failed after transport retries")
return
else:
if response.cancel_requested:
guard.mark_lost("cancellation requested by control plane")
return
lease_expires_at = response.lease_expires_at
@@ -0,0 +1,116 @@
from __future__ import annotations
import hmac
import json
import os
from dataclasses import dataclass, field
from hashlib import pbkdf2_hmac
from pathlib import Path
from secrets import token_bytes
from uuid import uuid4
PBKDF2_ITERATIONS = 600_000
SALT_BYTES = 16
class LocalAccountStateError(RuntimeError):
"""Raised when persisted local account state is missing or invalid."""
@dataclass(frozen=True)
class LocalAccountState:
username: str
salt: bytes = field(repr=False)
iterations: int
password_hash: bytes = field(repr=False)
class LocalAccountStore:
def __init__(self, path: str | Path) -> None:
self.path = Path(path)
def load(self) -> LocalAccountState | None:
if not self.path.exists():
return None
try:
payload = json.loads(self.path.read_text(encoding="utf-8"))
except (OSError, ValueError, json.JSONDecodeError) as exc:
raise LocalAccountStateError("local account state is unreadable") from exc
if not isinstance(payload, dict):
raise LocalAccountStateError("local account state must be an object")
username = payload.get("username")
salt_hex = payload.get("salt")
iterations = payload.get("iterations")
password_hash_hex = payload.get("password_hash")
if (
not isinstance(username, str)
or not username
or not isinstance(salt_hex, str)
or not isinstance(iterations, int)
or iterations <= 0
or not isinstance(password_hash_hex, str)
):
raise LocalAccountStateError("local account state is invalid")
try:
salt = bytes.fromhex(salt_hex)
password_hash = bytes.fromhex(password_hash_hex)
except ValueError as exc:
raise LocalAccountStateError("local account state is invalid") from exc
return LocalAccountState(
username=username,
salt=salt,
iterations=iterations,
password_hash=password_hash,
)
def create(self, username: str, password: str) -> LocalAccountState:
if not username.strip():
raise ValueError("username must not be empty")
if not password:
raise ValueError("password must not be empty")
salt = token_bytes(SALT_BYTES)
state = LocalAccountState(
username=username,
salt=salt,
iterations=PBKDF2_ITERATIONS,
password_hash=_derive_hash(password, salt, PBKDF2_ITERATIONS),
)
self._write(state)
return state
def verify(self, state: LocalAccountState, password: str) -> bool:
candidate = _derive_hash(password, state.salt, state.iterations)
return hmac.compare_digest(candidate, state.password_hash)
def _write(self, state: LocalAccountState) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
temporary = self.path.with_name(f".{self.path.name}.{uuid4().hex}.tmp")
payload = {
"username": state.username,
"salt": state.salt.hex(),
"iterations": state.iterations,
"password_hash": state.password_hash.hex(),
}
try:
temporary.write_text(
json.dumps(payload, ensure_ascii=True, indent=2) + "\n",
encoding="utf-8",
)
_restrict_permissions(temporary)
os.replace(temporary, self.path)
_restrict_permissions(self.path)
finally:
if temporary.exists():
temporary.unlink()
def _derive_hash(password: str, salt: bytes, iterations: int) -> bytes:
return pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
def _restrict_permissions(path: Path) -> None:
try:
path.chmod(0o600)
except OSError:
return
@@ -0,0 +1,72 @@
from __future__ import annotations
import asyncio
import uuid
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from cloud.internal_api.models import (
ClaimResponse,
HeartbeatResponse,
HostTaskCancellationResponse,
HostTaskSubmissionResponse,
LeaseRenewalResponse,
TerminalResultResponse,
)
from host_agent.progress import TaskProgressSnapshot
class LocalHostAgentClient:
"""In-process task broker used when the Host Agent runs without Cloud."""
def __init__(self, *, host_id: str, device_ids: Callable[[], list[str]]) -> None:
self.host_id = host_id
self._device_ids = device_ids
self._queue: asyncio.Queue[tuple[str, str, str | None]] = asyncio.Queue()
self._cancelled: set[str] = set()
async def submit_self_task(self, *, goal: str, device_id: str | None = None) -> HostTaskSubmissionResponse:
task_id = f"local-{uuid.uuid4().hex}"
await self._queue.put((task_id, goal, device_id))
return HostTaskSubmissionResponse(task_id=task_id)
async def claim(self):
task_id, goal, requested_device = await self._queue.get()
devices = self._device_ids()
device_id = requested_device or (devices[0] if devices else "")
if not device_id:
return None
from cloud.internal_api.models import AssignmentModel
return AssignmentModel(
task_id=task_id,
attempt=1,
lease_id=f"local-lease-{uuid.uuid4().hex}",
lease_expires_at=datetime.now(UTC) + timedelta(days=3650),
host_id=self.host_id,
device_id=device_id,
goal=goal,
)
async def heartbeat(self, *args, **kwargs) -> HeartbeatResponse:
return HeartbeatResponse(
host_id=self.host_id,
accepted_devices=len(self._device_ids()),
received_at=datetime.now(UTC),
)
async def renew(self, assignment, *, progress: TaskProgressSnapshot | None = None) -> LeaseRenewalResponse:
return LeaseRenewalResponse(
status="renewed",
lease_expires_at=datetime.now(UTC) + timedelta(days=3650),
)
async def report_result(self, assignment, *, status: str, failure_reason: str | None = None, result: dict | None = None) -> TerminalResultResponse:
return TerminalResultResponse(status="recorded")
async def cancel_task(self, task_id: str) -> HostTaskCancellationResponse:
self._cancelled.add(task_id)
return HostTaskCancellationResponse(task_id=task_id, status="cancel_requested")
async def aclose(self) -> None:
return None
@@ -0,0 +1,155 @@
"""Per-device MCP session-level busy tracker.
The cloud-side assignment path and the MCP-driven path both drive devices
through the same in-process ``DeviceManager``. This tracker records which
devices are currently held by an MCP session so that:
- MCP tool calls against a device held by another session (or by a cloud
assignment checked separately by the caller via ``AgentStatusTracker``)
can fail fast with a busy error.
- The heartbeat payload can advertise ``mcp_busy_device_ids`` so the cloud
scheduler won't dispatch conflicting assignments to the same device.
Leases expire ``ttl_seconds`` after the last ``renew()`` call (set on every
tool call from the holding session). Expired leases are lazy-swept on read.
"""
from __future__ import annotations
import time
from dataclasses import dataclass
from datetime import UTC, datetime
from threading import Lock
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Callable
@dataclass(frozen=True)
class McpDeviceLease:
device_id: str
session_id: str
acquired_at: datetime
last_seen_at: datetime
class McpBusyTracker:
def __init__(
self,
*,
ttl_seconds: float = 20.0,
now: Callable[[], datetime] | None = None,
) -> None:
self._ttl = float(ttl_seconds)
self._now = now or (lambda: datetime.now(UTC))
self._lock = Lock()
# device_id -> McpDeviceLease
self._leases: dict[str, McpDeviceLease] = {}
def acquire(self, device_id: str, session_id: str) -> bool:
with self._lock:
self._sweep_locked()
existing = self._leases.get(device_id)
if existing is not None and existing.session_id != session_id:
return False
now = self._now()
lease = McpDeviceLease(
device_id=device_id,
session_id=session_id,
acquired_at=(existing.acquired_at if existing is not None else now),
last_seen_at=now,
)
self._leases[device_id] = lease
return True
def renew(self, device_id: str, session_id: str) -> bool:
with self._lock:
self._sweep_locked()
existing = self._leases.get(device_id)
# Tolerate boundary: lease may have been swept, but if the caller
# is the legitimate previous holder, re-acquire on their behalf.
if existing is None:
now = self._now()
self._leases[device_id] = McpDeviceLease(
device_id=device_id,
session_id=session_id,
acquired_at=now,
last_seen_at=now,
)
return True
if existing.session_id != session_id:
return False
self._leases[device_id] = McpDeviceLease(
device_id=device_id,
session_id=session_id,
acquired_at=existing.acquired_at,
last_seen_at=self._now(),
)
return True
def release(self, session_id: str) -> list[str]:
with self._lock:
freed = [
device_id
for device_id, lease in self._leases.items()
if lease.session_id == session_id
]
for device_id in freed:
del self._leases[device_id]
return freed
def release_device(self, device_id: str, session_id: str) -> bool:
with self._lock:
existing = self._leases.get(device_id)
if existing is None or existing.session_id != session_id:
return False
del self._leases[device_id]
return True
def busy_device_ids(self) -> list[str]:
with self._lock:
self._sweep_locked()
return sorted(self._leases)
def snapshot(self) -> list[McpDeviceLease]:
with self._lock:
self._sweep_locked()
return sorted(self._leases.values(), key=lambda lease: lease.device_id)
def wait_until_usable(
self,
device_id: str,
session_id: str,
*,
timeout: float,
poll_interval: float = 1.0,
cloud_busy_check: Callable[[], bool] | None = None,
) -> bool:
"""Block until ``device_id`` is acquirable by ``session_id`` or timeout.
Reserved capability. MVP callers use try-acquire (``acquire`` -> False
means busy). This method exists for future wiring where the cloud
assignment path or an explicit MCP tool may opt to wait.
"""
deadline = time.monotonic() + timeout
while True:
cloud_busy = cloud_busy_check() if cloud_busy_check else False
if not cloud_busy:
if self.acquire(device_id, session_id):
return True
if time.monotonic() >= deadline:
return False
remaining = deadline - time.monotonic()
time.sleep(max(0.0, min(poll_interval, remaining)))
def _sweep_locked(self) -> None:
"""Caller holds ``self._lock``. Drops leases past their TTL."""
cutoff = self._now()
expired = [
device_id
for device_id, lease in self._leases.items()
if (cutoff - lease.last_seen_at).total_seconds() > self._ttl
]
for device_id in expired:
del self._leases[device_id]
@@ -0,0 +1,116 @@
"""Bearer-token persistence for the host-agent MCP server.
The token is generated on first start and persisted to a JSON file with
0o600 permissions (POSIX) alongside the host identity. Rotation = delete
the file and restart host-agent.
"""
from __future__ import annotations
import json
import os
import secrets
import tempfile
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Callable
_TOKEN_BYTES = 32
class McpTokenStoreError(RuntimeError):
"""Raised when the MCP token file cannot be read or written."""
@dataclass(frozen=True)
class McpToken:
version: int
token: str
created_at: datetime
class McpTokenStore:
def __init__(
self,
path: Path,
*,
now: Callable[[], datetime] | None = None,
) -> None:
self._path = Path(path)
self._now = now or (lambda: datetime.now(UTC))
def load_or_create(self) -> McpToken:
if self._path.exists():
return self._read_existing()
return self._generate_and_write()
def verify(self, presented: str) -> bool:
try:
token = self.load_or_create()
except McpTokenStoreError:
return False
import hmac
return hmac.compare_digest(token.token, presented)
def _read_existing(self) -> McpToken:
try:
data = json.loads(self._path.read_text())
except (OSError, json.JSONDecodeError) as exc:
raise McpTokenStoreError(
f"cannot read MCP token file {self._path}: {exc}"
) from exc
if not isinstance(data, dict):
raise McpTokenStoreError("MCP token file is not a JSON object")
try:
return McpToken(
version=int(data["version"]),
token=str(data["token"]),
created_at=datetime.fromisoformat(str(data["created_at"])),
)
except (KeyError, TypeError, ValueError) as exc:
raise McpTokenStoreError(f"MCP token file schema invalid: {exc}") from exc
def _generate_and_write(self) -> McpToken:
token = McpToken(
version=1,
token=secrets.token_urlsafe(_TOKEN_BYTES),
created_at=self._now(),
)
payload = {
"version": token.version,
"token": token.token,
"created_at": token.created_at.isoformat(),
}
try:
self._atomic_write(json.dumps(payload, indent=2))
except OSError as exc:
raise McpTokenStoreError(
f"cannot write MCP token file {self._path}: {exc}"
) from exc
return token
def _atomic_write(self, content: str) -> None:
self._path.parent.mkdir(parents=True, exist_ok=True)
# Atomic on POSIX; on Windows os.replace is also atomic per docs.
fd, tmp_name = tempfile.mkstemp(
prefix=".host_mcp_token.",
suffix=".tmp",
dir=str(self._path.parent),
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(content)
os.chmod(tmp_name, 0o600)
os.replace(tmp_name, self._path)
except BaseException:
try:
os.unlink(tmp_name)
except OSError:
pass
raise
@@ -0,0 +1,47 @@
"""Host-local execution metadata for Cloud planner accounting."""
from __future__ import annotations
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Iterator
from cloud.internal_api.models import AssignmentModel
@dataclass(frozen=True)
class PlannerExecutionContext:
task_id: str
attempt: int
lease_id: str
_context: ContextVar[PlannerExecutionContext | None] = ContextVar(
"host_agent_planner_execution_context",
default=None,
)
def current_planner_execution_context() -> PlannerExecutionContext | None:
return _context.get()
@contextmanager
def bind_planner_execution_context(
assignment: "AssignmentModel",
) -> "Iterator[None]":
token = _context.set(
PlannerExecutionContext(
task_id=assignment.task_id,
attempt=assignment.attempt,
lease_id=assignment.lease_id,
)
)
try:
yield
finally:
_context.reset(token)
@@ -0,0 +1,47 @@
from __future__ import annotations
import json
import os
from pathlib import Path
from uuid import uuid4
from cloud.internal_api.models import HostGovernancePolicyModel
class HostPolicyCacheError(RuntimeError):
"""Raised when the locally cached non-secret Host policy is invalid."""
class HostPolicyCacheStore:
"""Atomically persists only the Cloud-supplied, non-secret policy cache."""
def __init__(self, path: str | Path) -> None:
self.path = Path(path)
def load(self) -> HostGovernancePolicyModel | None:
if not self.path.exists():
return None
try:
payload = json.loads(self.path.read_text(encoding="utf-8"))
return HostGovernancePolicyModel.model_validate(payload)
except (OSError, ValueError, json.JSONDecodeError) as exc:
raise HostPolicyCacheError("Host policy cache is invalid") from exc
def save(self, policy: HostGovernancePolicyModel) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
temporary = self.path.with_name(f".{self.path.name}.{uuid4().hex}.tmp")
try:
temporary.write_text(
policy.model_dump_json(indent=2) + "\n",
encoding="utf-8",
)
os.replace(temporary, self.path)
finally:
if temporary.exists():
temporary.unlink()
def clear(self) -> None:
try:
self.path.unlink()
except FileNotFoundError:
return
+39 -14
View File
@@ -1,11 +1,15 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal, Protocol
from typing import TYPE_CHECKING, Literal, Protocol
from cloud.internal_api.models import AssignmentModel
from host_agent.assignment import AssignmentExecutionResult
from host_agent.client import HostAgentClient
from host_agent.status import AgentStatusTracker
if TYPE_CHECKING:
from collections.abc import Callable
class ActiveAssignmentExecutor(Protocol):
@@ -25,24 +29,45 @@ class AssignmentProcessor:
self,
client: HostAgentClient,
active_executor: ActiveAssignmentExecutor,
*,
status_tracker: AgentStatusTracker | None = None,
on_result: Callable[[AssignmentModel, AssignmentProcessingResult], None]
| None = None,
) -> None:
self.client = client
self.active_executor = active_executor
self.status_tracker = status_tracker
self.on_result = on_result
def request_stop(self) -> None:
self.active_executor.request_stop()
async def process(self, assignment: AssignmentModel) -> AssignmentProcessingResult:
execution = await self.active_executor.run(assignment)
status = "done" if execution.status == "done" else "failed"
failure_reason = execution.failure_reason if status == "failed" else None
response = await self.client.report_result(
assignment,
status=status,
failure_reason=failure_reason,
result=dict(execution.metadata),
)
return AssignmentProcessingResult(
execution=execution,
report_status=response.status,
)
if self.status_tracker is not None:
self.status_tracker.mark_assignment_started(assignment)
try:
execution = await self.active_executor.run(assignment)
if execution.status in {"done", "cancelled"}:
status = execution.status
else:
status = "failed"
failure_reason = execution.failure_reason if status != "done" else None
response = await self.client.report_result(
assignment,
status=status,
failure_reason=failure_reason,
result=dict(execution.metadata),
)
result = AssignmentProcessingResult(
execution=execution,
report_status=response.status,
)
finally:
if self.status_tracker is not None:
self.status_tracker.mark_assignment_finished()
if self.on_result is not None:
try:
self.on_result(assignment, result)
except Exception:
pass
return result
@@ -0,0 +1,52 @@
from __future__ import annotations
import threading
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Callable
_MAX_SUMMARY_LENGTH = 500
@dataclass(frozen=True)
class TaskProgressSnapshot:
step_index: int
step_status: str
summary: str
updated_at: datetime
class TaskProgressHolder:
"""Thread-safe latest-step-progress holder for one in-flight assignment.
Written by the execution thread (via ``update``, wired as the
``TaskRunner.on_step_progress`` callback) and read by the asyncio
lease-renewal loop (via ``snapshot``) just before each renewal call.
"""
def __init__(self, *, now: Callable[[], datetime] | None = None) -> None:
self._now = now or (lambda: datetime.now(UTC))
self._lock = threading.Lock()
self._snapshot: TaskProgressSnapshot | None = None
def update(self, step_index: int, step_status: str, summary: str) -> None:
if len(summary) > _MAX_SUMMARY_LENGTH:
summary = summary[:_MAX_SUMMARY_LENGTH]
with self._lock:
self._snapshot = TaskProgressSnapshot(
step_index=step_index,
step_status=step_status,
summary=summary,
updated_at=self._now(),
)
def clear(self) -> None:
with self._lock:
self._snapshot = None
def snapshot(self) -> TaskProgressSnapshot | None:
with self._lock:
return self._snapshot
@@ -0,0 +1,63 @@
from __future__ import annotations
import logging
from datetime import UTC, datetime, timedelta
from host_agent.config import HostAgentConfig
from storage.task_metadata import TaskMetadataStore
from storage.timeline import Timeline
logger = logging.getLogger(__name__)
def prune_task_history(
metadata_store: TaskMetadataStore,
timeline: Timeline,
*,
config: HostAgentConfig,
now: datetime | None = None,
) -> int:
"""Delete tasks beyond the configured retention window/count.
Computes two candidate retained sets -- the last ``max_count`` tasks
and tasks younger than ``max_age_days`` -- and keeps whichever set
is smaller (i.e. the more restrictive bound always wins).
Returns the number of tasks pruned.
"""
reference = now or datetime.now(UTC)
tasks = metadata_store.list_tasks()
keep_by_count = {task["id"] for task in tasks[: config.task_retention_max_count]}
cutoff = reference - timedelta(days=config.task_retention_max_age_days)
keep_by_age = {
task["id"] for task in tasks if _parse_timestamp(task["created_at"]) >= cutoff
}
keep_ids = keep_by_count if len(keep_by_count) <= len(keep_by_age) else keep_by_age
pruned = 0
for task in tasks:
if task["id"] not in keep_ids:
_safe_delete(timeline, metadata_store, task["id"])
pruned += 1
return pruned
def _safe_delete(
timeline: Timeline, metadata_store: TaskMetadataStore, task_id: str
) -> None:
try:
timeline.delete_task(task_id)
except Exception:
logger.debug("timeline delete failed for task %s", task_id, exc_info=True)
try:
metadata_store.delete_task(task_id)
except Exception:
logger.debug("metadata delete failed for task %s", task_id, exc_info=True)
def _parse_timestamp(value: str) -> datetime:
parsed = datetime.fromisoformat(value)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=UTC)
return parsed
@@ -0,0 +1,105 @@
"""Host-agent wiring for skill sync against the Cloud Control Plane.
Constructs the synced catalog store, the local skill store, the Cloud API
sync client, and the :class:`SkillSyncRunner`, then drives them on the host
agent's lifecycle: the runner pulls incremental per-host skill deltas into the
synced catalog (forking local overrides on revocation, design D9), and a
best-effort inventory of the agent's local skills is reported to the Cloud
(design D7). Lives in ``host_agent`` (not ``runtime``) for the same boundary
reasons as :mod:`host_agent.cloud_planner_client`.
"""
from __future__ import annotations
import logging
import threading
from host_agent.config import HostAgentConfig
log = logging.getLogger(__name__)
class HostAgentSkillSync:
"""Owns the skill stores, sync runner, and inventory reporting thread."""
def __init__(
self,
config: HostAgentConfig,
*,
poll_interval: float | None = None,
http_client=None,
) -> None:
from api.skill_sync import CloudApiSkillClient, SkillSyncRunner
from storage.local_skills import LocalSkillStore
from storage.skill_catalog import SkillCatalogStore
state_dir = config.identity_path.parent
self.config = config
self.synced_store = SkillCatalogStore(db_path=state_dir / "skills.sqlite3")
self.local_store = LocalSkillStore(db_path=state_dir / "local_skills.sqlite3")
self.client = CloudApiSkillClient(
config.control_plane_url,
host_token=config.token,
client=http_client,
)
self.runner = SkillSyncRunner(
store=self.synced_store,
client=self.client,
subscriptions=[config.host_id],
poll_interval=poll_interval or config.skill_sync_interval_seconds,
local_store=self.local_store,
)
self._inventory_stop = threading.Event()
self._inventory_thread: threading.Thread | None = None
def start(self) -> None:
"""Start the sync poll loop + periodic inventory reporting."""
self.runner.start_background()
self._inventory_stop.clear()
self._inventory_thread = threading.Thread(
target=self._report_inventory_forever, daemon=True
)
self._inventory_thread.start()
log.info("skill sync started for host %s", self.config.host_id)
def stop(self) -> None:
"""Stop the sync loop + inventory thread and close the HTTP client."""
self.runner.stop_background()
self._inventory_stop.set()
if self._inventory_thread is not None:
self._inventory_thread.join(timeout=5.0)
self._inventory_thread = None
self.client.close()
def report_inventory_once(self) -> None:
"""Report the current local-skill inventory to the Cloud (best-effort)."""
try:
inventory = [
{
"id": meta.id,
"name": meta.name,
"kind": meta.kind,
"origin": "local",
}
for meta in self.local_store.list_local()
]
for override in self.local_store.list_overrides():
inventory.append(
{
"id": override.metadata.id,
"name": override.metadata.name,
"kind": override.metadata.kind,
"origin": "cloud",
"locally_overridden": True,
}
)
self.client.report_inventory(self.config.host_id, inventory)
except Exception: # best-effort: never impair local operation
log.debug("skill inventory report failed", exc_info=True)
def _report_inventory_forever(self) -> None:
# Report once at startup, then on the sync cadence.
self.report_inventory_once()
interval = self.config.skill_sync_interval_seconds
while not self._inventory_stop.wait(interval):
self.report_inventory_once()
+117
View File
@@ -0,0 +1,117 @@
from __future__ import annotations
import threading
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
from cloud.internal_api.models import AssignmentModel
from host_agent.progress import TaskProgressSnapshot
if TYPE_CHECKING:
from collections.abc import Callable
@dataclass(frozen=True)
class _CurrentAssignment:
task_id: str
device_id: str
goal: str | None
workflow_definition_id: str | None
started_at: datetime
@dataclass(frozen=True)
class _LastHeartbeat:
ok: bool
device_count: int
at: datetime
class AgentStatusTracker:
def __init__(self, *, now: Callable[[], datetime] | None = None) -> None:
self._now = now or (lambda: datetime.now(UTC))
self._lock = threading.Lock()
self._current_assignment: _CurrentAssignment | None = None
self._last_heartbeat: _LastHeartbeat | None = None
self._host_policy: dict[str, Any] | None = None
self._latest_progress: TaskProgressSnapshot | None = None
def mark_assignment_started(self, assignment: AssignmentModel) -> None:
with self._lock:
self._current_assignment = _CurrentAssignment(
task_id=assignment.task_id,
device_id=assignment.device_id,
goal=assignment.goal,
workflow_definition_id=assignment.workflow_definition_id,
started_at=self._now(),
)
def mark_assignment_finished(self) -> None:
with self._lock:
self._current_assignment = None
self._latest_progress = None
def set_latest_progress(self, snapshot: TaskProgressSnapshot | None) -> None:
with self._lock:
self._latest_progress = snapshot
def mark_heartbeat(self, *, ok: bool, device_count: int) -> None:
with self._lock:
self._last_heartbeat = _LastHeartbeat(
ok=ok,
device_count=device_count,
at=self._now(),
)
def mark_host_policy(self, policy: Any | None) -> None:
with self._lock:
self._host_policy = (
{
"revision": policy.revision,
"self_submission_enabled": policy.self_submission_enabled,
"max_active_tasks": policy.max_active_tasks,
"daily_token_budget": policy.daily_token_budget,
}
if policy is not None
else None
)
def snapshot(self) -> dict[str, Any]:
with self._lock:
current_assignment = self._current_assignment
last_heartbeat = self._last_heartbeat
progress = self._latest_progress
return {
"current_assignment": (
{
"task_id": current_assignment.task_id,
"device_id": current_assignment.device_id,
"goal": current_assignment.goal,
"workflow_definition_id": current_assignment.workflow_definition_id,
"started_at": current_assignment.started_at.isoformat(),
}
if current_assignment is not None
else None
),
"last_heartbeat": (
{
"ok": last_heartbeat.ok,
"device_count": last_heartbeat.device_count,
"at": last_heartbeat.at.isoformat(),
}
if last_heartbeat is not None
else None
),
"host_policy": self._host_policy.copy() if self._host_policy else None,
"progress": (
{
"step_index": progress.step_index,
"step_status": progress.step_status,
"summary": progress.summary,
"updated_at": progress.updated_at.isoformat(),
}
if progress is not None
else None
),
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,89 @@
from __future__ import annotations
import hmac
import secrets
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from threading import Lock
from host_agent.local_account import LocalAccountStore
SESSION_TOKEN_BYTES = 32
CSRF_TOKEN_BYTES = 32
@dataclass(frozen=True)
class SessionState:
username: str
csrf_token: str
expires_at: datetime
class SessionManager:
def __init__(
self,
*,
ttl_seconds: float,
now: Callable[[], datetime] | None = None,
) -> None:
self._ttl = timedelta(seconds=ttl_seconds)
self._now = now or (lambda: datetime.now(UTC))
self._lock = Lock()
self._sessions: dict[str, SessionState] = {}
def create_session(self, username: str) -> tuple[str, str]:
session_token = secrets.token_urlsafe(SESSION_TOKEN_BYTES)
csrf_token = secrets.token_urlsafe(CSRF_TOKEN_BYTES)
state = SessionState(
username=username,
csrf_token=csrf_token,
expires_at=self._now() + self._ttl,
)
with self._lock:
self._sessions[session_token] = state
return session_token, csrf_token
def validate(self, session_token: str) -> SessionState | None:
now = self._now()
with self._lock:
state = self._sessions.get(session_token)
if state is None:
return None
if state.expires_at <= now:
del self._sessions[session_token]
return None
renewed = SessionState(
username=state.username,
csrf_token=state.csrf_token,
expires_at=now + self._ttl,
)
self._sessions[session_token] = renewed
return renewed
def validate_csrf(self, session_token: str, csrf_token: str) -> bool:
state = self.validate(session_token)
if state is None:
return False
return hmac.compare_digest(state.csrf_token, csrf_token)
def invalidate(self, session_token: str) -> None:
with self._lock:
self._sessions.pop(session_token, None)
def attempt_login(store: LocalAccountStore, *, username: str, password: str) -> bool:
account = store.load()
if account is None or account.username != username:
return False
return store.verify(account, password)
def change_password(
store: LocalAccountStore, *, current_password: str, new_password: str
) -> bool:
account = store.load()
if account is None or not store.verify(account, current_password):
return False
store.create(account.username, new_password)
return True
@@ -0,0 +1,255 @@
"""FastMCP server builder for the host-agent MCP endpoint.
Wraps ``api.mcp.tool_handlers(manager=...)`` with:
- Cloud-busy and MCP-busy checks (per-device, fail-fast on conflict).
- Lazy session-level device lock acquire / renew.
- Display-status mapping for ``list_devices`` / ``device_status`` so
connected-but-idle devices don't appear "busy" (which they do at the
``DeviceManager`` layer because an Appium/WDA session is open).
The builder returns a ``FastMCP`` instance. The caller
(``create_console_app``) is responsible for wrapping it in
``BearerAuthMiddleware`` and mounting at ``/mcp``.
"""
from __future__ import annotations
import contextvars
from collections.abc import Callable
from typing import Any
from device.manager import DeviceManager
from host_agent.mcp_lock import McpBusyTracker
from host_agent.status import AgentStatusTracker
from mcp.server.fastmcp import Context, FastMCP
# Tool names that don't target a specific device — skip busy check.
_NON_DEVICE_TOOLS = frozenset({"list_devices", "device_status"})
# Tools that report status and should use the display-status mapping.
_STATUS_TOOLS = frozenset({"list_devices", "device_status"})
# Name of the wrapper kwarg FastMCP injects the live ``Context`` into.
# We set ``tool.context_kwarg = _CONTEXT_KWARG`` after swapping the tool's
# ``fn`` (see ``build_mcp_server``) so FastMCP passes ``ctx`` into our
# wrapper alongside the validated arguments.
_CONTEXT_KWARG = "ctx"
class McpDeviceBusyError(Exception):
"""Raised by the wrapper when the target device is held by the cloud
assignment path or another MCP session."""
def __init__(self, device_id: str, busy_owner: str) -> None:
super().__init__(f"device {device_id} is busy (held by {busy_owner})")
self.device_id = device_id
self.busy_owner = busy_owner
class FastMcpSdkIncompatibilityError(RuntimeError):
"""Raised when the FastMCP SDK layout diverges from what this module
expects (e.g. ``Tool.fn`` rename or ``Tool.context_kwarg`` removal)."""
# contextvars fallback used by tests and any call that originates outside a
# live FastMCP request lifecycle. Production handlers run inside an MCP
# request whose context exposes ``request_id`` and the underlying
# ``session``; ``_current_session_id`` reads from that context first and
# falls back to this ContextVar.
_TEST_SESSION_ID: contextvars.ContextVar[str] = contextvars.ContextVar(
"_TEST_SESSION_ID", default=""
)
def _current_session_id(ctx: Context | None = None) -> str:
"""Extract a stable per-MCP-session identifier from the live context.
The mcp SDK 1.28.1 ``Context`` exposes ``session`` (a long-lived
``ServerSession`` instance per Streamable HTTP session). Its Python
object identity (``id(ctx.session)``) is stable across every tool call
the same client makes within that session, which is exactly the
identity the busy tracker needs to renew leases.
Falls back to ``_TEST_SESSION_ID`` when no Context is supplied (i.e.
when invoked outside a FastMCP request lifecycle, as ``_call_tool_sync``
does in tests).
"""
if ctx is not None:
session_obj = getattr(ctx, "session", None)
if session_obj is not None:
return f"mcp_session:{id(session_obj)}"
return _TEST_SESSION_ID.get("")
def build_mcp_server(
*,
manager: DeviceManager,
mcp_busy_tracker: McpBusyTracker,
status_tracker: AgentStatusTracker,
) -> FastMCP:
"""Construct the FastMCP server wrapping ``tool_handlers``."""
# Imported lazily to keep the package import graph flat.
from api.mcp import tool_handlers
handlers = tool_handlers(manager=manager)
server = FastMCP("apex-host-agent")
for tool_name, raw_handler in handlers.items():
wrapped = _wrap_tool(
tool_name,
raw_handler,
mcp_busy_tracker=mcp_busy_tracker,
status_tracker=status_tracker,
)
# Register the raw handler so FastMCP captures its signature (the
# MCP wire schema is derived from the function signature). Then
# swap ``tool.fn`` for our busy-check / status-mapping wrapper.
# Using ``*args, **kwargs`` directly breaks the schema, so we have
# to keep the signature and only replace the underlying callable.
server._tool_manager.add_tool( # type: ignore[attr-defined]
raw_handler, name=tool_name
)
try:
tool = server._tool_manager._tools[tool_name] # type: ignore[attr-defined]
tool.fn = wrapped
# FastMCP injects the live Context into the kwarg named by
# ``tool.context_kwarg``. The raw handler doesn't declare one,
# so the cached value is None; we override it so the wrapper
# receives the Context via its ``ctx`` kwarg.
tool.context_kwarg = _CONTEXT_KWARG
except AttributeError as exc:
raise FastMcpSdkIncompatibilityError(
"FastMCP SDK layout changed: cannot swap Tool.fn or set "
f"context_kwarg (tool={tool_name!r}). Underlying error: {exc}"
) from exc
return server
def _wrap_tool(
tool_name: str,
handler: Callable[..., Any],
*,
mcp_busy_tracker: McpBusyTracker,
status_tracker: AgentStatusTracker,
) -> Callable[..., Any]:
def wrapped(*args: Any, **kwargs: Any) -> Any:
ctx = kwargs.pop(_CONTEXT_KWARG, None)
session_id = _current_session_id(ctx)
device_id = kwargs.get("device_id")
if tool_name in _STATUS_TOOLS:
return _with_display_status(handler, status_tracker, *args, **kwargs)
if device_id is not None and tool_name not in _NON_DEVICE_TOOLS:
_check_and_acquire(device_id, session_id, mcp_busy_tracker, status_tracker)
return handler(*args, **kwargs)
return wrapped
def _check_and_acquire(
device_id: str,
session_id: str,
mcp_busy_tracker: McpBusyTracker,
status_tracker: AgentStatusTracker,
) -> None:
cloud_busy = _cloud_busy_device_id(status_tracker)
if cloud_busy == device_id:
raise McpDeviceBusyError(device_id, "cloud_assignment")
if device_id in mcp_busy_tracker.busy_device_ids():
existing = next(
(
lease
for lease in mcp_busy_tracker.snapshot()
if lease.device_id == device_id
),
None,
)
if existing is not None and existing.session_id != session_id:
prefix = existing.session_id[:8]
raise McpDeviceBusyError(device_id, f"mcp_session:{prefix}")
if not mcp_busy_tracker.acquire(device_id, session_id):
# Race: someone else got it between check and acquire.
raise McpDeviceBusyError(device_id, "another_session")
mcp_busy_tracker.renew(device_id, session_id)
def _cloud_busy_device_id(status_tracker: AgentStatusTracker) -> str | None:
"""Return the device_id currently bound to the cloud assignment, if any."""
snap = status_tracker.snapshot()
current = snap.get("current_assignment")
if not isinstance(current, dict):
return None
device_id = current.get("device_id")
return device_id if isinstance(device_id, str) else None
def _with_display_status(
handler: Callable[..., Any],
status_tracker: AgentStatusTracker,
*args: Any,
**kwargs: Any,
) -> Any:
busy_device_id = _cloud_busy_device_id(status_tracker)
result = handler(*args, **kwargs)
if isinstance(result, list):
for item in result:
if isinstance(item, dict) and "status" in item:
item["status"] = _display_status(
item["status"], item.get("id"), busy_device_id
)
return result
if isinstance(result, dict) and "status" in result:
result["status"] = _display_status(
result["status"], result.get("device_id"), busy_device_id
)
return result
def _display_status(raw: str, device_id: Any, busy_device_id: str | None) -> str:
"""Mirror ``host_agent.web.app._device_display_status`` semantics.
A device that's locally "busy" because it's connected-but-idle reports
"connected" instead, unless it's the device currently running a cloud
assignment (in which case "busy" is the truthful status).
"""
if raw == "busy" and device_id != busy_device_id:
return "connected"
return raw
def _call_tool_sync(
server: FastMCP,
tool_name: str,
arguments: dict[str, Any],
*,
session_id: str,
) -> Any:
"""Test helper: invoke a registered tool synchronously with a forced
``session_id``. Bypasses the HTTP/MCP transport layer (and the live
FastMCP Context) so tests don't need an MCP client.
Walks FastMCP's tool registry (``_tool_manager._tools[tool_name].fn``) —
the exact attribute path follows mcp SDK 1.28.1's
``ToolManager._tools`` layout.
"""
token = _TEST_SESSION_ID.set(session_id)
try:
manager = getattr(server, "_tool_manager", None)
if manager is None:
raise KeyError(f"tool {tool_name!r} not registered (no tool manager)")
registry = getattr(manager, "_tools", None) or getattr(manager, "tools", None)
if isinstance(registry, dict):
tool = registry.get(tool_name)
else:
tool = manager.get_tool(tool_name) # type: ignore[union-attr]
if tool is None:
raise KeyError(f"tool {tool_name!r} not registered")
# FastMCP Tool wraps a callable; our wrappers are sync, so unwrap.
fn = getattr(tool, "fn", None) or getattr(tool, "func", None)
if fn is None:
raise KeyError(f"tool {tool_name!r} has no callable")
return fn(**arguments)
finally:
_TEST_SESSION_ID.reset(token)
@@ -0,0 +1,36 @@
"""Bearer-token auth middleware for the MCP sub-app.
Mounted on the FastMCP ``streamable_http_app()`` (NOT the console FastAPI),
so cookie-session auth on console routes is unaffected.
"""
from __future__ import annotations
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from host_agent.mcp_token import McpTokenStore
class BearerAuthMiddleware(BaseHTTPMiddleware):
def __init__(self, app, token_store: McpTokenStore) -> None:
super().__init__(app)
self._store = token_store
async def dispatch(self, request: Request, call_next) -> Response: # type: ignore[no-untyped-def]
header = request.headers.get("Authorization")
if not header or not header.lower().startswith("bearer "):
return _unauthorized()
presented = header.split(" ", 1)[1].strip()
if not self._store.verify(presented):
return _unauthorized()
return await call_next(request)
def _unauthorized() -> JSONResponse:
return JSONResponse(
status_code=401,
content={"error": "invalid token"},
headers={"WWW-Authenticate": "Bearer"},
)
@@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block body %}
<h1>Account</h1>
{% if message %}
<p class="notice">{{ message }}</p>
{% endif %}
{% if error %}
<p class="error">{{ error }}</p>
{% endif %}
<form method="post" action="/account">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Current password <input type="password" name="current_password" required></label><br>
<label>New password <input type="password" name="new_password" required></label><br>
<label>Confirm new password <input type="password" name="confirm_password" required></label><br>
<button type="submit">Change password</button>
</form>
{% endblock %}
@@ -0,0 +1,41 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>{{ title }}</title>
<style>{% block styles %}body { font-family: system-ui, sans-serif; margin: 0; background: #f5f5f5; color: #222; }
header { background: #20303f; color: #fff; padding: 0.75rem 1.5rem; }
header nav { display: inline; margin-left: 1.5rem; }
header nav a, header nav form { display: inline-block; margin-right: 1rem; }
header a { color: #fff; text-decoration: none; }
header button { background: none; border: none; color: #fff; text-decoration: underline; cursor: pointer; padding: 0; font: inherit; }
main { padding: 1.5rem; max-width: 960px; margin: 0 auto; }
table { border-collapse: collapse; width: 100%; margin-bottom: 1rem; background: #fff; }
th, td { border: 1px solid #ccc; padding: 0.4rem 0.6rem; text-align: left; }
form.inline { display: inline; margin: 0; }
.error { color: #b00020; }
.notice { color: #1b5e20; }{% endblock %}</style>
</head>
<body>
<header>
<strong>Host Agent Console</strong>
{% block nav %}{% if session %}
<nav>
<a href="/">Status</a>
<a href="/devices">Devices</a>
<a href="/tasks">Tasks</a>
<a href="/account">Account</a>
<a href="/history">History</a>
<a href="/conversations">Conversations</a>
<form class="inline" method="post" action="/logout">
<input type="hidden" name="csrf_token" value="{{ session.csrf_token }}">
<button type="submit">Logout</button>
</form>
</nav>
{% endif %}{% endblock %}
</header>
<main>
{% block body %}{% endblock %}
</main>
</body>
</html>
@@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block body %}
<h1>Conversations</h1>
{% if not events %}<p>No conversation activity recorded yet.</p>{% endif %}
{% for event in events %}
<article>
<h2>{{ event["event_type"] }} <small>{{ event["occurred_at"] }}</small></h2>
{% if event.get("content") %}<pre>{{ event["content"] }}</pre>{% endif %}
{% if event.get("thinking") %}<details><summary>LLM reasoning</summary><pre>{{ event["thinking"] }}</pre></details>{% endif %}
{% if event.get("tool_calls") %}<details open><summary>Tool calls</summary><pre>{{ event["tool_calls"] | tojson(indent=2) }}</pre></details>{% endif %}
{% if event.get("tool_name") %}<p><strong>{{ event["tool_name"] }}</strong></p><pre>{{ event.get("arguments") | tojson(indent=2) }}</pre><pre>{{ event.get("result") | tojson(indent=2) }}</pre>{% endif %}
</article>
{% endfor %}
{% endblock %}
@@ -0,0 +1,91 @@
{% extends "base.html" %}
{% block body %}
<h1>Status</h1>
<section>
<h2>Enrollment</h2>
<p>Host ID: {{ identity.host_id if identity else "" or "not enrolled" }}</p>
<p>Agent instance ID: {{ identity.agent_instance_id if identity else "" or "unknown" }}</p>
<p>Control plane: {{ config.control_plane_url }}</p>
</section>
<section>
<h2>Heartbeat</h2>
<p id="last-heartbeat">{{ heartbeat_text }}</p>
</section>
<section>
<h2>Cloud policy</h2>
<p id="host-policy">{{ policy_text }}</p>
</section>
<section>
<h2>Current assignment</h2>
<p id="current-assignment">{{ assignment_text }}</p>
<p id="current-progress">{{ progress_text }}</p>
</section>
<section>
<h2>MCP</h2>
<table>
<tbody>
<tr>
<td>MCP</td>
<td>
{% if mcp_endpoint %}
endpoint <code>{{ mcp_endpoint }}</code>;
{% if mcp_busy_devices %}busy: {{ mcp_busy_devices|join(", ") }}{% else %}idle{% endif %}
{% else %}
not configured
{% endif %}
</td>
</tr>
</tbody>
</table>
</section>
<section>
<h2>Devices</h2>
<table>
<thead><tr><th>ID</th><th>Name</th><th>Driver</th><th>Status</th></tr></thead>
<tbody id="device-status-body">{% for device in devices %}<tr><td>{{ device.id }}</td><td>{{ device.name or "" }}</td><td>{{ device.driver_type }}</td><td>{{ device.display_status }}</td></tr>{% endfor %}</tbody>
</table>
</section>
{% raw %}<script>
(function () {
function render(data) {
var hb = data.status.last_heartbeat;
document.getElementById("last-heartbeat").textContent = hb
? (hb.ok ? "ok" : "failed") + " at " + hb.at + " (" + hb.device_count + " devices)"
: "never";
var current = data.status.current_assignment;
document.getElementById("current-assignment").textContent = current
? current.task_id + " on " + current.device_id + " (started " + current.started_at + ")"
: "none";
var progress = data.status.progress;
document.getElementById("current-progress").textContent = progress
? "step " + progress.step_index + " \u2014 " + progress.step_status + ": " + progress.summary
: "";
var policy = data.status.host_policy;
document.getElementById("host-policy").textContent = policy
? "revision " + policy.revision + "; self-submission "
+ (policy.self_submission_enabled ? "enabled" : "disabled")
+ "; max active tasks " + (policy.max_active_tasks || "unlimited")
+ "; daily token budget " + (policy.daily_token_budget || "unmetered")
: "no Cloud policy cached";
var body = document.getElementById("device-status-body");
body.innerHTML = "";
data.devices.forEach(function (device) {
var row = document.createElement("tr");
["id", "name", "driver_type", "status"].forEach(function (key) {
var cell = document.createElement("td");
cell.textContent = device[key] || "";
row.appendChild(cell);
});
body.appendChild(row);
});
}
function poll() {
fetch("/api/status", { credentials: "same-origin" })
.then(function (response) { return response.ok ? response.json() : null; })
.then(function (data) { if (data) render(data); })
.catch(function () {});
}
setInterval(poll, 5000);
})();
</script>{% endraw %}
{% endblock %}
@@ -0,0 +1,203 @@
{% extends "base.html" %}
{% block body %}
<h1>Devices</h1>
{% if error %}
<p class="error">{{ error }}</p>
{% endif %}
<table>
<thead><tr><th>ID</th><th>Name</th><th>Driver</th><th>Cloud ID</th><th>Screenshot</th><th></th></tr></thead>
<tbody>{% for device in devices %}
<tr>
<td>{{ device["device_id"] }}</td>
<td>{{ device["name"] or "" }}</td>
<td>{{ device["driver_type"] }}</td>
<td>{{ device["cloud_device_id"] or "" }}</td>
<td>
<button type="button" class="screenshot-button" data-device-id="{{ device["device_id"] }}">Get screenshot</button>
<div class="screenshot-preview" data-screenshot-preview hidden>
<p class="screenshot-status" data-screenshot-status></p>
<img alt="Current screen for {{ device["device_id"] }}" data-screenshot-image hidden>
</div>
</td>
<td>
<a href="/devices?edit={{ device["device_id"] }}">Edit</a>
<form class="inline" method="post" action="/devices/remove">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="device_id" value="{{ device["device_id"] }}">
<button type="submit">Remove</button>
</form>
</td>
</tr>
{% endfor %}</tbody>
</table>
<h2>{{ "Edit device" if edit_record else "Add device" }}</h2>
<button type="button" id="discover-ios">Scan connected iPhones</button>
<p id="discovery-status" class="screenshot-status"></p>
<div id="discovered-devices"></div>
<form method="post" action="/devices/save">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Device ID <input type="text" name="device_id" value="{{ edit_record["device_id"] if edit_record else "" }}" required></label><br>
<label>Name <input type="text" name="name" value="{{ edit_record["name"] if edit_record else "" }}"></label><br>
<label>Platform and protocol
<select name="driver_type" id="driver-type" required>
<option value="wda"{% if not edit_record or edit_record["driver_type"] == "wda" %} selected{% endif %}>iOS - Appium / XCUITest (WDA)</option>
<option value="uiautomator2"{% if edit_record and edit_record["driver_type"] == "uiautomator2" %} selected{% endif %}>Android - Appium / UiAutomator2</option>
</select>
</label><br>
<label>Appium server URL <input type="url" name="server_url" value="{{ edit_record["connection_info"].get("server_url", "http://127.0.0.1:4723") if edit_record else "http://127.0.0.1:4723" }}" required></label><br>
<label>Device UDID <input type="text" name="udid" value="{{ edit_record["connection_info"].get("udid", "") if edit_record else "" }}" required></label><br>
<label>Device name <input type="text" name="device_name" value="{{ edit_record["connection_info"].get("device_name", "") if edit_record else "" }}" placeholder="iPhone"></label><br>
<label class="ios-setting">WDA local port <input type="number" min="1" max="65535" name="wda_local_port" value="{{ edit_record["connection_info"].get("wda_local_port", "") if edit_record else "" }}" placeholder="8100"></label>
<label class="android-setting">UiAutomator2 system port <input type="number" min="1" max="65535" name="system_port" value="{{ edit_record["connection_info"].get("system_port", "") if edit_record else "" }}" placeholder="8200"></label><br>
<details>
<summary>Advanced connection capabilities (JSON)</summary>
<textarea name="connection_info" rows="4" cols="60">{{ connection_info_json }}</textarea>
</details>
<p id="connection-test-status" class="screenshot-status"></p>
<button type="button" id="test-connection">Test connection</button>
<button type="submit">Save</button>
</form>
<style>
.screenshot-preview { margin-top: 0.5rem; max-width: 260px; }
.screenshot-preview img { display: block; width: 100%; height: auto; border: 1px solid #c8d0d6; }
.screenshot-status { margin: 0 0 0.35rem; color: #5e6b73; }
.screenshot-status.error { color: #b00020; }
</style>
<script>
(() => {
const driverType = document.getElementById("driver-type");
const syncPlatformFields = () => {
const ios = driverType.value === "wda";
document.querySelectorAll(".ios-setting").forEach((el) => { el.hidden = !ios; });
document.querySelectorAll(".android-setting").forEach((el) => { el.hidden = ios; });
};
driverType.addEventListener("change", syncPlatformFields);
syncPlatformFields();
const csrfInput = document.querySelector('input[name="csrf_token"]');
const csrfToken = csrfInput ? csrfInput.value : "";
const form = document.querySelector('form[action="/devices/save"]');
const discoverButton = document.getElementById("discover-ios");
const discoveryStatus = document.getElementById("discovery-status");
const discoveredDevices = document.getElementById("discovered-devices");
discoverButton.addEventListener("click", async () => {
discoverButton.disabled = true;
discoveryStatus.classList.remove("error");
discoveryStatus.textContent = "Scanning...";
discoveredDevices.replaceChildren();
try {
const response = await fetch("/api/devices/discover-ios");
const payload = await response.json();
if (!response.ok) throw new Error(payload.detail || "Discovery failed.");
discoveryStatus.textContent = payload.devices.length
? `Found ${payload.devices.length} connected iPhone(s).`
: "No connected, paired iPhones found.";
payload.devices.forEach((device, index) => {
const row = document.createElement("p");
const description = document.createElement("span");
description.textContent = `${device.name} - ${device.model} - iOS ${device.os_version} (${device.transport}) `;
const select = document.createElement("button");
select.type = "button";
select.textContent = device.configured ? "Already added" : "Use this iPhone";
select.disabled = device.configured;
select.addEventListener("click", () => {
form.elements.driver_type.value = "wda";
form.elements.udid.value = device.udid;
form.elements.device_name.value = device.name;
form.elements.wda_local_port.value = device.suggested_wda_port;
form.elements.device_id.value ||= `ios-phone-${index + 1}`;
form.elements.name.value ||= device.name;
const advanced = JSON.parse(form.elements.connection_info.value || "{}");
advanced.mjpegServerPort = device.suggested_mjpeg_port;
advanced.derivedDataPath = `/tmp/wda-${device.udid}`;
form.elements.connection_info.value = JSON.stringify(advanced, null, 2);
syncPlatformFields();
form.scrollIntoView({ behavior: "smooth", block: "start" });
});
row.append(description, select);
discoveredDevices.append(row);
});
} catch (error) {
discoveryStatus.classList.add("error");
discoveryStatus.textContent = error.message || "Discovery failed.";
} finally {
discoverButton.disabled = false;
}
});
const testButton = document.getElementById("test-connection");
const testStatus = document.getElementById("connection-test-status");
const connectionInfo = () => {
const data = new FormData(form);
let info = {};
const advanced = String(data.get("connection_info") || "{}");
info = JSON.parse(advanced);
["server_url", "udid", "device_name"].forEach((key) => {
const value = String(data.get(key) || "").trim();
if (value) info[key] = value;
});
const portKey = data.get("driver_type") === "wda" ? "wda_local_port" : "system_port";
const port = String(data.get(portKey) || "").trim();
if (port) info[portKey] = Number(port);
return { driver_type: data.get("driver_type"), connection_info: info };
};
testButton.addEventListener("click", async () => {
testButton.disabled = true;
testStatus.classList.remove("error");
testStatus.textContent = "Testing connection...";
try {
const response = await fetch("/api/devices/test-connection", {
method: "POST",
headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken },
body: JSON.stringify(connectionInfo()),
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.detail || "Connection test failed.");
testStatus.textContent = "Connection successful.";
} catch (error) {
testStatus.classList.add("error");
testStatus.textContent = error.message || "Connection test failed.";
} finally {
testButton.disabled = false;
}
});
document.querySelectorAll(".screenshot-button").forEach((button) => {
button.addEventListener("click", async () => {
const deviceId = button.dataset.deviceId;
const preview = button.parentElement.querySelector("[data-screenshot-preview]");
const status = preview.querySelector("[data-screenshot-status]");
const image = preview.querySelector("[data-screenshot-image]");
const previousUrl = image.dataset.objectUrl;
if (previousUrl) URL.revokeObjectURL(previousUrl);
button.disabled = true;
preview.hidden = false;
image.hidden = true;
status.classList.remove("error");
status.textContent = "Capturing...";
try {
const response = await fetch(
"/api/devices/" + encodeURIComponent(deviceId) + "/screenshot",
{ method: "POST", headers: { "X-CSRF-Token": csrfToken } }
);
if (!response.ok) {
let detail = "Screenshot failed.";
try {
const payload = await response.json();
if (payload.detail) detail = payload.detail;
} catch (_) {}
throw new Error(detail);
}
const objectUrl = URL.createObjectURL(await response.blob());
image.src = objectUrl;
image.dataset.objectUrl = objectUrl;
image.hidden = false;
status.textContent = "Captured.";
} catch (error) {
status.classList.add("error");
status.textContent = error.message || "Screenshot failed.";
} finally {
button.disabled = false;
}
});
});
})();
</script>
{% endblock %}
@@ -0,0 +1,8 @@
{% extends "base.html" %}
{% block body %}
<h1>History</h1>
<table>
<thead><tr><th>Time</th><th>Kind</th><th>Summary</th></tr></thead>
<tbody>{% for entry in entries %}<tr><td>{{ entry["occurred_at"] }}</td><td>{{ entry["kind"] }}</td><td>{{ entry["summary"] }}</td></tr>{% endfor %}</tbody>
</table>
{% endblock %}
@@ -0,0 +1,18 @@
{% extends "base.html" %}
{% block nav %}{% endblock %}
{% block body %}
<h1>Login</h1>
{% if not account %}
<p>No local account exists yet. Run <code>device-host-agent setup</code>
on this machine to create one before logging in to the console.</p>
{% else %}
{% if error %}
<p class="error">{{ error }}</p>
{% endif %}
<form method="post" action="/login">
<label>Username <input type="text" name="username" required></label><br>
<label>Password <input type="password" name="password" required></label><br>
<button type="submit">Log in</button>
</form>
{% endif %}
{% endblock %}
@@ -0,0 +1,259 @@
{% extends "base.html" %}
{% block styles %}
{{ super() }}
.task-back { margin-top: 0; }
.timeline-step { border: 1px solid #c8d0d6; background: #fff; padding: 1rem; margin-bottom: 1rem; }
.step-heading { display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; align-items: baseline; margin-bottom: 0.75rem; }
.step-heading p { margin: 0; color: #4d5a63; }
.evidence-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; margin-bottom: 1rem; }
.evidence-pane { margin: 0; min-width: 0; }
.evidence-pane h3 { font-size: 1rem; margin: 0 0 0.35rem; }
.screenshot-frame { min-height: 6rem; border: 1px solid #c8d0d6; background: #f8fafb; display: grid; place-items: center; overflow: hidden; color: #5e6b73; position: relative; }
.screenshot-frame img { display: block; width: 100%; height: auto; }
.overlay-svg { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; }
.overlay-svg .overlay-box { fill: none; stroke-width: 2; vector-effect: non-scaling-stroke; }
.overlay-svg .overlay-box.source-ui { stroke: #1e88e5; }
.overlay-svg .overlay-box.source-ocr { stroke: #fb8c00; }
.overlay-svg .overlay-boxes { display: none; }
.overlay-svg.show-boxes .overlay-boxes { display: inline; }
.overlay-svg .action-tap { fill: #e53935; fill-opacity: 0.25; stroke: #e53935; stroke-width: 2; vector-effect: non-scaling-stroke; }
.overlay-svg .action-swipe-line { stroke: #e53935; stroke-width: 3; vector-effect: non-scaling-stroke; fill: none; }
.overlay-svg .action-swipe-dot { fill: #e53935; }
.overlay-toggle { margin-bottom: 1rem; }
.step-details { margin-top: 0.75rem; }
.step-details summary { cursor: pointer; font-weight: 600; }
.step-details pre { white-space: pre-wrap; overflow-wrap: anywhere; margin: 0.65rem 0 0; padding: 0.65rem; border: 1px solid #d5dce0; background: #f8fafb; }
.operation-grid { display: grid; grid-template-columns: minmax(7rem, 0.4fr) minmax(0, 1fr); gap: 0.35rem 0.75rem; margin: 0.65rem 0 0; }
.operation-grid dt { font-weight: 600; }
.operation-grid dd { margin: 0; overflow-wrap: anywhere; }
.observation-list, .ui-tree-nodes { margin: 0.65rem 0 0; padding-left: 1.25rem; }
.observation-list li, .ui-tree-nodes li { margin-bottom: 0.45rem; overflow-wrap: anywhere; }
.observation-list span, .ui-tree-nodes span { color: #4d5a63; margin-left: 0.4rem; }
.ui-tree-nodes code { overflow-wrap: anywhere; }
@media (max-width: 640px) {
.evidence-grid { grid-template-columns: minmax(0, 1fr); }
.timeline-step { padding: 0.75rem; }
}
{% endblock %}
{% block body %}
<p class="task-back"><a href="/tasks">&larr; Back to executions</a></p>
<h1>Execution</h1>
<table>
<thead><tr><th>Field</th><th>Value</th></tr></thead>
<tbody>{% for row in task_rows %}<tr><td>{{ row[0] }}</td><td>{{ row[1] }}</td></tr>{% endfor %}</tbody>
</table>
{% if cancel_notice %}<p class="notice" id="cancel-notice">{{ cancel_notice }}</p>{% endif %}
{% if cancel_error %}<p class="error" id="cancel-error">{{ cancel_error }}</p>{% endif %}
{% if can_cancel %}
<form method="post" action="/tasks/{{ task['id'] }}/cancel">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<p><button type="submit">Cancel task</button></p>
</form>
{% endif %}
<h2>Timeline</h2>
{% if not timeline_steps %}
<p>No timeline records.</p>
{% else %}
<p class="overlay-toggle">
<label><input type="checkbox" id="overlay-boxes-toggle"> Show OCR/UI-tree bounding boxes on before-action screenshots</label>
</p>
{% endif %}
{% for step in timeline_steps %}
<section class="timeline-step">
<div class="step-heading">
<strong>Step {{ step.index }}</strong>
<p>{{ step.timestamp }}</p>
</div>
<div class="evidence-grid">
<figure class="evidence-pane">
<h3>Before action</h3>
<div class="screenshot-frame">
{% if step.before_screenshot_src %}
<img src="{{ step.before_screenshot_src }}" alt="Screenshot before action">
{% if step.overlay.width and step.overlay.height %}
<svg class="overlay-svg" data-step-overlay
viewBox="0 0 {{ step.overlay.width }} {{ step.overlay.height }}"
preserveAspectRatio="none"></svg>
<script type="application/json" class="step-overlay-data">{{ {"overlay": step.overlay, "tool_call": step.tool_call} | tojson }}</script>
{% endif %}
{% else %}
<span>No screenshot</span>
{% endif %}
</div>
</figure>
<figure class="evidence-pane">
<h3>After action</h3>
<div class="screenshot-frame">
{% if step.after_screenshot_src %}
<img src="{{ step.after_screenshot_src }}" alt="Screenshot after action">
{% else %}
<span>No screenshot</span>
{% endif %}
</div>
</figure>
</div>
<details class="step-details" open>
<summary>Operation</summary>
<dl class="operation-grid">
<dt>Action</dt><dd>{{ step.tool_call.get("action") or "-" }}</dd>
<dt>Description</dt><dd>{{ step.tool_call.get("description") or "-" }}</dd>
</dl>
<pre>{{ step.tool_call | tojson(indent=2) }}</pre>
</details>
<details class="step-details">
<summary>Result</summary>
<pre>{{ step.result | tojson(indent=2) }}</pre>
</details>
{% if step.prompt %}
<details class="step-details">
<summary>Planner prompt</summary>
<pre>{{ step.prompt }}</pre>
</details>
{% endif %}
{% if step.ocr_results %}
<details class="step-details">
<summary>OCR results ({{ step.ocr_results|length }})</summary>
<ul class="observation-list">
{% for ocr in step.ocr_results %}
<li>
<strong>{{ ocr.get("text") or "-" }}</strong>
<span>{{ ocr.get("bounds") | tojson }}</span>
{% if ocr.get("confidence") is not none %}<span>confidence {{ "%.3f" | format(ocr.get("confidence")) }}</span>{% endif %}
</li>
{% endfor %}
</ul>
</details>
{% endif %}
{% if step.ui_tree_nodes %}
<details class="step-details">
<summary>UI tree ({{ step.ui_tree_nodes|length }} normalized nodes)</summary>
<ul class="ui-tree-nodes">
{% for node in step.ui_tree_nodes %}
<li>
<strong>{{ node.get("type") or "unknown" }}</strong>
<span>{{ node.get("text") or node.get("id") or "-" }}</span>
<code>{{ node.get("bounds") | tojson }}</code>
{% if node.get("confidence") is not none %}<span>confidence {{ "%.3f" | format(node.get("confidence")) }}</span>{% endif %}
</li>
{% endfor %}
</ul>
</details>
{% endif %}
</section>
{% endfor %}
<script>
(function () {
const SVG_NS = "http://www.w3.org/2000/svg";
function rect(bounds, className) {
const el = document.createElementNS(SVG_NS, "rect");
el.setAttribute("x", bounds.x);
el.setAttribute("y", bounds.y);
el.setAttribute("width", bounds.width);
el.setAttribute("height", bounds.height);
el.setAttribute("class", className);
return el;
}
function buildBoxesGroup(elements) {
const group = document.createElementNS(SVG_NS, "g");
group.setAttribute("class", "overlay-boxes");
(elements || []).forEach(function (element) {
const bounds = element.bounds;
if (!bounds) return;
const source = element.source === "ui" ? "source-ui" : "source-ocr";
const box = rect(bounds, "overlay-box " + source);
const label = element.text || element.id || "";
if (label) {
const title = document.createElementNS(SVG_NS, "title");
title.textContent = label;
box.appendChild(title);
}
group.appendChild(box);
});
return group;
}
function buildActionGroup(toolCall) {
const group = document.createElementNS(SVG_NS, "g");
group.setAttribute("class", "overlay-action");
if (!toolCall) return group;
const action = toolCall.action;
const args = toolCall.args || {};
if (action === "tap" && isFinite(args.x) && isFinite(args.y)) {
const circle = document.createElementNS(SVG_NS, "circle");
circle.setAttribute("cx", args.x);
circle.setAttribute("cy", args.y);
circle.setAttribute("r", 14);
circle.setAttribute("class", "action-tap");
group.appendChild(circle);
} else if (
action === "swipe" &&
isFinite(args.start_x) &&
isFinite(args.start_y) &&
isFinite(args.end_x) &&
isFinite(args.end_y)
) {
const line = document.createElementNS(SVG_NS, "line");
line.setAttribute("x1", args.start_x);
line.setAttribute("y1", args.start_y);
line.setAttribute("x2", args.end_x);
line.setAttribute("y2", args.end_y);
line.setAttribute("class", "action-swipe-line");
group.appendChild(line);
const dot = document.createElementNS(SVG_NS, "circle");
dot.setAttribute("r", 8);
dot.setAttribute("class", "action-swipe-dot");
const motion = document.createElementNS(SVG_NS, "animateMotion");
motion.setAttribute("dur", "1.2s");
motion.setAttribute("repeatCount", "indefinite");
motion.setAttribute(
"path",
"M" + args.start_x + "," + args.start_y + " L" + args.end_x + "," + args.end_y
);
dot.appendChild(motion);
group.appendChild(dot);
}
return group;
}
document.querySelectorAll("svg.overlay-svg[data-step-overlay]").forEach(function (svg) {
const dataScript = svg.nextElementSibling;
if (!dataScript || !dataScript.classList.contains("step-overlay-data")) return;
let payload;
try {
payload = JSON.parse(dataScript.textContent);
} catch (err) {
return;
}
svg.appendChild(buildBoxesGroup(payload.overlay && payload.overlay.elements));
svg.appendChild(buildActionGroup(payload.tool_call));
});
const toggle = document.getElementById("overlay-boxes-toggle");
if (toggle) {
const STORAGE_KEY = "task-detail-overlay-boxes-visible";
// 页面加载时恢复之前的选择
const savedState = localStorage.getItem(STORAGE_KEY);
if (savedState !== null) {
const shouldShow = savedState === "true";
toggle.checked = shouldShow;
document.querySelectorAll("svg.overlay-svg").forEach(function (svg) {
svg.classList.toggle("show-boxes", shouldShow);
});
}
toggle.addEventListener("change", function () {
const isChecked = toggle.checked;
// 保存状态到 localStorage
localStorage.setItem(STORAGE_KEY, String(isChecked));
document.querySelectorAll("svg.overlay-svg").forEach(function (svg) {
svg.classList.toggle("show-boxes", isChecked);
});
});
}
})();
</script>
{% endblock %}
@@ -0,0 +1,54 @@
{% extends "base.html" %}
{% block body %}
<h1>Tasks</h1>
<section id="task-submission">
<h2>Submit task to current Host</h2>
{% if submission_available %}
<form method="post" action="/tasks/submit">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<p>
<label for="goal">Goal</label><br>
<textarea id="goal" name="goal" rows="4" cols="60" required>{{ goal_value }}</textarea>
</p>
<p>
<label for="device_id">Target device</label><br>
<select id="device_id" name="device_id">
<option value="{{ automatic_device_value }}"{% if selected_device == automatic_device_value %} selected{% endif %}>Automatic (let Host choose an eligible device)</option>
{% for device in devices %}<option value="{{ device.id }}"{% if selected_device == device.id %} selected{% endif %}>{{ device.label }}</option>{% endfor %}
</select>
</p>
{% if submission_error %}<p class="error" id="submission-error">{{ submission_error }}</p>{% endif %}
{% if submission_unknown %}<p class="error" id="submission-unknown">Submission outcome is unknown. The task may have been queued. Check the Cloud console before submitting again.</p>{% endif %}
{% if submission_notice %}<p class="notice" id="submission-notice">{{ submission_notice }}</p>{% endif %}
<p><button type="submit">Submit task</button></p>
</form>
{% else %}
<p class="error" id="submission-unavailable">Host submission client is not available yet. Wait for Host enrollment to complete, then refresh.</p>
{% endif %}
</section>
<section id="local-tasks">
<h2>Executed tasks on this Host</h2>
{% if metadata_store_missing %}
<p class="error">Task metadata store is not configured.</p>
{% elif not tasks %}
<p>No executions recorded yet.</p>
{% else %}
<table>
<thead><tr><th>Execution ID</th><th>Cloud task</th><th>Attempt</th><th>Status</th><th>Device</th><th>Created</th><th>Updated</th></tr></thead>
<tbody>{% for task in tasks %}
<tr>
<td><a href="/tasks/{{ task["id"] }}">{{ task["id"] }}</a></td>
<td>{{ task.get("source_task_id") or "" }}</td>
<td>{{ task.get("source_attempt") if task.get("source_attempt") is not none else "" }}</td>
<td>{{ task.get("status") or "" }}</td>
<td>{{ task.get("device_id") or "" }}</td>
<td>{{ task.get("created_at") or "" }}</td>
<td>{{ task.get("updated_at") or "" }}</td>
</tr>
{% endfor %}</tbody>
</table>
{% endif %}
</section>
{% endblock %}
+9 -1
View File
@@ -2,11 +2,16 @@
name = "device-host-agent"
version = "0.1.0"
description = "Outbound device-host worker for the Device Cloud Platform."
requires-python = ">=3.14"
requires-python = ">=3.13,<3.14"
dependencies = [
"device-agent-runtime==0.1.0",
"device-cloud-platform==0.1.0",
"fastapi>=0.115.0",
"filelock>=3.0",
"httpx>=0.27.0",
"jinja2>=3.1",
"mcp>=1.28,<2",
"uvicorn[standard]>=0.30.0",
]
[project.scripts]
@@ -20,6 +25,9 @@ build-backend = "setuptools.build_meta"
where = ["."]
include = ["host_agent*"]
[tool.setuptools.package-data]
"host_agent.web" = ["templates/*.html"]
[tool.uv.sources]
device-agent-runtime = { workspace = true }
device-cloud-platform = { workspace = true }
+11
View File
@@ -0,0 +1,11 @@
import pytest
@pytest.fixture(autouse=True)
def _humanize_disabled_by_default_in_tests(monkeypatch):
"""Humanize defaults ON in production; tests default it OFF so existing
exact-coordinate assertions stay deterministic. Tests that want to
exercise humanize call ``monkeypatch.setenv("APEX_HUMANIZE_ENABLED", "true")``
in their own body, which overrides this fixture (test body runs after
fixture setup)."""
monkeypatch.setenv("APEX_HUMANIZE_ENABLED", "false")
@@ -0,0 +1,43 @@
<script>
(function () {
function render(data) {
var hb = data.status.last_heartbeat;
document.getElementById("last-heartbeat").textContent = hb
? (hb.ok ? "ok" : "failed") + " at " + hb.at + " (" + hb.device_count + " devices)"
: "never";
var current = data.status.current_assignment;
document.getElementById("current-assignment").textContent = current
? current.task_id + " on " + current.device_id + " (started " + current.started_at + ")"
: "none";
var progress = data.status.progress;
document.getElementById("current-progress").textContent = progress
? "step " + progress.step_index + " \u2014 " + progress.step_status + ": " + progress.summary
: "";
var policy = data.status.host_policy;
document.getElementById("host-policy").textContent = policy
? "revision " + policy.revision + "; self-submission "
+ (policy.self_submission_enabled ? "enabled" : "disabled")
+ "; max active tasks " + (policy.max_active_tasks || "unlimited")
+ "; daily token budget " + (policy.daily_token_budget || "unmetered")
: "no Cloud policy cached";
var body = document.getElementById("device-status-body");
body.innerHTML = "";
data.devices.forEach(function (device) {
var row = document.createElement("tr");
["id", "name", "driver_type", "status"].forEach(function (key) {
var cell = document.createElement("td");
cell.textContent = device[key] || "";
row.appendChild(cell);
});
body.appendChild(row);
});
}
function poll() {
fetch("/api/status", { credentials: "same-origin" })
.then(function (response) { return response.ok ? response.json() : null; })
.then(function (data) { if (data) render(data); })
.catch(function () {});
}
setInterval(poll, 5000);
})();
</script>
@@ -0,0 +1,240 @@
"""Shared fixtures and context factories for Jinja2 template tests."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
import pytest
from host_agent.config import HostAgentConfig
from host_agent.web.app import _ENV
from host_agent.web.auth import SessionState
XSS_PROBE = "<script>alert(1)</script>"
_ESCAPED_PROBE = "&lt;script&gt;alert(1)&lt;/script&gt;"
@pytest.fixture
def env() -> Any:
"""The module-level Jinja2 Environment from host_agent.web.app."""
return _ENV
@pytest.fixture
def sample_session() -> SessionState:
"""A representative logged-in session."""
return SessionState(
username="operator",
csrf_token="test-csrf-token",
expires_at=datetime(2030, 1, 1, tzinfo=UTC),
)
@pytest.fixture
def xss_probe() -> str:
return XSS_PROBE
# ---------------------------------------------------------------------------
# Context factories
# ---------------------------------------------------------------------------
def make_login_context(
*, account: Any = None, error: str | None = None
) -> dict[str, Any]:
return {
"title": "Login",
"session": None,
"account": account,
"error": error,
}
def make_dashboard_context(
session: SessionState,
*,
devices: list[dict[str, Any]] | None = None,
identity: Any = None,
heartbeat_text: str = "never",
assignment_text: str = "none",
progress_text: str = "",
policy_text: str = "no Cloud policy cached",
config: HostAgentConfig | None = None,
) -> dict[str, Any]:
if devices is None:
devices = [
{
"id": "dev-1",
"name": "Pixel 8",
"driver_type": "wda",
"display_status": "connected",
},
{
"id": "dev-2",
"name": "iPhone 15",
"driver_type": "wda",
"display_status": "busy",
},
]
if config is None:
config = HostAgentConfig(control_plane_url="http://localhost:8080")
return {
"title": "Status",
"session": session,
"identity": identity,
"devices": devices,
"config": config,
"heartbeat_text": heartbeat_text,
"assignment_text": assignment_text,
"progress_text": progress_text,
"policy_text": policy_text,
}
def make_devices_context(
session: SessionState,
*,
devices: list[dict[str, Any]] | None = None,
edit_record: dict[str, Any] | None = None,
connection_info_json: str = "{}",
error: str | None = None,
) -> dict[str, Any]:
if devices is None:
devices = [
{
"device_id": "dev-1",
"name": "Pixel 8",
"driver_type": "wda",
"cloud_device_id": "cloud-1",
"connection_info": {"port": 8100},
},
]
if edit_record is None:
edit_record = {
"device_id": "dev-1",
"name": "Pixel 8",
"driver_type": "wda",
"connection_info": {"port": 8100},
}
connection_info_json = '{"port": 8100}'
return {
"title": "Devices",
"session": session,
"devices": devices,
"csrf_token": session.csrf_token,
"edit_record": edit_record,
"connection_info_json": connection_info_json,
"error": error,
}
def make_account_context(
session: SessionState,
*,
message: str | None = None,
error: str | None = None,
) -> dict[str, Any]:
return {
"title": "Account",
"session": session,
"csrf_token": session.csrf_token,
"message": message,
"error": error,
}
def make_history_context(
session: SessionState,
*,
entries: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
if entries is None:
entries = [
{
"occurred_at": "2026-01-01T00:00:00Z",
"kind": "assignment",
"summary": "Task abc-123 started on dev-1",
},
]
return {
"title": "History",
"session": session,
"entries": entries,
}
def make_tasks_list_context(
session: SessionState,
*,
tasks: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
if tasks is None:
tasks = [
{
"id": "task-001",
"status": "completed",
"device_id": "dev-1",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:05:00Z",
},
]
return {
"title": "Tasks",
"session": session,
"tasks": tasks,
}
def make_task_detail_context(
session: SessionState,
*,
task: dict[str, Any] | None = None,
task_rows: list[tuple[str, Any]] | None = None,
timeline_steps: list[dict[str, Any]] | None = None,
can_cancel: bool = False,
cancel_notice: str | None = None,
cancel_error: str | None = None,
) -> dict[str, Any]:
if task is None:
task = {
"id": "task-001",
"goal": "Open settings",
"device_id": "dev-1",
"status": "completed",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:05:00Z",
}
if task_rows is None:
task_rows = [
("id", task["id"]),
("goal", task["goal"]),
("device_id", task["device_id"]),
("status", task["status"]),
]
if timeline_steps is None:
timeline_steps = [
{
"index": 0,
"timestamp": "2026-01-01T00:01:00Z",
"prompt": "Tap the Settings icon",
"tool_call": {"action": "tap", "x": 100, "y": 200},
"result": {"ok": True},
"before_screenshot_src": None,
"after_screenshot_src": None,
"ocr_results": [],
"ui_tree_nodes": [],
},
]
return {
"title": "Task task-001",
"session": session,
"csrf_token": session.csrf_token,
"task": task,
"task_rows": task_rows,
"timeline_steps": timeline_steps,
"can_cancel": can_cancel,
"cancel_notice": cancel_notice,
"cancel_error": cancel_error,
}
@@ -0,0 +1,297 @@
"""Render-smoke, XSS-probe, byte-identity, and autoescape tests for the
Host Agent console Jinja2 templates (Tasks 6.3-6.7)."""
from __future__ import annotations
from pathlib import Path
from typing import Any
import pytest
from host_agent.config import HostAgentConfig
from host_agent.web.auth import SessionState
from .conftest import (
XSS_PROBE,
_ESCAPED_PROBE,
make_account_context,
make_dashboard_context,
make_devices_context,
make_history_context,
make_login_context,
make_task_detail_context,
make_tasks_list_context,
)
# ---------------------------------------------------------------------------
# 6.3 Render-smoke tests (one per template)
# ---------------------------------------------------------------------------
def test_login_renders(env) -> None:
html = env.get_template("login.html").render(
**make_login_context(account={"username": "operator"})
)
assert '<form method="post" action="/login">' in html
def test_dashboard_renders(env, sample_session) -> None:
html = env.get_template("dashboard.html").render(
**make_dashboard_context(sample_session)
)
assert 'id="last-heartbeat"' in html
def test_devices_renders(env, sample_session) -> None:
html = env.get_template("devices.html").render(
**make_devices_context(sample_session)
)
assert '<form method="post" action="/devices/save">' in html
assert 'class="screenshot-button"' in html
def test_account_renders(env, sample_session) -> None:
html = env.get_template("account.html").render(
**make_account_context(sample_session)
)
assert '<form method="post" action="/account">' in html
def test_history_renders(env, sample_session) -> None:
html = env.get_template("history.html").render(
**make_history_context(sample_session)
)
assert "<table>" in html
def test_tasks_list_renders(env, sample_session) -> None:
html = env.get_template("tasks_list.html").render(
**make_tasks_list_context(sample_session)
)
assert "<h1>Tasks</h1>" in html
def test_task_detail_renders(env, sample_session) -> None:
html = env.get_template("task_detail.html").render(
**make_task_detail_context(sample_session)
)
assert "<h2>Timeline</h2>" in html
def test_task_detail_shows_cancel_button_for_non_terminal_task(
env, sample_session
) -> None:
html = env.get_template("task_detail.html").render(
**make_task_detail_context(sample_session, can_cancel=True)
)
assert 'action="/tasks/task-001/cancel"' in html
assert "Cancel task" in html
def test_task_detail_hides_cancel_button_for_terminal_task(
env, sample_session
) -> None:
html = env.get_template("task_detail.html").render(
**make_task_detail_context(sample_session, can_cancel=False)
)
assert 'action="/tasks/task-001/cancel"' not in html
def test_task_detail_renders_cancel_notice_and_error(env, sample_session) -> None:
html = env.get_template("task_detail.html").render(
**make_task_detail_context(
sample_session,
cancel_notice="Cancellation requested. It may take a moment to take effect.",
)
)
assert 'id="cancel-notice"' in html
html = env.get_template("task_detail.html").render(
**make_task_detail_context(
sample_session,
cancel_error="Failed to request cancellation. Try again.",
)
)
assert 'id="cancel-error"' in html
# ---------------------------------------------------------------------------
# 6.4 XSS-probe tests (parametrised over templates with operator-influenced
# string fields set to <script>alert(1)</script>)
# ---------------------------------------------------------------------------
_TEMPLATES_WITH_XSS = ["dashboard", "devices", "history", "tasks_list", "task_detail"]
def _xss_context(template: str, session: SessionState) -> dict[str, Any]:
"""Build a context where every operator-influenced string field is the
XSS probe string."""
if template == "dashboard":
return make_dashboard_context(
session,
devices=[
{
"id": XSS_PROBE,
"name": XSS_PROBE,
"driver_type": XSS_PROBE,
"display_status": XSS_PROBE,
}
],
heartbeat_text=XSS_PROBE,
assignment_text=XSS_PROBE,
progress_text=XSS_PROBE,
policy_text=XSS_PROBE,
)
if template == "devices":
return make_devices_context(
session,
devices=[
{
"device_id": XSS_PROBE,
"name": XSS_PROBE,
"driver_type": XSS_PROBE,
"cloud_device_id": XSS_PROBE,
"connection_info": {},
}
],
edit_record={
"device_id": XSS_PROBE,
"name": XSS_PROBE,
"driver_type": XSS_PROBE,
"connection_info": {},
},
connection_info_json=XSS_PROBE,
error=XSS_PROBE,
)
if template == "history":
return make_history_context(
session,
entries=[
{
"occurred_at": XSS_PROBE,
"kind": XSS_PROBE,
"summary": XSS_PROBE,
}
],
)
if template == "tasks_list":
return make_tasks_list_context(
session,
tasks=[
{
"id": XSS_PROBE,
"status": XSS_PROBE,
"device_id": XSS_PROBE,
"created_at": XSS_PROBE,
"updated_at": XSS_PROBE,
}
],
)
if template == "task_detail":
return make_task_detail_context(
session,
task={
"id": XSS_PROBE,
"goal": XSS_PROBE,
"device_id": XSS_PROBE,
"status": XSS_PROBE,
"created_at": XSS_PROBE,
"updated_at": XSS_PROBE,
},
task_rows=[
("id", XSS_PROBE),
("goal", XSS_PROBE),
("status", XSS_PROBE),
],
timeline_steps=[
{
"index": 0,
"timestamp": XSS_PROBE,
"prompt": XSS_PROBE,
"tool_call": {"action": XSS_PROBE, "description": XSS_PROBE},
"result": {"ok": XSS_PROBE},
"before_screenshot_src": None,
"after_screenshot_src": None,
"ocr_results": [],
"ui_tree_nodes": [],
}
],
)
raise ValueError(f"unknown template {template}")
@pytest.mark.parametrize("template", _TEMPLATES_WITH_XSS)
def test_xss_probe_is_escaped(env, sample_session, template: str) -> None:
html = env.get_template(f"{template}.html").render(
**_xss_context(template, sample_session)
)
assert _ESCAPED_PROBE in html, f"escaped probe missing from {template}"
assert XSS_PROBE not in html, f"raw <script> leaked in {template}"
# ---------------------------------------------------------------------------
# 6.5 Byte-identity test for dashboard inline <script>
# ---------------------------------------------------------------------------
def test_dashboard_script_byte_identity(env, sample_session) -> None:
config = HostAgentConfig(control_plane_url="http://localhost:8080")
html = env.get_template("dashboard.html").render(
title="Status",
session=sample_session,
identity=None,
devices=[],
config=config,
heartbeat_text="never",
assignment_text="none",
progress_text="",
policy_text="no Cloud policy cached",
)
start = html.index("<script>")
end = html.index("</script>", start) + len("</script>")
script_block = html[start:end]
baseline = (
(Path(__file__).parent / "__baseline__" / "dashboard_script.txt")
.read_text(encoding="utf-8")
.rstrip()
)
assert script_block.rstrip() == baseline
# ---------------------------------------------------------------------------
# 6.6 No-autoescape-bypass test
# ---------------------------------------------------------------------------
def test_no_autoescape_bypass_in_templates() -> None:
templates_dir = (
Path(__file__).resolve().parents[3] / "host_agent" / "web" / "templates"
)
forbidden = ["| safe", "{% autoescape false %}", "{% endautoescape %}"]
for tpl_path in templates_dir.glob("*.html"):
content = tpl_path.read_text(encoding="utf-8")
for pattern in forbidden:
assert pattern not in content, f"{pattern} found in {tpl_path.name}"
# ---------------------------------------------------------------------------
# 6.7 _ENV autoescape configuration test
# ---------------------------------------------------------------------------
def test_env_autoescape_enabled_for_html(env) -> None:
"""The Environment's autoescape policy must escape content rendered
through .html templates."""
# Verify the autoescape policy is active (truthy or callable).
assert env.autoescape is True or callable(env.autoescape)
# Render the XSS probe through a .html template that interpolates it
# (login.html interpolates {{ error }}) and confirm it is escaped.
result = env.get_template("login.html").render(
title="Login",
session=None,
account={"username": "operator"},
error="<script>alert(1)</script>",
)
assert "&lt;script&gt;alert(1)&lt;/script&gt;" in result
assert "<script>alert(1)</script>" not in result
+432 -1
View File
@@ -1,9 +1,14 @@
from __future__ import annotations
import asyncio
import socket
from contextlib import suppress
from datetime import UTC, datetime, timedelta
import httpx
import pytest
from starlette.testclient import TestClient
from cloud.internal_api.models import (
AssignmentModel,
DeviceEnrollmentResponse,
@@ -11,9 +16,29 @@ from cloud.internal_api.models import (
)
from device.manager import DeviceManager
from host_agent.app import HostAgentApplication, create_application
from host_agent.assignment import AssignmentExecutor
from host_agent.config import HostAgentConfig
from host_agent.execution import create_execution_factories
from host_agent.history import ConsoleHistoryStore
from host_agent.identity import HostIdentityStore
from host_agent.instance_lock import InstanceAlreadyRunningError
from host_agent.local_account import LocalAccountStore
from host_agent.mcp_lock import McpBusyTracker
from host_agent.mcp_token import McpTokenStore
from host_agent.status import AgentStatusTracker
from host_agent.web.app import create_console_app
from host_agent.web.auth import SessionManager
from host_agent.web.mcp import build_mcp_server
from storage.artifact_store import ArtifactStore
from storage.device_config import DeviceConfigStore
from storage.task_metadata import TaskMetadataStore
from storage.timeline import Timeline
def _free_loopback_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
probe.bind(("127.0.0.1", 0))
return probe.getsockname()[1]
def _config() -> HostAgentConfig:
@@ -88,7 +113,6 @@ def test_create_application_enrolls_host_and_devices_before_managed_startup(
def __init__(self) -> None:
self.config = HostAgentConfig(
control_plane_url="https://control.example",
enrollment_token="one-time-token",
enrollment_managed=True,
)
@@ -352,3 +376,410 @@ def test_main_task_cancellation_waits_for_active_work_shutdown() -> None:
assert events == ["work-finished", "final-heartbeat", "closed"]
asyncio.run(scenario())
def test_console_serves_http_and_shuts_down_cleanly(tmp_path) -> None:
async def scenario() -> None:
port = _free_loopback_port()
config = HostAgentConfig(
control_plane_url="https://control.example",
host_id="host-a",
token="secret",
identity_path=tmp_path / "host_identity.json",
local_account_path=tmp_path / "host_local_account.json",
console_bind_host="127.0.0.1",
console_port=port,
)
application = create_application(config=config, manager=DeviceManager())
assert application.console_server is not None
claim_started = asyncio.Event()
claim_cancelled = asyncio.Event()
events: list[str] = []
class BlockingClient:
async def claim(self):
claim_started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
claim_cancelled.set()
raise
async def aclose(self):
events.append("closed")
class RecordingHeartbeat:
async def run(self, stop):
await stop.wait()
async def sync_once(self):
events.append("final-heartbeat")
class IdleProcessor:
async def process(self, assignment):
raise AssertionError("no assignment expected")
def request_stop(self):
events.append("stop-work")
application.client = BlockingClient() # type: ignore[assignment]
application.heartbeat = RecordingHeartbeat() # type: ignore[assignment]
application.processor = IdleProcessor() # type: ignore[assignment]
stop = asyncio.Event()
running = asyncio.create_task(application.run_async(stop))
await claim_started.wait()
response: httpx.Response | None = None
async with httpx.AsyncClient() as http_client:
loop = asyncio.get_running_loop()
deadline = loop.time() + 5
while loop.time() < deadline:
try:
response = await http_client.get(
f"http://127.0.0.1:{port}/login", timeout=0.5
)
except httpx.TransportError:
await asyncio.sleep(0.05)
continue
break
assert response is not None
assert response.status_code == 200
assert "Login" in response.text
stop.set()
await asyncio.wait_for(running, timeout=5)
assert claim_cancelled.is_set()
assert events == ["stop-work", "final-heartbeat", "closed"]
assert application.console_server.should_exit is True
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
probe.settimeout(0.5)
with suppress(ConnectionRefusedError, OSError):
probe.connect(("127.0.0.1", port))
raise AssertionError("console socket should be closed after shutdown")
asyncio.run(scenario())
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 not None
asyncio.run(application.client.aclose())
def test_dependency_supervisor_is_none_when_disabled(tmp_path, monkeypatch) -> None:
monkeypatch.chdir(tmp_path)
application = create_application(config=_config(), manager=DeviceManager())
assert application.dependency_supervisor is None
asyncio.run(application.client.aclose())
def test_dependency_supervisor_constructed_when_enabled_with_no_deps(
tmp_path, monkeypatch
) -> None:
monkeypatch.chdir(tmp_path)
config = HostAgentConfig(
control_plane_url="https://control.example",
host_id="host-a",
token="secret",
dependency_supervisor_enabled=True,
)
application = create_application(config=config, manager=DeviceManager())
assert application.dependency_supervisor is not None
assert application.dependency_supervisor.dependencies == []
asyncio.run(application.client.aclose())
def test_dependency_supervisor_constructed_when_enabled_with_appium_only(
tmp_path, monkeypatch
) -> None:
monkeypatch.chdir(tmp_path)
config = HostAgentConfig(
control_plane_url="https://control.example",
host_id="host-a",
token="secret",
dependency_supervisor_enabled=True,
appium_supervised=True,
appium_host="127.0.0.1",
appium_port=4723,
)
application = create_application(config=config, manager=DeviceManager())
assert application.dependency_supervisor is not None
deps = application.dependency_supervisor.dependencies
assert [dep.name for dep in deps] == ["appium"]
asyncio.run(application.client.aclose())
def test_run_async_starts_supervisor_before_first_heartbeat_connect() -> None:
async def scenario() -> None:
events: list[str] = []
class SupervisedNoOp:
def __init__(self) -> None:
self.started = False
self.stopped = False
async def start(self) -> None:
self.started = True
events.append("supervisor-start")
async def run(self, stop: asyncio.Event) -> None:
events.append("supervisor-run-entered")
await stop.wait()
async def stop(self) -> None:
self.stopped = True
events.append("supervisor-stop")
class BlockingClient:
async def claim(self):
await asyncio.Event().wait()
async def aclose(self):
events.append("closed")
class RecordingHeartbeat:
def __init__(self) -> None:
self.connect_called = False
def connect_devices(self) -> None:
self.connect_called = True
events.append("connect-devices")
async def run(self, stop: asyncio.Event) -> None:
# Mirror HeartbeatSynchronizer.run which calls connect_devices()
# at the very top — supervisor must have started already.
self.connect_devices()
await stop.wait()
async def sync_once(self):
events.append("final-heartbeat")
class IdleProcessor:
async def process(self, assignment):
raise AssertionError("no assignment expected")
def request_stop(self):
events.append("stop-work")
supervisor = SupervisedNoOp()
application = HostAgentApplication(
client=BlockingClient(), # type: ignore[arg-type]
heartbeat=RecordingHeartbeat(), # type: ignore[arg-type]
processor=IdleProcessor(), # type: ignore[arg-type]
dependency_supervisor=supervisor, # type: ignore[arg-type]
)
stop = asyncio.Event()
running = asyncio.create_task(application.run_async(stop))
# Yield long enough for startup sequencing to land.
await asyncio.sleep(0.05)
stop.set()
await asyncio.wait_for(running, timeout=1.0)
assert supervisor.started is True
assert supervisor.stopped is True
# Supervisor startup must precede the heartbeat's connect_devices().
assert events.index("supervisor-start") < events.index("connect-devices")
# Supervisor stop must run before client close.
assert events.index("supervisor-stop") < events.index("closed")
asyncio.run(scenario())
def test_second_create_application_against_held_lock_raises_before_enrollment(
tmp_path, monkeypatch
) -> None:
monkeypatch.chdir(tmp_path)
identity_path = tmp_path / "host_identity.json"
config = HostAgentConfig(
control_plane_url="https://control.example",
host_id="host-a",
token="secret",
identity_path=identity_path,
)
class TrackingEnrollmentClient:
def __init__(self) -> None:
self.config = config
self.calls: list[str] = []
def enroll_host(self, **payload):
self.calls.append("host")
return HostEnrollmentResponse(host_id="host-a")
def enroll_device(self, **payload):
self.calls.append("device")
return DeviceEnrollmentResponse(device_id="device-a")
def close(self) -> None:
return None
first_client = TrackingEnrollmentClient()
first_app = create_application(
config=config,
identity_store=HostIdentityStore(identity_path),
enrollment_client=first_client, # type: ignore[arg-type]
)
try:
second_client = TrackingEnrollmentClient()
with pytest.raises(InstanceAlreadyRunningError) as info:
create_application(
config=config,
identity_store=HostIdentityStore(identity_path),
enrollment_client=second_client, # type: ignore[arg-type]
)
assert info.value.lock_path == identity_path.parent / "host_agent.lock"
assert second_client.calls == []
finally:
asyncio.run(first_app.client.aclose())
def test_create_application_with_independent_identity_paths_coexist(
tmp_path, monkeypatch
) -> None:
monkeypatch.chdir(tmp_path)
config_a = HostAgentConfig(
control_plane_url="https://control.example",
host_id="host-a",
token="secret",
identity_path=tmp_path / "identity-a" / "host_identity.json",
)
config_b = HostAgentConfig(
control_plane_url="https://control.example",
host_id="host-b",
token="secret",
identity_path=tmp_path / "identity-b" / "host_identity.json",
)
app_a = create_application(config=config_a, manager=DeviceManager())
try:
app_b = create_application(config=config_b, manager=DeviceManager())
asyncio.run(app_b.client.aclose())
finally:
asyncio.run(app_a.client.aclose())
def test_create_application_wires_mcp_components(tmp_path, monkeypatch) -> None:
"""create_application produces a console app with /mcp mounted (auth-protected)
and persists the host_mcp_token.json file alongside the identity."""
monkeypatch.chdir(tmp_path)
config = _config()
config_store = DeviceConfigStore(tmp_path / "devices.sqlite3")
identity_store = HostIdentityStore(config.identity_path)
history_store = ConsoleHistoryStore(
tmp_path / "host_console_history.sqlite3",
limit=config.console_history_limit,
)
metadata_store = TaskMetadataStore(db_path=config.task_progress_db_path)
timeline = Timeline(ArtifactStore(root=config.task_artifact_dir))
status_tracker = AgentStatusTracker()
application = create_application(
config=config,
device_config_store=config_store,
identity_store=identity_store,
manager=DeviceManager(),
)
# Token file must exist after create_application.
assert (config.identity_path.parent / "host_mcp_token.json").exists()
# Heartbeat must hold the in-process McpBusyTracker.
assert application.heartbeat.mcp_busy_tracker is not None
# Build the same console app the production path builds and verify /mcp
# is mounted (responds 401, not 404) without a bearer token.
mcp_token_store = McpTokenStore(config.identity_path.parent / "host_mcp_token.json")
mcp_token_store.load_or_create()
mcp_busy_tracker = McpBusyTracker(ttl_seconds=20.0)
mcp_server = build_mcp_server(
manager=application.heartbeat.manager,
mcp_busy_tracker=mcp_busy_tracker,
status_tracker=status_tracker,
)
console_app = create_console_app(
config=config,
manager=application.heartbeat.manager,
config_store=config_store,
local_account_store=LocalAccountStore(config.local_account_path),
identity_store=identity_store,
history_store=history_store,
status_tracker=status_tracker,
session_manager=SessionManager(ttl_seconds=config.console_session_ttl_seconds),
enrollment_client=None,
host_client=application.client,
metadata_store=metadata_store,
timeline=timeline,
executor=AssignmentExecutor(
create_execution_factories(
application.heartbeat.manager,
metadata_store=metadata_store,
timeline=timeline,
host_agent_config=config,
),
mcp_busy_tracker=mcp_busy_tracker,
),
mcp_server=mcp_server,
mcp_token_store=mcp_token_store,
mcp_busy_tracker=mcp_busy_tracker,
)
with TestClient(console_app) as client:
resp = client.post("/mcp/")
assert resp.status_code == 401 # auth required, not 404
asyncio.run(application.client.aclose())
def test_lock_released_after_run_async_allows_restart(tmp_path, monkeypatch) -> None:
monkeypatch.chdir(tmp_path)
identity_path = tmp_path / "host_identity.json"
config = HostAgentConfig(
control_plane_url="https://control.example",
host_id="host-a",
token="secret",
identity_path=identity_path,
)
first_app = create_application(config=config, manager=DeviceManager())
class StoppedClient:
async def claim(self):
raise AssertionError("polling must not start")
async def aclose(self):
return None
class FastHeartbeat:
async def run(self, stop):
await stop.wait()
async def sync_once(self):
return None
class IdleProcessor:
async def process(self, assignment):
raise AssertionError("no assignment expected")
def request_stop(self):
return None
first_app.client = StoppedClient() # type: ignore[assignment]
first_app.heartbeat = FastHeartbeat() # type: ignore[assignment]
first_app.processor = IdleProcessor() # type: ignore[assignment]
stop = asyncio.Event()
stop.set()
asyncio.run(first_app.run_async(stop))
# Lock must be free now; a fresh create_application against the same
# identity_path must succeed (simulates a clean restart).
second_app = create_application(config=config, manager=DeviceManager())
asyncio.run(second_app.client.aclose())
@@ -25,6 +25,7 @@ def _assignment(**overrides) -> AssignmentModel:
def test_goal_assignment_executes_through_task_runner() -> None:
received: list[Task] = []
created: list[tuple[str, str | None, int | None]] = []
class FakeTaskRunner:
def run(self, task: Task) -> Task:
@@ -32,10 +33,21 @@ def test_goal_assignment_executes_through_task_runner() -> None:
task.status = "completed"
return task
class FakeMetadataStore:
def create_task(
self,
task: Task,
*,
source_task_id: str | None = None,
source_attempt: int | None = None,
) -> None:
created.append((task.id, source_task_id, source_attempt))
factories = ExecutionFactories(
task_runner_factory=lambda: FakeTaskRunner(), # type: ignore[arg-type,return-value]
workflow_runner_factory=lambda: object(), # type: ignore[arg-type,return-value]
workflow_store=object(), # type: ignore[arg-type]
metadata_store=FakeMetadataStore(), # type: ignore[arg-type]
)
result = AssignmentExecutor(factories).execute(_assignment())
@@ -44,6 +56,7 @@ def test_goal_assignment_executes_through_task_runner() -> None:
assert received[0].goal == "open settings"
assert received[0].device_id == "device-a"
assert result.metadata["runtime_task_id"] == received[0].id
assert created == [(received[0].id, "cloud-task", 1)]
def test_goal_assignment_preserves_runtime_failure_reason() -> None:
@@ -65,6 +78,39 @@ def test_goal_assignment_preserves_runtime_failure_reason() -> None:
assert result.failure_reason == "planner unavailable"
def test_goal_assignment_maps_cancellation_stop_to_cancelled_status() -> None:
# First should_stop() call is Executor.execute()'s pre-flight check (must pass
# through so the runner is actually invoked); the runner's own loop then stops.
calls = {"count": 0}
def should_stop() -> bool:
calls["count"] += 1
return calls["count"] > 1
class FakeTaskRunner:
def run(self, task: Task, *, should_stop=None, stop_reason=None) -> Task:
assert should_stop is not None and should_stop()
assert stop_reason is not None
task.status = "cancelled"
task.failure_reason = stop_reason()
return task
factories = ExecutionFactories(
task_runner_factory=lambda: FakeTaskRunner(), # type: ignore[arg-type,return-value]
workflow_runner_factory=lambda: object(), # type: ignore[arg-type,return-value]
workflow_store=object(), # type: ignore[arg-type]
)
result = AssignmentExecutor(factories).execute(
_assignment(),
should_stop=should_stop,
stop_reason=lambda: "cancellation requested by control plane",
)
assert result.status == "cancelled"
assert result.failure_reason == "cancellation requested by control plane"
def test_workflow_assignment_loads_and_executes_definition() -> None:
definition = object()
calls: list[tuple[object, str]] = []
@@ -96,6 +142,108 @@ def test_workflow_assignment_loads_and_executes_definition() -> None:
}
def test_workflow_assignment_maps_cancellation_stop_to_cancelled_status() -> None:
calls = {"count": 0}
def should_stop() -> bool:
calls["count"] += 1
return calls["count"] > 1
class FakeWorkflowStore:
def get_definition(self, definition_id: str):
return object() if definition_id == "workflow-a" else None
class FakeWorkflowRunner:
def run(
self,
loaded_definition,
device_id: str,
*,
should_stop=None,
stop_reason=None,
):
assert should_stop is not None and should_stop()
assert stop_reason is not None
return SimpleNamespace(
id="run-a", status="cancelled", failure_reason=stop_reason()
)
factories = ExecutionFactories(
task_runner_factory=lambda: object(), # type: ignore[arg-type,return-value]
workflow_runner_factory=lambda: FakeWorkflowRunner(), # type: ignore[arg-type,return-value]
workflow_store=FakeWorkflowStore(), # type: ignore[arg-type]
)
result = AssignmentExecutor(factories).execute(
_assignment(goal=None, workflow_definition_id="workflow-a"),
should_stop=should_stop,
stop_reason=lambda: "cancellation requested by control plane",
)
assert result.status == "cancelled"
assert result.metadata == {
"workflow_run_id": "run-a",
"workflow_status": "cancelled",
}
def test_execute_fails_fast_when_mcp_session_holds_device() -> None:
"""Cloud assignment arriving for a device currently held by an MCP
session must fail immediately rather than fight for the device."""
from host_agent.mcp_lock import McpBusyTracker
tracker = McpBusyTracker()
tracker.acquire("phone-1", "sess-mcp")
executor = AssignmentExecutor(
_build_factories(),
mcp_busy_tracker=tracker,
)
assignment = _assignment(device_id="phone-1")
result = executor.execute(assignment)
assert result.status == "failed"
assert "MCP" in (result.failure_reason or "")
def test_execute_skips_check_when_tracker_is_none() -> None:
"""Default backward-compat: no tracker → no fail-fast."""
executor = AssignmentExecutor(_build_factories())
# Without a real workflow store / task runner this test verifies the
# entry-point path doesn't raise on the mcp_busy check.
# We use a goal + a mock runner factory so execute() runs through.
assignment = _assignment()
result = executor.execute(assignment)
# Should run through normally (not fail on MCP check)
assert result.status == "done"
def _build_factories() -> ExecutionFactories:
"""Shared factory fixture used by MCP-hold tests."""
received: list[Task] = []
class FakeTaskRunner:
def run(self, task: Task) -> Task:
received.append(task)
task.status = "completed"
return task
class FakeMetadataStore:
def create_task(
self,
task: Task,
*,
source_task_id: str | None = None,
source_attempt: int | None = None,
) -> None:
pass
return ExecutionFactories(
task_runner_factory=lambda: FakeTaskRunner(), # type: ignore[arg-type,return-value]
workflow_runner_factory=lambda: object(), # type: ignore[arg-type,return-value]
workflow_store=object(), # type: ignore[arg-type]
metadata_store=FakeMetadataStore(), # type: ignore[arg-type]
)
def test_unknown_workflow_fails_without_running() -> None:
class FakeWorkflowStore:
def get_definition(self, definition_id: str):
+166
View File
@@ -0,0 +1,166 @@
from __future__ import annotations
import sys
import pytest
from host_agent import cli
from host_agent.instance_lock import InstanceAlreadyRunningError
from host_agent.local_account import LocalAccountStore
class _RecordingApplication:
def __init__(self) -> None:
self.ran = False
def run(self) -> None:
self.ran = True
def _base_env(tmp_path) -> dict[str, str]:
return {
"HOST_AGENT_CONTROL_PLANE_URL": "https://cloud.example",
"HOST_AGENT_LOCAL_ACCOUNT_PATH": str(tmp_path / "account.json"),
"HOST_AGENT_IDENTITY_PATH": str(tmp_path / "identity.json"),
}
def _set_env(monkeypatch, tmp_path) -> None:
for key, value in _base_env(tmp_path).items():
monkeypatch.setenv(key, value)
def _patch_create_application(monkeypatch) -> dict:
captured: dict = {}
def fake_create_application(*, config=None, **kwargs):
captured["config"] = config
app = _RecordingApplication()
captured["app"] = app
return app
monkeypatch.setattr(cli, "create_application", fake_create_application)
return captured
def test_existing_account_fast_path_skips_prompting(monkeypatch, tmp_path) -> None:
_set_env(monkeypatch, tmp_path)
LocalAccountStore(tmp_path / "account.json").create(
"operator", "correct horse battery staple"
)
def fail_input(prompt: str = "") -> str:
raise AssertionError("must not prompt when a local account already exists")
monkeypatch.setattr("builtins.input", fail_input)
captured = _patch_create_application(monkeypatch)
cli.main([])
assert captured["app"].ran is True
assert captured["config"].display_name == "operator"
def test_interactive_first_run_prompts_and_creates_account(
monkeypatch, tmp_path
) -> None:
_set_env(monkeypatch, tmp_path)
monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
inputs = iter(["operator"])
monkeypatch.setattr("builtins.input", lambda prompt="": next(inputs))
passwords = iter(["hunter2", "hunter2"])
monkeypatch.setattr("getpass.getpass", lambda prompt="": next(passwords))
captured = _patch_create_application(monkeypatch)
cli.main([])
assert captured["app"].ran is True
account = LocalAccountStore(tmp_path / "account.json").load()
assert account is not None
assert account.username == "operator"
def test_non_interactive_without_account_exits_with_clear_error(
monkeypatch, tmp_path, capsys
) -> None:
_set_env(monkeypatch, tmp_path)
monkeypatch.setattr(sys.stdin, "isatty", lambda: False)
captured = _patch_create_application(monkeypatch)
with pytest.raises(SystemExit) as exc_info:
cli.main([])
assert exc_info.value.code == 1
assert "setup" in capsys.readouterr().err
assert "app" not in captured
assert LocalAccountStore(tmp_path / "account.json").load() is None
def test_setup_subcommand_creates_account(monkeypatch, tmp_path) -> None:
_set_env(monkeypatch, tmp_path)
inputs = iter(["operator"])
monkeypatch.setattr("builtins.input", lambda prompt="": next(inputs))
passwords = iter(["hunter2", "hunter2"])
monkeypatch.setattr("getpass.getpass", lambda prompt="": next(passwords))
captured = _patch_create_application(monkeypatch)
cli.main(["setup"])
assert "app" not in captured
account = LocalAccountStore(tmp_path / "account.json").load()
assert account is not None
assert account.username == "operator"
def test_setup_subcommand_refuses_overwrite_without_confirmation(
monkeypatch, tmp_path
) -> None:
_set_env(monkeypatch, tmp_path)
store = LocalAccountStore(tmp_path / "account.json")
original = store.create("operator", "original-password")
monkeypatch.setattr("builtins.input", lambda prompt="": "n")
cli.main(["setup"])
assert store.load() == original
def test_duplicate_instance_exits_with_clear_error(
monkeypatch, tmp_path, capsys
) -> None:
_set_env(monkeypatch, tmp_path)
LocalAccountStore(tmp_path / "account.json").create(
"operator", "correct horse battery staple"
)
lock_path = tmp_path / "identity_dir" / "host_agent.lock"
def raising_create_application(*, config=None, **kwargs):
raise InstanceAlreadyRunningError(lock_path)
monkeypatch.setattr(cli, "create_application", raising_create_application)
with pytest.raises(SystemExit) as exc_info:
cli.main([])
assert exc_info.value.code == 1
err = capsys.readouterr().err
assert "another Host Agent instance" in err
assert str(lock_path) in err
def test_mcp_token_subcommand_prints_token(tmp_path, capsys, monkeypatch) -> None:
monkeypatch.setenv("HOST_AGENT_IDENTITY_PATH", str(tmp_path / "host_identity.json"))
monkeypatch.setenv(
"HOST_AGENT_LOCAL_ACCOUNT_PATH", str(tmp_path / "host_local_account.json")
)
# Also set control plane URL to satisfy config loading
monkeypatch.setenv("HOST_AGENT_CONTROL_PLANE_URL", "https://cloud.example")
from host_agent.cli import main
main(["mcp-token"])
out = capsys.readouterr().out.strip()
assert len(out) >= 40 # token is ~43 chars
# Subsequent invocation prints the same token (idempotent).
main(["mcp-token"])
out2 = capsys.readouterr().out.strip()
assert out == out2
+237 -4
View File
@@ -11,6 +11,8 @@ from cloud.internal_api.models import AssignmentModel, DeviceSnapshotModel
from host_agent.client import (
HostAgentClient,
HostAgentEnrollmentClient,
HostAgentAPIError,
HostTaskSubmissionUnknownError,
StaleLeaseError,
)
from host_agent.config import HostAgentConfig
@@ -138,6 +140,30 @@ def test_stale_lease_response_raises_typed_error_without_retry() -> None:
assert attempts == 1
def test_renew_deserializes_cancel_requested_flag() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
json={
"status": "renewed",
"lease_expires_at": "2026-07-12T00:05:00Z",
"cancel_requested": True,
},
)
async def scenario() -> None:
async with httpx.AsyncClient(
transport=httpx.MockTransport(handler),
base_url="https://control.example",
) as http_client:
client = HostAgentClient(_config(), http_client=http_client)
response = await client.renew(_assignment())
assert response.status == "renewed"
assert response.cancel_requested is True
asyncio.run(scenario())
def test_result_report_retries_identical_payload_after_response_loss() -> None:
payloads: list[dict[str, object]] = []
@@ -172,7 +198,216 @@ def test_result_report_retries_identical_payload_after_response_loss() -> None:
assert payloads[0]["failure_reason"] == "planner unavailable"
def test_bootstrap_client_retries_identical_enrollment_and_enrolls_device() -> None:
def test_submit_self_task_posts_once_and_returns_task_id() -> None:
requests: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
return httpx.Response(201, json={"task_id": "task-cloud-1"})
async def scenario() -> None:
async with httpx.AsyncClient(
transport=httpx.MockTransport(handler),
base_url="https://control.example",
) as http_client:
client = HostAgentClient(_config(), http_client=http_client)
response = await client.submit_self_task(
goal="open settings",
device_id=None,
)
assert response.task_id == "task-cloud-1"
asyncio.run(scenario())
assert len(requests) == 1
assert requests[0].url.path == "/internal/v1/hosts/host-a/tasks"
assert requests[0].headers["authorization"] == "Bearer host-secret"
payload = json.loads(requests[0].content)
assert payload == {
"host_id": "host-a",
"goal": "open settings",
"device_id": None,
}
def test_submit_self_task_does_not_retry_transport_failure() -> None:
attempts = 0
sleeps: list[float] = []
def handler(request: httpx.Request) -> httpx.Response:
nonlocal attempts
attempts += 1
raise httpx.ConnectError("network down", request=request)
async def sleep(delay: float) -> None:
sleeps.append(delay)
async def scenario() -> None:
async with httpx.AsyncClient(
transport=httpx.MockTransport(handler),
base_url="https://control.example",
) as http_client:
client = HostAgentClient(_config(), http_client=http_client, sleep=sleep)
with pytest.raises(HostTaskSubmissionUnknownError):
await client.submit_self_task(goal="open settings")
asyncio.run(scenario())
assert attempts == 1
assert sleeps == []
def test_submit_self_task_treats_5xx_as_unknown_outcome_without_retry() -> None:
attempts = 0
sleeps: list[float] = []
def handler(request: httpx.Request) -> httpx.Response:
nonlocal attempts
attempts += 1
return httpx.Response(502, json={"detail": "bad gateway"})
async def sleep(delay: float) -> None:
sleeps.append(delay)
async def scenario() -> None:
async with httpx.AsyncClient(
transport=httpx.MockTransport(handler),
base_url="https://control.example",
) as http_client:
client = HostAgentClient(_config(), http_client=http_client, sleep=sleep)
with pytest.raises(HostTaskSubmissionUnknownError):
await client.submit_self_task(goal="open settings")
asyncio.run(scenario())
assert attempts == 1
assert sleeps == []
def test_submit_self_task_raises_definitive_error_on_4xx_rejection() -> None:
attempts = 0
def handler(request: httpx.Request) -> httpx.Response:
nonlocal attempts
attempts += 1
return httpx.Response(
403,
json={"detail": "Host self-submission is disabled"},
)
async def scenario() -> None:
async with httpx.AsyncClient(
transport=httpx.MockTransport(handler),
base_url="https://control.example",
) as http_client:
client = HostAgentClient(_config(), http_client=http_client)
with pytest.raises(HostAgentAPIError) as error:
await client.submit_self_task(goal="open settings")
assert "host-secret" not in str(error.value)
asyncio.run(scenario())
assert attempts == 1
def test_submit_self_task_treats_malformed_success_as_unknown() -> None:
attempts = 0
def handler(request: httpx.Request) -> httpx.Response:
nonlocal attempts
attempts += 1
return httpx.Response(201, json={"unexpected": "shape"})
async def scenario() -> None:
async with httpx.AsyncClient(
transport=httpx.MockTransport(handler),
base_url="https://control.example",
) as http_client:
client = HostAgentClient(_config(), http_client=http_client)
with pytest.raises(HostTaskSubmissionUnknownError):
await client.submit_self_task(goal="open settings")
asyncio.run(scenario())
assert attempts == 1
def test_submit_self_task_does_not_duplicate_when_response_is_lost() -> None:
attempts = 0
def handler(request: httpx.Request) -> httpx.Response:
nonlocal attempts
attempts += 1
raise httpx.ReadError("response lost", request=request)
async def scenario() -> None:
async with httpx.AsyncClient(
transport=httpx.MockTransport(handler),
base_url="https://control.example",
) as http_client:
client = HostAgentClient(
_config(),
http_client=http_client,
sleep=lambda delay: asyncio.sleep(0),
)
with pytest.raises(HostTaskSubmissionUnknownError):
await client.submit_self_task(goal="open settings")
asyncio.run(scenario())
assert attempts == 1
def test_heartbeat_includes_mcp_busy_device_ids_in_payload() -> None:
"""When mcp_busy_device_ids is passed, the client sends it in the request."""
captured: list[dict[str, object]] = []
def handler(request: httpx.Request) -> httpx.Response:
captured.append(json.loads(request.content))
return httpx.Response(
200,
json={
"host_id": "host-a",
"accepted_devices": 0,
"received_at": "2026-07-12T00:00:00Z",
},
)
async def scenario() -> None:
async with httpx.AsyncClient(
transport=httpx.MockTransport(handler),
base_url="https://control.example",
) as http_client:
client = HostAgentClient(_config(), http_client=http_client)
await client.heartbeat([], mcp_busy_device_ids=["phone-1"])
asyncio.run(scenario())
assert captured[0]["mcp_busy_device_ids"] == ["phone-1"]
def test_heartbeat_omits_mcp_busy_device_ids_when_empty() -> None:
"""Empty list is omitted from the payload (backward compatible)."""
captured: list[dict[str, object]] = []
def handler(request: httpx.Request) -> httpx.Response:
captured.append(json.loads(request.content))
return httpx.Response(
200,
json={
"host_id": "host-a",
"accepted_devices": 0,
"received_at": "2026-07-12T00:00:00Z",
},
)
async def scenario() -> None:
async with httpx.AsyncClient(
transport=httpx.MockTransport(handler),
base_url="https://control.example",
) as http_client:
client = HostAgentClient(_config(), http_client=http_client)
await client.heartbeat([], mcp_busy_device_ids=[])
asyncio.run(scenario())
assert "mcp_busy_device_ids" not in captured[0]
def test_bootstrap_client_directly_enrolls_and_enrolls_device() -> None:
requests: list[httpx.Request] = []
host_attempts = 0
@@ -189,7 +424,6 @@ def test_bootstrap_client_retries_identical_enrollment_and_enrolls_device() -> N
config = _config(
host_id="",
token="",
enrollment_token="one-time-token",
enrollment_managed=True,
)
with httpx.Client(
@@ -209,7 +443,6 @@ def test_bootstrap_client_retries_identical_enrollment_and_enrolls_device() -> N
client.config = _config(
host_id=host.host_id,
token="host-token-" + ("x" * 40),
enrollment_token="one-time-token",
enrollment_managed=True,
)
device = client.enroll_device(
@@ -223,5 +456,5 @@ def test_bootstrap_client_retries_identical_enrollment_and_enrolls_device() -> N
assert device.device_id == "device-cloud-a"
assert len(requests) == 3
assert requests[0].content == requests[1].content
assert requests[0].headers["authorization"] == "Bearer one-time-token"
assert "authorization" not in requests[0].headers
assert requests[2].headers["authorization"] == ("Bearer host-token-" + ("x" * 40))
@@ -0,0 +1,251 @@
from __future__ import annotations
import json
import httpx
import pytest
from host_agent.cloud_planner_client import (
_CLOUD_PROXY_HTTP_TIMEOUT_SECONDS,
CloudProxyToolCallingClient,
)
from host_agent.config import HostAgentConfig
from host_agent.planner_context import PlannerExecutionContext, _context
from runtime.tool_calling_client import ToolCallDecision, ToolCallUnavailable
from runtime.tool_specs import ToolSpec
_CONFIG = HostAgentConfig(
control_plane_url="https://control-plane.example",
host_id="host-a",
token="token-a",
)
_TOOLS = [ToolSpec(name="tap", description="tap an element", parameters={})]
def _client(handler) -> CloudProxyToolCallingClient:
transport = httpx.MockTransport(handler)
http_client = httpx.Client(base_url=_CONFIG.control_plane_url, transport=transport)
return CloudProxyToolCallingClient(_CONFIG, http_client=http_client)
def test_decide_returns_tool_call_decision_on_success() -> None:
seen_requests: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
seen_requests.append(request)
return httpx.Response(
200,
json={
"tool_name": "tap",
"arguments": {"x": 1, "y": 2},
"rationale": "The button is visible. Opening it.",
"thinking": "A tap should navigate to the next page.",
"purpose": "Open the next page.",
"expected_outcome": "The next page is visible.",
},
)
client = _client(handler)
decision = client.decide(
system_prompt="you are a planner",
user_prompt="tap login",
screenshot=None,
tools=_TOOLS,
timeout=30.0,
)
assert decision == ToolCallDecision(
tool_name="tap",
arguments={"x": 1, "y": 2},
text_output="The button is visible. Opening it.",
thinking="A tap should navigate to the next page.",
purpose="Open the next page.",
expected_outcome="The next page is visible.",
)
assert len(seen_requests) == 1
request = seen_requests[0]
assert request.url.path == "/internal/v1/hosts/host-a/planner/decide"
assert request.headers["Authorization"] == "Bearer token-a"
body = json.loads(request.content)
assert body["host_id"] == "host-a"
assert body["system_prompt"] == "you are a planner"
assert body["screenshot_base64"] is None
assert body["timeout_seconds"] == 30.0
def test_decide_base64_encodes_screenshot() -> None:
seen_requests: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
seen_requests.append(request)
return httpx.Response(200, json={"tool_name": "tap", "arguments": {}})
client = _client(handler)
client.decide(
system_prompt="sp",
user_prompt="up",
screenshot=b"hello",
tools=_TOOLS,
timeout=10.0,
)
body = json.loads(seen_requests[0].content)
assert body["screenshot_base64"] == "aGVsbG8="
def test_decide_forwards_planner_history() -> None:
seen_requests: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
seen_requests.append(request)
return httpx.Response(200, json={"tool_name": "tap", "arguments": {}})
client = _client(handler)
history = [
{
"user_prompt": "first screen",
"tool_name": "tap",
"arguments": {"x": 1, "y": 2},
"rationale": "Open it.",
"tool_result": {"success": True},
}
]
client.decide(
system_prompt="sp",
user_prompt="next screen",
screenshot=None,
tools=_TOOLS,
timeout=10.0,
history=history,
)
assert json.loads(seen_requests[0].content)["history"] == history
def test_decide_clamps_legacy_timeout_and_waits_for_cloud_profile_timeout() -> None:
seen_requests: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
seen_requests.append(request)
return httpx.Response(200, json={"tool_name": "tap", "arguments": {}})
client = _client(handler)
client.decide(
system_prompt="sp",
user_prompt="up",
screenshot=None,
tools=_TOOLS,
timeout=180.0,
)
request = seen_requests[0]
body = json.loads(request.content)
assert body["timeout_seconds"] == 120.0
assert request.extensions["timeout"]["read"] == _CLOUD_PROXY_HTTP_TIMEOUT_SECONDS
def test_decide_includes_bound_assignment_context() -> None:
seen_requests: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
seen_requests.append(request)
return httpx.Response(200, json={"tool_name": "tap", "arguments": {}})
client = _client(handler)
token = _context.set(
PlannerExecutionContext(task_id="task-a", attempt=2, lease_id="lease-a")
)
try:
client.decide(
system_prompt="sp",
user_prompt="up",
screenshot=None,
tools=_TOOLS,
timeout=10.0,
)
finally:
_context.reset(token)
body = json.loads(seen_requests[0].content)
assert body["task_id"] == "task-a"
assert body["attempt"] == 2
assert body["lease_id"] == "lease-a"
def test_decide_raises_tool_call_unavailable_on_network_error() -> None:
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("connection refused", request=request)
client = _client(handler)
with pytest.raises(ToolCallUnavailable):
client.decide(
system_prompt="sp",
user_prompt="up",
screenshot=None,
tools=_TOOLS,
timeout=5.0,
)
def test_decide_raises_tool_call_unavailable_on_structured_error_response() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
502,
json={"code": "planner_unavailable", "detail": "provider timed out"},
)
client = _client(handler)
with pytest.raises(ToolCallUnavailable, match="provider timed out"):
client.decide(
system_prompt="sp",
user_prompt="up",
screenshot=None,
tools=_TOOLS,
timeout=5.0,
)
def test_decide_raises_tool_call_unavailable_on_unstructured_error_response() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(500, text="internal server error")
client = _client(handler)
with pytest.raises(ToolCallUnavailable):
client.decide(
system_prompt="sp",
user_prompt="up",
screenshot=None,
tools=_TOOLS,
timeout=5.0,
)
def test_close_closes_an_internally_constructed_http_client() -> None:
client = CloudProxyToolCallingClient(_CONFIG)
client.close()
assert client._client.is_closed
def test_close_leaves_an_externally_supplied_http_client_open() -> None:
"""Only a client built internally (no ``http_client`` override) is closed
by ``close()``; a caller-supplied ``http_client`` is left open for the
caller to manage."""
transport = httpx.MockTransport(lambda request: httpx.Response(200, json={}))
external_http_client = httpx.Client(
base_url=_CONFIG.control_plane_url, transport=transport
)
client = CloudProxyToolCallingClient(_CONFIG, http_client=external_http_client)
client.close()
assert not external_http_client.is_closed
external_http_client.close()
+185 -27
View File
@@ -11,24 +11,36 @@ from host_agent.config import (
)
BASE_ENV = {
"HOST_AGENT_HOST_ID": "host-a",
"HOST_AGENT_TOKEN": "secret",
}
def test_load_host_agent_config_uses_managed_cloud_defaults() -> None:
config = load_host_agent_config({})
def test_load_host_agent_config_uses_local_network_defaults() -> None:
assert load_host_agent_config(BASE_ENV) == HostAgentConfig(
control_plane_url="http://127.0.0.1:8001",
host_id="host-a",
token="secret",
assert config == HostAgentConfig(
control_plane_url="https://amcp.home.jerryyan.top",
enrollment_managed=True,
)
assert config.ai_planner_transport == "cloud"
def test_load_host_agent_config_allows_explicit_direct_planner_transport() -> None:
config = load_host_agent_config({"AI_PLANNER_TRANSPORT": "direct"})
assert config.ai_planner_transport == "direct"
def test_load_host_agent_config_supports_local_mode() -> None:
config = load_host_agent_config({"HOST_AGENT_MODE": "local"})
assert config.mode == "local"
assert config.control_plane_url == ""
assert config.enrollment_managed is False
assert config.ai_planner_transport == "direct"
assert config.dependency_supervisor_enabled is True
assert config.appium_supervised is True
def test_load_host_agent_config_parses_poll_and_retry_values() -> None:
config = load_host_agent_config(
{
**BASE_ENV,
"HOST_AGENT_CONTROL_PLANE_URL": "https://cloud.example/v1/",
"HOST_AGENT_HEARTBEAT_INTERVAL_SECONDS": "10",
"HOST_AGENT_POLL_TIMEOUT_SECONDS": "15",
@@ -42,12 +54,11 @@ def test_load_host_agent_config_parses_poll_and_retry_values() -> None:
assert config.max_retry_backoff_seconds == 20
def test_load_host_agent_config_supports_managed_enrollment(tmp_path) -> None:
def test_load_host_agent_config_uses_direct_enrollment(tmp_path) -> None:
identity_path = tmp_path / "host_identity.json"
config = load_host_agent_config(
{
"HOST_AGENT_CONTROL_PLANE_URL": "https://cloud.example",
"HOST_AGENT_ENROLLMENT_TOKEN": "one-time-token",
"HOST_AGENT_IDENTITY_PATH": str(identity_path),
"HOST_AGENT_DISPLAY_NAME": "Edge Mac",
}
@@ -55,31 +66,49 @@ def test_load_host_agent_config_supports_managed_enrollment(tmp_path) -> None:
assert config.host_id == ""
assert config.token == ""
assert config.enrollment_token == "one-time-token"
assert config.identity_path == identity_path
assert config.enrollment_managed is True
assert config.display_name == "Edge Mac"
assert "one-time-token" not in repr(config)
def test_existing_identity_state_allows_restart_without_enrollment_token(
tmp_path,
) -> None:
identity_path = tmp_path / "host_identity.json"
identity_path.write_text("{}", encoding="utf-8")
def test_static_credential_environment_variables_are_ignored() -> None:
config = load_host_agent_config(
{
"HOST_AGENT_HOST_ID": "legacy-host",
"HOST_AGENT_TOKEN": "legacy-token",
"HOST_AGENT_ENROLLMENT_TOKEN": "legacy-enrollment-token",
}
)
config = load_host_agent_config({"HOST_AGENT_IDENTITY_PATH": str(identity_path)})
assert config.identity_path == Path(identity_path)
assert config.host_id == ""
assert config.token == ""
assert config.enrollment_managed is True
def test_fresh_install_defaults_local_account_path() -> None:
config = load_host_agent_config(
{"HOST_AGENT_CONTROL_PLANE_URL": "https://cloud.example"}
)
assert config.enrollment_managed is True
assert config.local_account_path == Path("tasks/host_local_account.json")
def test_local_account_path_can_be_overridden(tmp_path) -> None:
account_path = tmp_path / "account.json"
config = load_host_agent_config(
{
"HOST_AGENT_CONTROL_PLANE_URL": "https://cloud.example",
"HOST_AGENT_LOCAL_ACCOUNT_PATH": str(account_path),
}
)
assert config.local_account_path == account_path
@pytest.mark.parametrize(
"overrides",
[
{"HOST_AGENT_HOST_ID": ""},
{"HOST_AGENT_TOKEN": ""},
{"HOST_AGENT_HOST_ID": "host-a", "HOST_AGENT_TOKEN": ""},
{"HOST_AGENT_CONTROL_PLANE_URL": "ftp://cloud.example"},
{"HOST_AGENT_POLL_TIMEOUT_SECONDS": "0"},
{
@@ -92,4 +121,133 @@ def test_load_host_agent_config_rejects_invalid_values(
overrides: dict[str, str],
) -> None:
with pytest.raises(HostAgentConfigurationError):
load_host_agent_config({**BASE_ENV, **overrides})
load_host_agent_config(overrides)
def test_console_defaults_are_loopback_bound() -> None:
config = load_host_agent_config({})
assert config.console_bind_host == "127.0.0.1"
assert config.console_port == 8765
assert config.console_allow_non_loopback is False
assert config.console_session_ttl_seconds == 43200.0
assert config.console_history_limit == 200
@pytest.mark.parametrize("bind_host", ["127.0.0.1", "localhost", "::1"])
def test_console_loopback_bind_host_passes(bind_host: str) -> None:
config = load_host_agent_config(
{
"HOST_AGENT_CONSOLE_BIND_HOST": bind_host,
}
)
assert config.console_bind_host == bind_host
def test_console_non_loopback_bind_without_opt_in_raises() -> None:
with pytest.raises(HostAgentConfigurationError):
load_host_agent_config(
{
"HOST_AGENT_CONSOLE_BIND_HOST": "0.0.0.0",
}
)
def test_console_non_loopback_bind_with_opt_in_succeeds() -> None:
config = load_host_agent_config(
{
"HOST_AGENT_CONSOLE_BIND_HOST": "0.0.0.0",
"HOST_AGENT_CONSOLE_ALLOW_NON_LOOPBACK": "true",
}
)
assert config.console_bind_host == "0.0.0.0"
assert config.console_allow_non_loopback is True
def test_console_env_vars_parse_numeric_and_bool_fields() -> None:
config = load_host_agent_config(
{
"HOST_AGENT_CONSOLE_PORT": "9001",
"HOST_AGENT_CONSOLE_SESSION_TTL_SECONDS": "3600",
"HOST_AGENT_CONSOLE_HISTORY_LIMIT": "50",
}
)
assert config.console_port == 9001
assert config.console_session_ttl_seconds == 3600
assert config.console_history_limit == 50
@pytest.mark.parametrize(
"overrides",
[
{"HOST_AGENT_CONSOLE_PORT": "0"},
{"HOST_AGENT_CONSOLE_SESSION_TTL_SECONDS": "-1"},
{"HOST_AGENT_CONSOLE_HISTORY_LIMIT": "0"},
],
)
def test_console_numeric_fields_reject_invalid_values(
overrides: dict[str, str],
) -> None:
with pytest.raises(HostAgentConfigurationError):
load_host_agent_config(overrides)
def test_dependency_supervisor_defaults_to_disabled() -> None:
config = load_host_agent_config({})
assert config.dependency_supervisor_enabled is False
assert config.appium_supervised is False
assert config.appium_host == "127.0.0.1"
assert config.appium_port == 4723
assert config.dependency_restart_max_attempts == 5
def test_dependency_supervisor_env_vars_parse_bool_and_numeric_fields() -> None:
config = load_host_agent_config(
{
"HOST_AGENT_DEPENDENCY_SUPERVISOR_ENABLED": "true",
"HOST_AGENT_APPIUM_SUPERVISED": "1",
"HOST_AGENT_APPIUM_HOST": "0.0.0.0",
"HOST_AGENT_APPIUM_PORT": "4724",
"HOST_AGENT_DEPENDENCY_RESTART_MAX_ATTEMPTS": "8",
}
)
assert config.dependency_supervisor_enabled is True
assert config.appium_supervised is True
assert config.appium_host == "0.0.0.0"
assert config.appium_port == 4724
assert config.dependency_restart_max_attempts == 8
@pytest.mark.parametrize(
"overrides",
[
{"HOST_AGENT_APPIUM_PORT": "0"},
{"HOST_AGENT_DEPENDENCY_RESTART_MAX_ATTEMPTS": "-1"},
],
)
def test_dependency_supervisor_numeric_fields_reject_invalid_values(
overrides: dict[str, str],
) -> None:
with pytest.raises(HostAgentConfigurationError):
load_host_agent_config(overrides)
@pytest.mark.parametrize(
"setting,value",
[
("HOST_AGENT_RUNTIME_SUPERVISED", "true"),
("HOST_AGENT_RUNTIME_HOST", "127.0.0.1"),
("HOST_AGENT_RUNTIME_PORT", "8000"),
],
)
def test_removed_runtime_supervision_settings_are_rejected(
setting: str,
value: str,
) -> None:
with pytest.raises(HostAgentConfigurationError, match="standalone Runtime service"):
load_host_agent_config({setting: value})
@@ -0,0 +1,502 @@
from __future__ import annotations
import asyncio
import io
import logging
import subprocess
from collections.abc import Sequence
import httpx
import pytest
from host_agent.dependency_supervisor import (
DependencySupervisor,
ProbeResult,
SupervisedDependency,
_SupervisorKnobs,
appium_argv_factory,
probe_appium,
)
from host_agent.config import HostAgentConfig
# ---------------------------------------------------------------------------
# Test doubles
# ---------------------------------------------------------------------------
class _FakePopen:
"""Minimal subprocess.Popen stand-in for supervisor tests."""
instances: list[_FakePopen] = []
def __init__(
self,
argv: Sequence[str],
*,
stdout_lines: Sequence[str] = (),
pid: int = 0,
**_kwargs: object,
) -> None:
self.argv = list(argv)
self.pid = pid or (100 + len(_FakePopen.instances))
self.returncode: int | None = None
self.stdout = io.StringIO("".join(line + "\n" for line in stdout_lines))
self.terminate_calls = 0
self.kill_calls = 0
self.wait_calls = 0
_FakePopen.instances.append(self)
def poll(self) -> int | None:
return self.returncode
def terminate(self) -> None:
self.terminate_calls += 1
self.returncode = -15
def kill(self) -> None:
self.kill_calls += 1
self.returncode = -9
def wait(self, timeout: float | None = None) -> int:
self.wait_calls += 1
if self.returncode is None:
raise subprocess.TimeoutExpired(cmd=self.argv, timeout=timeout or 0)
return self.returncode
@pytest.fixture(autouse=True)
def _reset_fake_popen() -> None:
_FakePopen.instances.clear()
yield
_FakePopen.instances.clear()
def _dep(
name: str = "appium",
*,
host: str = "127.0.0.1",
port: int = 4723,
probe_responses: Sequence[ProbeResult] = (ProbeResult.NO_LISTENER,),
argv_factory=None,
) -> tuple[SupervisedDependency, list[ProbeResult]]:
"""Build a SupervisedDependency whose probe returns scripted responses."""
call_log: list[ProbeResult] = []
responses = list(probe_responses)
fallback = probe_responses[-1] if probe_responses else ProbeResult.NO_LISTENER
def probe(_host: str, _port: int) -> ProbeResult:
if responses:
result = responses.pop(0)
else:
result = fallback
call_log.append(result)
return result
if argv_factory is None:
def argv_factory(h: str, p: int) -> list[str]:
return ["echo", name]
dep = SupervisedDependency(
name=name,
host=host,
port=port,
argv_factory=argv_factory,
probe=probe,
)
return dep, call_log
class _FakeSleep:
"""Records sleep durations so tests can assert backoff progression."""
def __init__(self) -> None:
self.calls: list[float] = []
async def __call__(self, seconds: float) -> None:
self.calls.append(seconds)
def _build_supervisor(
deps: list[SupervisedDependency],
*,
max_attempts: int = 5,
knobs: _SupervisorKnobs | None = None,
sleep: _FakeSleep | None = None,
popen_factory=None,
) -> tuple[DependencySupervisor, _FakeSleep]:
fake_sleep = sleep or _FakeSleep()
sup = DependencySupervisor(
list(deps),
max_attempts=max_attempts,
knobs=knobs
or _SupervisorKnobs(
startup_timeout_seconds=1.0,
readiness_poll_interval_seconds=0.01,
crash_poll_interval_seconds=0.01,
initial_backoff_seconds=1.0,
max_backoff_seconds=30.0,
terminate_grace_period_seconds=1.0,
),
sleep=fake_sleep,
popen_factory=popen_factory or _FakePopen,
logger=logging.getLogger("test"),
)
return sup, fake_sleep
class _DummySocket:
def __enter__(self) -> "_DummySocket":
return self
def __exit__(self, *exc) -> None:
return None
def _raise_connection_refused(*_args, **_kwargs):
raise ConnectionRefusedError("no listener")
def _ok_connection(*_args, **_kwargs):
return _DummySocket()
# ---------------------------------------------------------------------------
# Probe tests (tasks 2.1, 2.3)
# ---------------------------------------------------------------------------
def test_probe_returns_no_listener_when_port_is_closed(monkeypatch) -> None:
monkeypatch.setattr(
"host_agent.dependency_supervisor.socket.create_connection",
_raise_connection_refused,
)
assert probe_appium("127.0.0.1", 4723) is ProbeResult.NO_LISTENER
def test_probe_returns_healthy_on_appium_status_endpoint(monkeypatch) -> None:
monkeypatch.setattr(
"host_agent.dependency_supervisor.socket.create_connection",
_ok_connection,
)
monkeypatch.setattr(
"host_agent.dependency_supervisor.httpx.get",
lambda url, timeout=2.0: httpx.Response(200, json={"ready": True}),
)
assert probe_appium("127.0.0.1", 4723) is ProbeResult.HEALTHY
def test_probe_returns_unhealthy_when_listener_returns_non_200(monkeypatch) -> None:
monkeypatch.setattr(
"host_agent.dependency_supervisor.socket.create_connection",
_ok_connection,
)
monkeypatch.setattr(
"host_agent.dependency_supervisor.httpx.get",
lambda url, timeout=2.0: httpx.Response(500, text="boom"),
)
assert probe_appium("127.0.0.1", 4723) is ProbeResult.UNHEALTHY_LISTENER
def test_probe_returns_unhealthy_when_listener_returns_non_json(monkeypatch) -> None:
monkeypatch.setattr(
"host_agent.dependency_supervisor.socket.create_connection",
_ok_connection,
)
monkeypatch.setattr(
"host_agent.dependency_supervisor.httpx.get",
lambda url, timeout=2.0: httpx.Response(200, text="not json"),
)
assert probe_appium("127.0.0.1", 4723) is ProbeResult.UNHEALTHY_LISTENER
def test_probe_returns_unhealthy_on_http_transport_error(monkeypatch) -> None:
monkeypatch.setattr(
"host_agent.dependency_supervisor.socket.create_connection",
_ok_connection,
)
def raise_http_error(url, timeout=2.0):
raise httpx.ConnectError("reset")
monkeypatch.setattr("host_agent.dependency_supervisor.httpx.get", raise_http_error)
assert probe_appium("127.0.0.1", 4723) is ProbeResult.UNHEALTHY_LISTENER
# ---------------------------------------------------------------------------
# Adoption logic tests (tasks 2.2, 2.3)
# ---------------------------------------------------------------------------
def test_start_adopts_existing_healthy_instance() -> None:
async def scenario() -> None:
dep, _ = _dep(probe_responses=(ProbeResult.HEALTHY,))
sup, _ = _build_supervisor([dep])
await sup.start()
assert dep.adopted is True
assert dep.ready is True
assert dep.process is None
asyncio.run(scenario())
def test_start_skips_when_port_has_unhealthy_listener() -> None:
async def scenario() -> None:
dep, _ = _dep(probe_responses=(ProbeResult.UNHEALTHY_LISTENER,))
sup, _ = _build_supervisor([dep])
await sup.start()
assert dep.adopted is False
assert dep.process is None
assert dep.given_up is True
asyncio.run(scenario())
def test_start_spawns_when_nothing_is_listening() -> None:
async def scenario() -> None:
# NO_LISTENER for initial probe → spawn path.
# HEALTHY on first readiness probe.
dep, _ = _dep(
probe_responses=(ProbeResult.NO_LISTENER, ProbeResult.HEALTHY),
)
sup, _ = _build_supervisor([dep])
await sup.start()
assert dep.process is not None
assert dep.adopted is False
assert dep.ready is True
assert dep.given_up is False
asyncio.run(scenario())
# ---------------------------------------------------------------------------
# Supervisor core tests (task 3.6)
# ---------------------------------------------------------------------------
def test_spawn_failure_marks_dependency_given_up() -> None:
async def scenario() -> None:
dep, _ = _dep(probe_responses=(ProbeResult.NO_LISTENER,))
def raising_popen(argv, **_kwargs):
raise FileNotFoundError("appium: not found")
sup, _ = _build_supervisor([dep], popen_factory=raising_popen)
await sup.start()
assert dep.process is None
assert dep.given_up is True
assert dep.ready is False
asyncio.run(scenario())
def test_spawn_uses_appium_argv_factory() -> None:
async def scenario() -> None:
captured: list[list[str]] = []
def recording_popen(argv, **kwargs):
captured.append(list(argv))
return _FakePopen(argv, stdout_lines=("hello",))
dep, _ = _dep(
name="appium",
port=4723,
probe_responses=(ProbeResult.NO_LISTENER, ProbeResult.HEALTHY),
argv_factory=appium_argv_factory,
)
sup, _ = _build_supervisor([dep], popen_factory=recording_popen)
await sup.start()
assert captured == [["appium", "--address", "127.0.0.1", "--port", "4723"]]
asyncio.run(scenario())
def test_readiness_timeout_leaves_process_running_without_restart_loop() -> None:
async def scenario() -> None:
# NO_LISTENER for initial probe; probe never becomes HEALTHY → startup
# timeout path (1.0s in test knobs).
dep, _ = _dep(probe_responses=(ProbeResult.NO_LISTENER,))
sup, _ = _build_supervisor([dep])
await sup.start()
assert dep.process is not None # still running
assert dep.ready is False
assert dep.given_up is False # not given up; crash-restart owns crashes
asyncio.run(scenario())
def test_crash_triggers_restart_with_backoff() -> None:
async def scenario() -> None:
# Probe responses:
# 1. start._start_one → NO_LISTENER → spawn path
# 2. first readiness probe → HEALTHY → ready
# 3. after restart, readiness probe → HEALTHY → ready again
dep, _ = _dep(
probe_responses=(
ProbeResult.NO_LISTENER,
ProbeResult.HEALTHY,
ProbeResult.HEALTHY,
),
argv_factory=appium_argv_factory,
)
spawn_count = {"n": 0}
def popen(argv, **kwargs):
spawn_count["n"] += 1
return _FakePopen(argv)
sup, sleep = _build_supervisor([dep], popen_factory=popen)
await sup.start()
assert dep.ready is True
crashes_before = dep.restart_attempts
# Crash the spawned process now (after readiness succeeded). The
# supervisor's run loop will detect this via poll().
first_proc = dep.process
assert isinstance(first_proc, _FakePopen)
first_proc.returncode = 1
stop = asyncio.Event()
task = asyncio.create_task(sup.run(stop))
# Give the loop a chance to detect the crash, sleep backoff, respawn.
await asyncio.sleep(0.05)
stop.set()
await asyncio.wait_for(task, timeout=1.0)
assert dep.restart_attempts == crashes_before + 1
assert dep.ready is True
assert sleep.calls == [1.0] # exponential backoff base for attempt 1
assert spawn_count["n"] == 2
asyncio.run(scenario())
def test_restart_exhaustion_gives_up() -> None:
async def scenario() -> None:
# Process crashes after every spawn; limit max_attempts to 2.
dep, _ = _dep(
probe_responses=(ProbeResult.NO_LISTENER,) + (ProbeResult.HEALTHY,) * 10,
)
spawn_count = {"n": 0}
def popen(argv, **kwargs):
spawn_count["n"] += 1
proc = _FakePopen(argv)
proc.returncode = 1
return proc
sup, sleep = _build_supervisor([dep], popen_factory=popen, max_attempts=2)
await sup.start()
stop = asyncio.Event()
task = asyncio.create_task(sup.run(stop))
deadline = asyncio.get_running_loop().time() + 2.0
while not dep.given_up and asyncio.get_running_loop().time() < deadline:
await asyncio.sleep(0.01)
stop.set()
await asyncio.wait_for(task, timeout=2.0)
assert dep.given_up is True
# Initial spawn + 2 successful restarts before the 3rd crash gives up.
assert dep.restart_attempts == 3
# Backoffs: 1.0 (attempt 1), 2.0 (attempt 2). No third restart.
assert sleep.calls == [1.0, 2.0]
asyncio.run(scenario())
def test_adopted_process_is_never_restarted_or_killed() -> None:
async def scenario() -> None:
dep, _ = _dep(probe_responses=(ProbeResult.HEALTHY,))
sup, _ = _build_supervisor([dep])
await sup.start()
stop = asyncio.Event()
task = asyncio.create_task(sup.run(stop))
await asyncio.sleep(0.02)
stop.set()
await asyncio.wait_for(task, timeout=1.0)
await sup.stop()
assert dep.adopted is True
assert dep.process is None
assert dep.restart_attempts == 0
asyncio.run(scenario())
def test_stop_terminates_only_spawned_children() -> None:
async def scenario() -> None:
spawned_dep, _ = _dep(
name="spawned",
port=4723,
probe_responses=(ProbeResult.NO_LISTENER, ProbeResult.HEALTHY),
)
adopted_dep, _ = _dep(
name="adopted",
port=8000,
probe_responses=(ProbeResult.HEALTHY,),
)
sup, _ = _build_supervisor([spawned_dep, adopted_dep])
await sup.start()
assert spawned_dep.process is not None
spawned_proc = spawned_dep.process
assert adopted_dep.adopted is True
await sup.stop()
assert isinstance(spawned_proc, _FakePopen)
assert spawned_proc.terminate_calls == 1
assert spawned_dep.process is None
asyncio.run(scenario())
# ---------------------------------------------------------------------------
# from_host_agent_config factory (covers wiring helper for task 4.1)
# ---------------------------------------------------------------------------
def test_from_host_agent_config_builds_empty_supervisor_when_no_dep_selected() -> None:
config = HostAgentConfig(
control_plane_url="https://control.example",
host_id="host-a",
token="t",
dependency_supervisor_enabled=True,
)
sup = DependencySupervisor.from_host_agent_config(config)
assert sup.dependencies == []
def test_from_host_agent_config_includes_appium_when_selected() -> None:
config = HostAgentConfig(
control_plane_url="https://control.example",
host_id="host-a",
token="t",
dependency_supervisor_enabled=True,
appium_supervised=True,
appium_host="10.0.0.5",
appium_port=4724,
dependency_restart_max_attempts=7,
)
sup = DependencySupervisor.from_host_agent_config(config)
names = [dep.name for dep in sup.dependencies]
assert names == ["appium"]
appium = sup.dependencies[0]
assert appium.host == "10.0.0.5"
assert appium.port == 4724
assert sup._max_attempts == 7
@@ -0,0 +1,171 @@
from __future__ import annotations
from cloud.internal_api.models import DeviceEnrollmentResponse
from device.manager import DeviceManager
from host_agent.config import HostAgentConfig
from host_agent.devices import register_local_device, unregister_local_device
from storage.device_config import DeviceConfigStore
def _config(*, enrollment_managed: bool) -> HostAgentConfig:
return HostAgentConfig(
control_plane_url="https://control.example",
host_id="host-a",
token="secret",
enrollment_managed=enrollment_managed,
)
class RecordingEnrollmentClient:
def __init__(self, device_id: str) -> None:
self.device_id = device_id
self.calls: list[dict[str, object]] = []
def enroll_device(self, **payload):
self.calls.append(payload)
return DeviceEnrollmentResponse(device_id=self.device_id)
def test_register_local_device_enrollment_managed_registers_under_cloud_id(
tmp_path,
) -> None:
store = DeviceConfigStore(tmp_path / "devices.sqlite3")
manager = DeviceManager()
enrollment_client = RecordingEnrollmentClient("device-cloud-a")
register_local_device(
store,
manager,
device_id="local-device-a",
driver_type="wda",
connection_info={"server_url": "http://127.0.0.1:4723"},
name="Lab iPhone",
config=_config(enrollment_managed=True),
enrollment_client=enrollment_client, # type: ignore[arg-type]
)
assert enrollment_client.calls == [
{
"local_device_id": "local-device-a",
"driver_type": "wda",
"name": "Lab iPhone",
"capability_tags": [],
}
]
assert [device.id for device in manager.list_devices()] == ["device-cloud-a"]
assert store.get("local-device-a")["cloud_device_id"] == "device-cloud-a"
def test_register_local_device_not_enrollment_managed_registers_under_local_id(
tmp_path,
) -> None:
store = DeviceConfigStore(tmp_path / "devices.sqlite3")
manager = DeviceManager()
enrollment_client = RecordingEnrollmentClient("device-cloud-a")
register_local_device(
store,
manager,
device_id="local-device-a",
driver_type="wda",
connection_info={"server_url": "http://127.0.0.1:4723"},
name="Lab iPhone",
config=_config(enrollment_managed=False),
enrollment_client=enrollment_client, # type: ignore[arg-type]
)
assert enrollment_client.calls == []
assert [device.id for device in manager.list_devices()] == ["local-device-a"]
assert store.get("local-device-a")["cloud_device_id"] is None
def test_register_local_device_reregister_under_new_cloud_id_replaces_prior_entry(
tmp_path,
) -> None:
store = DeviceConfigStore(tmp_path / "devices.sqlite3")
manager = DeviceManager()
config = _config(enrollment_managed=True)
register_local_device(
store,
manager,
device_id="local-device-a",
driver_type="wda",
connection_info={"server_url": "http://127.0.0.1:4723"},
name="Lab iPhone",
config=config,
enrollment_client=RecordingEnrollmentClient( # type: ignore[arg-type]
"device-cloud-a"
),
)
assert [device.id for device in manager.list_devices()] == ["device-cloud-a"]
register_local_device(
store,
manager,
device_id="local-device-a",
driver_type="wda",
connection_info={"server_url": "http://127.0.0.1:5000"},
name="Lab iPhone (moved)",
config=config,
enrollment_client=RecordingEnrollmentClient( # type: ignore[arg-type]
"device-cloud-b"
),
)
devices = manager.list_devices()
assert [device.id for device in devices] == ["device-cloud-b"]
assert devices[0].name == "Lab iPhone (moved)"
assert devices[0].connection_info == {"server_url": "http://127.0.0.1:5000"}
assert store.get("local-device-a")["cloud_device_id"] == "device-cloud-b"
def test_unregister_local_device_removes_device_with_cloud_id(tmp_path) -> None:
store = DeviceConfigStore(tmp_path / "devices.sqlite3")
manager = DeviceManager()
register_local_device(
store,
manager,
device_id="local-device-a",
driver_type="wda",
connection_info={},
name=None,
config=_config(enrollment_managed=True),
enrollment_client=RecordingEnrollmentClient( # type: ignore[arg-type]
"device-cloud-a"
),
)
unregister_local_device(store, manager, device_id="local-device-a")
assert manager.list_devices() == []
assert store.get("local-device-a") is None
def test_unregister_local_device_removes_device_without_cloud_id(tmp_path) -> None:
store = DeviceConfigStore(tmp_path / "devices.sqlite3")
manager = DeviceManager()
register_local_device(
store,
manager,
device_id="local-device-a",
driver_type="wda",
connection_info={},
name=None,
config=_config(enrollment_managed=False),
enrollment_client=None,
)
unregister_local_device(store, manager, device_id="local-device-a")
assert manager.list_devices() == []
assert store.get("local-device-a") is None
def test_unregister_local_device_is_a_no_op_for_unknown_device(tmp_path) -> None:
store = DeviceConfigStore(tmp_path / "devices.sqlite3")
manager = DeviceManager()
unregister_local_device(store, manager, device_id="unknown-device")
assert manager.list_devices() == []
+69 -31
View File
@@ -10,12 +10,12 @@ import httpx
import pytest
from fastapi.testclient import TestClient
from cloud.auth import BearerCredential
from cloud.auth import digest_token
from cloud.control_config import CloudControlConfig
from cloud.sdk.client import CloudClient
from cloud.scheduler import TaskConstraints
from cloud_api.app import create_app
from core.models import Scene
from core.models import Scene, utc_now
from device.manager import DeviceManager
from driver.base import Driver
from host_agent.assignment import AssignmentExecutionResult, AssignmentExecutor
@@ -47,6 +47,9 @@ class FakeDriver(Driver):
def tap(self, x: float, y: float) -> None:
self.calls.append(("tap", (x, y)))
def long_press(self, x: float, y: float, duration_ms: int = 1200) -> None:
return None
def swipe(
self,
start_x: float,
@@ -57,6 +60,14 @@ class FakeDriver(Driver):
) -> None:
return None
def swipe_path(
self, waypoints: list[tuple[float, float]], duration_ms: int
) -> None:
return None
def double_tap(self, x: float, y: float, interval_ms: int = 80) -> None:
return None
def input(self, text: str) -> None:
return None
@@ -79,23 +90,6 @@ class FakeDriver(Driver):
return None
def _credential(host_id: str) -> BearerCredential:
return BearerCredential(
principal_id=f"agent-{host_id}",
token=f"token-{host_id}",
scopes=frozenset(),
host_id=host_id,
)
def _public_credential() -> BearerCredential:
return BearerCredential(
principal_id="sdk",
token="public-token",
scopes=frozenset({"tasks:submit", "tasks:read"}),
)
def _config(host_id: str) -> HostAgentConfig:
return HostAgentConfig(
control_plane_url="http://control.test",
@@ -120,10 +114,18 @@ async def _control_plane(
scheduler_interval_seconds=60,
lease_reaper_interval_seconds=60,
lease_duration_seconds=lease_duration_seconds,
credentials=tuple(_credential(host_id) for host_id in host_ids),
)
)
async with app.router.lifespan_context(app):
for host_id in host_ids:
app.state.cloud_services.repository.enroll_host(
host_id=host_id,
agent_instance_id=f"agent-{host_id}",
credential_digest=digest_token(f"token-{host_id}"),
enrollment_token_digest=None,
display_name=host_id,
enrolled_at=utc_now(),
)
yield app
@@ -162,12 +164,22 @@ class _RecordingTransport(httpx.AsyncBaseTransport):
async def _sync_fake_device(
app,
client: HostAgentClient,
host_id: str,
device_id: str,
*,
driver_type: str = "wda",
) -> FakeDriver:
app.state.cloud_services.repository.enroll_device(
device_id=device_id,
host_id=host_id,
local_device_id=device_id,
driver_type=driver_type,
name=device_id,
capability_tags=[],
enrolled_at=utc_now(),
)
driver = FakeDriver()
manager = DeviceManager()
manager.register_device(
@@ -248,7 +260,7 @@ def test_one_host_executes_assignment_through_outbound_protocol(tmp_path) -> Non
paths: list[str] = []
async with _control_plane(tmp_path / "one-host.sqlite3", "host-a") as app:
async with _host_client(app, "host-a", request_paths=paths) as client:
await _sync_fake_device(client, "host-a", "device-a")
await _sync_fake_device(app, client, "host-a", "device-a")
task_id = app.state.cloud_services.scheduler.submit(
goal="open settings"
)
@@ -275,7 +287,7 @@ def test_nat_style_host_requires_only_outbound_requests(tmp_path) -> None:
paths: list[str] = []
async with _control_plane(tmp_path / "outbound-only.sqlite3", "host-a") as app:
async with _host_client(app, "host-a", request_paths=paths) as client:
await _sync_fake_device(client, "host-a", "device-a")
await _sync_fake_device(app, client, "host-a", "device-a")
assert await client.claim() is None
assert paths == [
@@ -297,8 +309,9 @@ def test_multiple_hosts_claim_only_their_matching_devices(tmp_path) -> None:
_host_client(app, "host-a") as client_a,
_host_client(app, "host-b") as client_b,
):
await _sync_fake_device(client_a, "host-a", "device-a")
await _sync_fake_device(app, client_a, "host-a", "device-a")
await _sync_fake_device(
app,
client_b,
"host-b",
"device-b",
@@ -331,7 +344,7 @@ def test_control_plane_restart_preserves_dispatched_assignment(tmp_path) -> None
assignment = None
async with _control_plane(database_path, "host-a") as first_app:
async with _host_client(first_app, "host-a") as client:
await _sync_fake_device(client, "host-a", "device-a")
await _sync_fake_device(first_app, client, "host-a", "device-a")
task_id = first_app.state.cloud_services.scheduler.submit(goal="resume")
first_app.state.cloud_services.scheduler.assign()
assignment = await client.claim()
@@ -351,7 +364,7 @@ def test_host_agent_restart_reuses_active_lease(tmp_path) -> None:
async def scenario() -> None:
async with _control_plane(tmp_path / "agent-restart.sqlite3", "host-a") as app:
async with _host_client(app, "host-a") as first_client:
await _sync_fake_device(first_client, "host-a", "device-a")
await _sync_fake_device(app, first_client, "host-a", "device-a")
task_id = app.state.cloud_services.scheduler.submit(goal="resume host")
app.state.cloud_services.scheduler.assign()
assignment = await first_client.claim()
@@ -376,7 +389,7 @@ def test_lease_loss_rejects_stale_host_result(tmp_path) -> None:
lease_duration_seconds=0.2,
) as app:
async with _host_client(app, "host-a") as client:
await _sync_fake_device(client, "host-a", "device-a")
await _sync_fake_device(app, client, "host-a", "device-a")
task_id = app.state.cloud_services.scheduler.submit(goal="expire")
app.state.cloud_services.scheduler.assign()
assignment = await client.claim()
@@ -405,7 +418,6 @@ def test_public_sdk_reports_fake_device_success_and_runtime_failure(tmp_path) ->
scheduler_interval_seconds=60,
lease_reaper_interval_seconds=60,
lease_duration_seconds=30,
credentials=(_public_credential(), _credential("host-a")),
)
)
driver = FakeDriver()
@@ -413,10 +425,38 @@ def test_public_sdk_reports_fake_device_success_and_runtime_failure(tmp_path) ->
manager.register_device("device-a", lambda: driver, status="idle")
with TestClient(app) as http_client:
app.state.cloud_services.user_auth_service.create_user(
username="operator",
display_name="Operator",
role="admin",
password="correct-horse-battery-staple",
must_change_password=False,
)
login = http_client.post(
"/v1/auth/login",
json={"username": "operator", "password": "correct-horse-battery-staple"},
)
assert login.status_code == 200
app.state.cloud_services.repository.enroll_host(
host_id="host-a",
agent_instance_id="agent-host-a",
credential_digest=digest_token("token-host-a"),
enrollment_token_digest=None,
display_name="host-a",
enrolled_at=utc_now(),
)
app.state.cloud_services.repository.enroll_device(
device_id="device-a",
host_id="host-a",
local_device_id="device-a",
driver_type="wda",
name="device-a",
capability_tags=[],
enrolled_at=utc_now(),
)
cloud_client = CloudClient(
"http://testserver",
http_client=http_client,
token="public-token",
)
async def scenario() -> None:
@@ -428,9 +468,7 @@ def test_public_sdk_reports_fake_device_success_and_runtime_failure(tmp_path) ->
heartbeat.connect_devices()
await heartbeat.sync_once()
successful_task_id = cloud_client.submit_task(goal="tap screen")[
"task_id"
]
successful_task_id = cloud_client.submit_task(goal="tap screen")["task_id"]
app.state.cloud_services.scheduler.assign()
successful_assignment = await host_client.claim()
assert successful_assignment is not None
@@ -0,0 +1,80 @@
from __future__ import annotations
from host_agent.client import HostAgentAPIError
from host_agent.config import HostAgentConfig
from host_agent.enrollment import resolve_host_identity
from host_agent.identity import HostIdentityStore
def _config(**overrides) -> HostAgentConfig:
values = {
"control_plane_url": "https://control.example",
}
values.update(overrides)
return HostAgentConfig(**values)
class _RecordingEnrollmentClient:
def __init__(self, *, host_id: str = "host-cloud-a") -> None:
self.host_id = host_id
self.calls: list[dict] = []
def enroll_host(self, **payload):
from cloud.internal_api.models import HostEnrollmentResponse
self.calls.append(payload)
return HostEnrollmentResponse(host_id=self.host_id)
class _RejectingEnrollmentClient:
def enroll_host(self, **payload):
raise HostAgentAPIError(401, "unauthorized")
def test_fresh_install_directly_enrolls(tmp_path) -> None:
identity_store = HostIdentityStore(tmp_path / "identity.json")
client = _RecordingEnrollmentClient()
resolved = resolve_host_identity(
_config(),
identity_store=identity_store,
client=client,
)
assert resolved.host_id == "host-cloud-a"
assert len(client.calls) == 1
assert identity_store.load().host_id == "host-cloud-a"
def test_self_service_rejection_propagates_as_api_error(tmp_path) -> None:
identity_store = HostIdentityStore(tmp_path / "identity.json")
client = _RejectingEnrollmentClient()
try:
resolve_host_identity(
_config(),
identity_store=identity_store,
client=client,
)
except HostAgentAPIError as exc:
assert exc.status_code == 401
else:
raise AssertionError("expected HostAgentAPIError to propagate")
assert identity_store.load().host_id is None
def test_existing_cached_identity_skips_enrollment(tmp_path) -> None:
identity_store = HostIdentityStore(tmp_path / "identity.json")
identity_store.complete(identity_store.load_or_create(), "host-cloud-a")
class ExplodingClient:
def enroll_host(self, **payload):
raise AssertionError("cached identity must skip enrollment")
resolved = resolve_host_identity(
_config(),
identity_store=identity_store,
client=ExplodingClient(), # type: ignore[arg-type]
)
assert resolved.host_id == "host-cloud-a"
@@ -2,13 +2,80 @@ from __future__ import annotations
from pathlib import Path
import pytest
from device.manager import DeviceManager
from driver.base import Driver
from host_agent import execution
from host_agent.cloud_planner_client import CloudProxyToolCallingClient
from host_agent.config import HostAgentConfig
from host_agent.execution import create_execution_factories
from runtime.ai_planner import AIPlanner
from runtime.planner import Planner
from runtime.task import TaskRunner
from workflow.runner import WorkflowRunner
from workflow.store import WorkflowStore
class FakeDriver(Driver):
def __init__(self) -> None:
self.calls: list[tuple[str, tuple[object, ...]]] = []
def connect(self) -> None:
self.calls.append(("connect", ()))
def disconnect(self) -> None:
return None
def screenshot(self) -> bytes:
return b"fake-screenshot-bytes"
def tap(self, x: float, y: float) -> None:
return None
def long_press(self, x: float, y: float, duration_ms: int = 1200) -> None:
return None
def swipe(
self,
start_x: float,
start_y: float,
end_x: float,
end_y: float,
duration_ms: int = 500,
) -> None:
return None
def swipe_path(
self, waypoints: list[tuple[float, float]], duration_ms: int
) -> None:
return None
def double_tap(self, x: float, y: float, interval_ms: int = 80) -> None:
return None
def input(self, text: str) -> None:
return None
def launch(self, app_id: str) -> None:
return None
def terminate(self, app_id: str) -> None:
return None
def tree(self):
return None
def home(self) -> None:
return None
def lock(self) -> None:
return None
def unlock(self) -> None:
return None
def test_execution_factories_compose_existing_runtime_and_workflow(tmp_path) -> None:
manager = DeviceManager()
workflow_store = WorkflowStore(tmp_path / "workflows.sqlite3")
@@ -26,6 +93,148 @@ def test_execution_factories_compose_existing_runtime_and_workflow(tmp_path) ->
assert isinstance(workflow_runner.task_runner_factory(), TaskRunner)
def test_created_task_runner_screenshot_provider_uses_configured_manager(
tmp_path,
) -> None:
"""Regression test: `create_task_runner()` must thread the Host Agent's
own `manager` into `screenshot_provider`. Omitting `manager=` makes the
tool fall back to the process-global `DEFAULT_MANAGER` singleton, which
never has this device registered, so it raises `DeviceNotFoundError` even
though the device is connected on the manager actually in use.
"""
manager = DeviceManager()
manager.register_device("phone-1", lambda: FakeDriver())
manager.connect("phone-1")
factories = create_execution_factories(
manager, workflow_store=WorkflowStore(tmp_path / "workflows.sqlite3")
)
task_runner = factories.task_runner_factory()
assert task_runner.screenshot_provider("phone-1") == b"fake-screenshot-bytes"
def test_created_task_runner_observer_uses_configured_manager(
tmp_path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Regression test: `create_task_runner()` must thread the Host Agent's
own `manager` into `observer` the same way it does for
`screenshot_provider` -- see the test above for the failure mode this
guards against.
"""
manager = DeviceManager()
seen: dict[str, object] = {}
def fake_describe_screen(device_id, *, manager=None):
seen["device_id"] = device_id
seen["manager"] = manager
return "scene-stub"
monkeypatch.setattr(execution, "describe_screen", fake_describe_screen)
factories = create_execution_factories(
manager, workflow_store=WorkflowStore(tmp_path / "workflows.sqlite3")
)
task_runner = factories.task_runner_factory()
assert task_runner.observer("phone-1") == "scene-stub"
assert seen == {"device_id": "phone-1", "manager": manager}
@pytest.mark.parametrize(
("driver_type", "expected_platform"),
[("wda", "ios"), ("uiautomator2", "android")],
)
def test_created_task_runner_resolves_platform_from_configured_driver(
tmp_path, driver_type: str, expected_platform: str
) -> None:
manager = DeviceManager()
manager.register_device("phone-1", lambda: FakeDriver(), driver_type=driver_type)
factories = create_execution_factories(
manager, workflow_store=WorkflowStore(tmp_path / "workflows.sqlite3")
)
task_runner = factories.task_runner_factory()
assert task_runner.device_platform_provider is not None
assert task_runner.device_platform_provider("phone-1") == expected_platform
def test_created_task_runner_defaults_to_ai_planner(
tmp_path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("AI_PLANNER_ENABLED", raising=False)
manager = DeviceManager()
factories = create_execution_factories(
manager, workflow_store=WorkflowStore(tmp_path / "workflows.sqlite3")
)
task_runner = factories.task_runner_factory()
assert isinstance(task_runner.planner, AIPlanner)
def test_created_task_runner_honors_explicit_ai_planner_opt_out(
tmp_path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("AI_PLANNER_ENABLED", "false")
manager = DeviceManager()
factories = create_execution_factories(
manager, workflow_store=WorkflowStore(tmp_path / "workflows.sqlite3")
)
task_runner = factories.task_runner_factory()
assert type(task_runner.planner) is Planner
@pytest.mark.parametrize("transport", [None, "cloud"])
def test_default_and_explicit_cloud_transport_build_ai_planner_with_cloud_proxy_client(
tmp_path, monkeypatch: pytest.MonkeyPatch, transport: str | None
) -> None:
monkeypatch.delenv("AI_PLANNER_ENABLED", raising=False)
manager = DeviceManager()
host_agent_config = HostAgentConfig(
control_plane_url="https://control-plane.example",
host_id="host-a",
token="token-a",
**({} if transport is None else {"ai_planner_transport": transport}),
)
factories = create_execution_factories(
manager,
workflow_store=WorkflowStore(tmp_path / "workflows.sqlite3"),
host_agent_config=host_agent_config,
)
task_runner = factories.task_runner_factory()
assert isinstance(task_runner.planner, AIPlanner)
assert isinstance(task_runner.planner.client, CloudProxyToolCallingClient)
assert task_runner.planner.client.config is host_agent_config
def test_explicit_direct_transport_preserves_local_provider_construction(
tmp_path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("AI_PLANNER_ENABLED", raising=False)
manager = DeviceManager()
host_agent_config = HostAgentConfig(
control_plane_url="https://control-plane.example",
host_id="host-a",
token="token-a",
ai_planner_transport="direct",
)
factories = create_execution_factories(
manager,
workflow_store=WorkflowStore(tmp_path / "workflows.sqlite3"),
host_agent_config=host_agent_config,
)
task_runner = factories.task_runner_factory()
assert isinstance(task_runner.planner, AIPlanner)
assert not isinstance(task_runner.planner.client, CloudProxyToolCallingClient)
def test_runtime_owned_packages_do_not_import_host_or_cloud_concerns() -> None:
root = Path(__file__).resolve().parents[3]
forbidden = ("import cloud", "from cloud", "import host_agent", "from host_agent")
+192 -1
View File
@@ -4,15 +4,22 @@ import asyncio
from datetime import UTC, datetime
from cloud.internal_api.models import HeartbeatResponse
from cloud.internal_api.models import HostGovernancePolicyModel
from device.manager import DeviceManager
from host_agent.config import HostAgentConfig
from host_agent.heartbeat import HeartbeatSynchronizer, build_device_snapshot
from host_agent.mcp_lock import McpBusyTracker
from host_agent.policy_cache import HostPolicyCacheStore
from host_agent.status import AgentStatusTracker
class ConnectableDriver:
def connect(self) -> None:
return None
def screenshot(self) -> bytes:
return b"ok"
def _config() -> HostAgentConfig:
return HostAgentConfig(
@@ -57,7 +64,9 @@ def test_heartbeat_synchronizer_runs_at_configured_interval_until_stopped() -> N
calls: list[list[str]] = []
class FakeClient:
async def heartbeat(self, devices, *, address=None):
async def heartbeat(
self, devices, *, address=None, policy_revision=0, **kwargs
):
calls.append([device.device_id for device in devices])
return HeartbeatResponse(
host_id="host-a",
@@ -81,3 +90,185 @@ def test_heartbeat_synchronizer_runs_at_configured_interval_until_stopped() -> N
asyncio.run(scenario())
assert calls == [["device-a"], ["device-a"], ["device-a"]]
assert manager.status("device-a") == "busy"
def test_offline_device_is_retried_on_next_heartbeat_cycle() -> None:
class FlakyDriver(ConnectableDriver):
def __init__(self, fail: bool) -> None:
self.fail = fail
def screenshot(self) -> bytes:
if self.fail:
raise RuntimeError("WDA disconnected")
return b"ok"
instances: list[FlakyDriver] = []
def factory() -> FlakyDriver:
driver = FlakyDriver(not instances)
instances.append(driver)
return driver
manager = DeviceManager()
manager.register_device("device-a", factory) # type: ignore[arg-type]
manager.connect("device-a")
sync = HeartbeatSynchronizer(manager, object(), _config()) # type: ignore[arg-type]
sync.probe_connected_devices()
assert manager.status("device-a") == "offline"
sync.connect_devices()
assert manager.status("device-a") == "busy"
def test_sync_once_notifies_status_tracker_and_on_sync_with_device_count() -> None:
manager = DeviceManager()
manager.register_device(
"device-a",
lambda: ConnectableDriver(), # type: ignore[arg-type,return-value]
)
manager.register_device(
"device-b",
lambda: ConnectableDriver(), # type: ignore[arg-type,return-value]
)
class FakeClient:
async def heartbeat(
self, devices, *, address=None, policy_revision=0, **kwargs
):
return HeartbeatResponse(
host_id="host-a",
accepted_devices=len(devices),
received_at=datetime.now(UTC),
)
async def scenario() -> None:
tracker = AgentStatusTracker()
on_sync_calls: list[int] = []
synchronizer = HeartbeatSynchronizer(
manager,
FakeClient(), # type: ignore[arg-type]
_config(),
status_tracker=tracker,
on_sync=on_sync_calls.append,
)
await synchronizer.sync_once()
assert on_sync_calls == [2]
last_heartbeat = tracker.snapshot()["last_heartbeat"]
assert last_heartbeat is not None
assert last_heartbeat["ok"] is True
assert last_heartbeat["device_count"] == 2
asyncio.run(scenario())
def test_heartbeat_caches_safe_host_policy_and_reuses_its_revision(tmp_path) -> None:
manager = DeviceManager()
cache = HostPolicyCacheStore(tmp_path / "host_policy.json")
revisions: list[int] = []
class UpdatingClient:
async def heartbeat(
self, devices, *, address=None, policy_revision=0, **kwargs
):
revisions.append(policy_revision)
return HeartbeatResponse(
host_id="host-a",
accepted_devices=len(devices),
received_at=datetime.now(UTC),
policy_revision=4,
policy=HostGovernancePolicyModel(
revision=4,
self_submission_enabled=False,
max_active_tasks=2,
daily_token_budget=900,
),
)
async def scenario() -> None:
tracker = AgentStatusTracker()
synchronizer = HeartbeatSynchronizer(
manager,
UpdatingClient(), # type: ignore[arg-type]
_config(),
policy_cache=cache,
status_tracker=tracker,
)
await synchronizer.sync_once()
assert tracker.snapshot()["host_policy"] == {
"revision": 4,
"self_submission_enabled": False,
"max_active_tasks": 2,
"daily_token_budget": 900,
}
restarted = HeartbeatSynchronizer(
manager,
UpdatingClient(), # type: ignore[arg-type]
_config(),
policy_cache=cache,
)
assert restarted.policy_revision == 4
asyncio.run(scenario())
assert revisions == [0]
assert '"token":' not in (tmp_path / "host_policy.json").read_text(encoding="utf-8")
def test_sync_once_passes_mcp_busy_device_ids_to_client() -> None:
"""When mcp_busy_tracker has a lease, sync_once relays the device_ids."""
manager = DeviceManager()
tracker = McpBusyTracker()
assert tracker.acquire("phone-1", "sess-a")
last_kwargs: dict[str, object] = {}
class FakeClient:
async def heartbeat(
self, devices, *, address=None, policy_revision=0, **kwargs
):
last_kwargs.update(kwargs)
return HeartbeatResponse(
host_id="host-a",
accepted_devices=len(devices),
received_at=datetime.now(UTC),
)
async def scenario() -> None:
sync = HeartbeatSynchronizer(
manager,
FakeClient(), # type: ignore[arg-type]
_config(),
mcp_busy_tracker=tracker,
)
await sync.sync_once()
asyncio.run(scenario())
assert last_kwargs.get("mcp_busy_device_ids") == ["phone-1"]
def test_sync_once_passes_empty_when_tracker_is_none() -> None:
"""Default: no tracker → no busy device ids forwarded."""
manager = DeviceManager()
last_kwargs: dict[str, object] = {}
class FakeClient:
async def heartbeat(
self, devices, *, address=None, policy_revision=0, **kwargs
):
last_kwargs.update(kwargs)
return HeartbeatResponse(
host_id="host-a",
accepted_devices=len(devices),
received_at=datetime.now(UTC),
)
async def scenario() -> None:
sync = HeartbeatSynchronizer(
manager,
FakeClient(), # type: ignore[arg-type]
_config(),
)
await sync.sync_once()
asyncio.run(scenario())
assert not last_kwargs.get("mcp_busy_device_ids")
@@ -0,0 +1,134 @@
from __future__ import annotations
import json
from datetime import UTC, datetime
from host_agent.history import ConsoleHistoryStore
def test_history_store_records_assignment_and_heartbeat_newest_first(tmp_path) -> None:
store = ConsoleHistoryStore(tmp_path / "history.sqlite3")
store.record_assignment(
task_id="task-a",
attempt=1,
status="done",
failure_reason=None,
device_id="device-a",
)
store.record_heartbeat(device_count=2)
entries = store.list_recent()
assert len(entries) == 2
assert entries[0] == {
"kind": "heartbeat",
"occurred_at": entries[0]["occurred_at"],
"summary": "heartbeat: 2 devices",
"detail": {"device_count": 2},
}
assert entries[1] == {
"kind": "assignment",
"occurred_at": entries[1]["occurred_at"],
"summary": "task-a attempt 1 on device-a: done",
"detail": {
"task_id": "task-a",
"attempt": 1,
"status": "done",
"failure_reason": None,
"device_id": "device-a",
},
}
def test_history_store_prunes_oldest_entries_beyond_limit(tmp_path) -> None:
store = ConsoleHistoryStore(tmp_path / "history.sqlite3", limit=3)
for index in range(5):
store.record_heartbeat(device_count=index)
entries = store.list_recent()
assert len(entries) == 3
assert [entry["detail"]["device_count"] for entry in entries] == [4, 3, 2]
def test_history_store_returns_empty_list_when_no_entries(tmp_path) -> None:
store = ConsoleHistoryStore(tmp_path / "history.sqlite3")
assert store.list_recent() == []
def test_history_store_uses_injected_now_for_occurred_at(tmp_path) -> None:
fixed_now = datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC)
store = ConsoleHistoryStore(tmp_path / "history.sqlite3", now=lambda: fixed_now)
store.record_heartbeat(device_count=1)
entries = store.list_recent()
assert entries[0]["occurred_at"] == fixed_now.isoformat()
def test_history_store_records_task_submission_with_device(tmp_path) -> None:
store = ConsoleHistoryStore(tmp_path / "history.sqlite3")
store.record_task_submission(task_id="task-cloud-1", device_id="device-cloud-a")
entries = store.list_recent()
assert len(entries) == 1
assert entries[0]["kind"] == "task_submission"
assert entries[0]["summary"] == "task submitted: task-cloud-1 on device-cloud-a"
assert entries[0]["detail"] == {
"task_id": "task-cloud-1",
"device_id": "device-cloud-a",
}
def test_history_store_records_task_submission_without_device(tmp_path) -> None:
store = ConsoleHistoryStore(tmp_path / "history.sqlite3")
store.record_task_submission(task_id="task-cloud-2", device_id=None)
entries = store.list_recent()
assert entries[0]["summary"] == ("task submitted: task-cloud-2 (automatic device)")
assert entries[0]["detail"] == {"task_id": "task-cloud-2"}
def test_history_store_task_submission_redacts_goal_and_secrets(tmp_path) -> None:
store = ConsoleHistoryStore(tmp_path / "history.sqlite3")
secret_goal = (
"rotate secret-XYZ-abcdef-very-secret cookie=session-abc; lease=lease-stale"
)
store.record_task_submission(task_id="task-cloud-3", device_id="device-cloud-a")
store.record_heartbeat(device_count=1)
entries = store.list_recent()
rendered = "\n".join(
repr(entry["summary"]) + " " + json.dumps(entry["detail"]) for entry in entries
)
assert secret_goal not in rendered
assert "session-abc" not in rendered
assert "lease-stale" not in rendered
def test_history_store_task_submissions_prune_beyond_limit(tmp_path) -> None:
store = ConsoleHistoryStore(tmp_path / "history.sqlite3", limit=3)
for index in range(5):
store.record_task_submission(
task_id=f"task-{index}", device_id=f"device-{index}"
)
entries = store.list_recent()
assert len(entries) == 3
assert [entry["detail"]["task_id"] for entry in entries] == [
"task-4",
"task-3",
"task-2",
]
@@ -0,0 +1,80 @@
from __future__ import annotations
import pytest
from host_agent.instance_lock import (
InstanceAlreadyRunningError,
InstanceLock,
)
def test_acquire_succeeds_when_free(tmp_path) -> None:
lock = InstanceLock(tmp_path)
lock.acquire()
assert lock.lock_path == tmp_path / "host_agent.lock"
lock.release()
def test_acquire_raises_when_already_held(tmp_path) -> None:
first = InstanceLock(tmp_path)
first.acquire()
try:
second = InstanceLock(tmp_path)
with pytest.raises(InstanceAlreadyRunningError) as info:
second.acquire()
assert info.value.lock_path == tmp_path / "host_agent.lock"
assert "another Host Agent instance" in str(info.value)
finally:
first.release()
def test_release_then_reacquire_succeeds(tmp_path) -> None:
lock = InstanceLock(tmp_path)
lock.acquire()
lock.release()
# Same handle can reacquire after release.
lock.acquire()
lock.release()
# A fresh handle against the same path also succeeds.
other = InstanceLock(tmp_path)
other.acquire()
other.release()
def test_release_is_idempotent_and_safe_before_acquire(tmp_path) -> None:
lock = InstanceLock(tmp_path)
# Releasing before acquire must be a no-op, not raise.
lock.release()
lock.acquire()
lock.release()
lock.release()
def test_independent_paths_never_contend(tmp_path) -> None:
dir_a = tmp_path / "identity-a"
dir_b = tmp_path / "identity-b"
lock_a = InstanceLock(dir_a)
lock_a.acquire()
try:
lock_b = InstanceLock(dir_b)
lock_b.acquire()
lock_b.release()
finally:
lock_a.release()
def test_context_manager_releases_on_exit(tmp_path) -> None:
with InstanceLock(tmp_path):
second = InstanceLock(tmp_path)
with pytest.raises(InstanceAlreadyRunningError):
second.acquire()
# After the context exits, a fresh handle can acquire.
InstanceLock(tmp_path).acquire()
def test_state_directory_is_created_if_missing(tmp_path) -> None:
nested = tmp_path / "nested" / "state"
lock = InstanceLock(nested)
assert nested.exists()
lock.acquire()
lock.release()
+73 -11
View File
@@ -29,13 +29,16 @@ def test_lease_renews_while_execution_is_active() -> None:
renewed = asyncio.Event()
class BlockingExecutor:
def execute(self, assignment, *, should_stop=None):
def execute(self, assignment, *, should_stop=None, stop_reason=None):
execution_started.set()
release_execution.wait(timeout=2)
return AssignmentExecutionResult(status="done")
def latest_progress(self):
return None
class RenewingClient:
async def renew(self, assignment):
async def renew(self, assignment, *, progress=None):
renewed.set()
return LeaseRenewalResponse(
status="renewed",
@@ -58,13 +61,13 @@ def test_lease_renews_while_execution_is_active() -> None:
asyncio.run(scenario())
def test_stale_lease_stops_later_interruptible_actions() -> None:
def test_cancel_requested_renewal_stops_execution_with_cancelled_status() -> None:
async def scenario() -> None:
first_action_started = Event()
actions: list[str] = []
class CooperativeExecutor:
def execute(self, assignment, *, should_stop=None):
def execute(self, assignment, *, should_stop=None, stop_reason=None):
assert should_stop is not None
actions.append("first")
first_action_started.set()
@@ -73,13 +76,66 @@ def test_stale_lease_stops_later_interruptible_actions() -> None:
Event().wait(0.001)
if not should_stop():
actions.append("second")
reason = stop_reason() if stop_reason is not None else None
return AssignmentExecutionResult(
status="failed",
failure_reason="execution interrupted",
status="cancelled" if reason and "cancel" in reason else "failed",
failure_reason=reason,
)
def latest_progress(self):
return None
class CancellingClient:
async def renew(self, assignment, *, progress=None):
assert await asyncio.to_thread(first_action_started.wait, 1)
return LeaseRenewalResponse(
status="renewed",
lease_expires_at=datetime.now(UTC) + timedelta(seconds=30),
cancel_requested=True,
)
result = await asyncio.wait_for(
ActiveAssignmentRunner(
CancellingClient(), # type: ignore[arg-type]
CooperativeExecutor(),
).run(_assignment()),
timeout=1,
)
assert result.status == "cancelled"
assert result.failure_reason == "cancellation requested by control plane"
assert actions == ["first"]
asyncio.run(scenario())
def test_stale_lease_stops_later_interruptible_actions() -> None:
async def scenario() -> None:
first_action_started = Event()
actions: list[str] = []
class CooperativeExecutor:
def execute(self, assignment, *, should_stop=None, stop_reason=None):
assert should_stop is not None
actions.append("first")
first_action_started.set()
assert first_action_started.wait(timeout=1)
while not should_stop():
Event().wait(0.001)
if not should_stop():
actions.append("second")
reason = stop_reason() if stop_reason is not None else None
assert reason == "lease rejected by control plane"
return AssignmentExecutionResult(
status="cancelled" if reason and "cancel" in reason else "failed",
failure_reason=reason,
)
def latest_progress(self):
return None
class StaleClient:
async def renew(self, assignment):
async def renew(self, assignment, *, progress=None):
assert await asyncio.to_thread(first_action_started.wait, 1)
raise StaleLeaseError(409, "stale lease")
@@ -102,11 +158,14 @@ def test_renewal_loop_exits_when_execution_finishes() -> None:
renew_calls = 0
class ImmediateExecutor:
def execute(self, assignment, *, should_stop=None):
def execute(self, assignment, *, should_stop=None, stop_reason=None):
return AssignmentExecutionResult(status="done")
def latest_progress(self):
return None
class CountingClient:
async def renew(self, assignment):
async def renew(self, assignment, *, progress=None):
nonlocal renew_calls
renew_calls += 1
return LeaseRenewalResponse(
@@ -133,7 +192,7 @@ def test_shutdown_request_stops_active_execution_cooperatively() -> None:
execution_started = Event()
class CooperativeExecutor:
def execute(self, assignment, *, should_stop=None):
def execute(self, assignment, *, should_stop=None, stop_reason=None):
assert should_stop is not None
execution_started.set()
while not should_stop():
@@ -143,8 +202,11 @@ def test_shutdown_request_stops_active_execution_cooperatively() -> None:
failure_reason="execution interrupted",
)
def latest_progress(self):
return None
class RenewingClient:
async def renew(self, assignment):
async def renew(self, assignment, *, progress=None):
return LeaseRenewalResponse(
status="renewed",
lease_expires_at=datetime.now(UTC) + timedelta(seconds=30),
@@ -0,0 +1,62 @@
from __future__ import annotations
import os
import pytest
from host_agent.local_account import LocalAccountStateError, LocalAccountStore
def test_local_account_store_creates_and_verifies_password(tmp_path) -> None:
path = tmp_path / "state" / "host_local_account.json"
store = LocalAccountStore(path)
assert store.load() is None
created = store.create("operator", "correct horse battery staple")
assert created.username == "operator"
assert store.load() == created
assert store.verify(created, "correct horse battery staple") is True
assert store.verify(created, "wrong password") is False
raw = path.read_text(encoding="utf-8")
assert "correct horse battery staple" not in raw
assert "password" not in raw.lower() or "password_hash" in raw
if os.name != "nt":
assert path.stat().st_mode & 0o777 == 0o600
def test_local_account_state_never_exposes_password_via_repr(tmp_path) -> None:
store = LocalAccountStore(tmp_path / "host_local_account.json")
created = store.create("operator", "hunter2")
assert "hunter2" not in repr(created)
assert "salt=" not in repr(created)
assert "password_hash=" not in repr(created)
def test_local_account_store_rejects_corrupted_file(tmp_path) -> None:
path = tmp_path / "host_local_account.json"
path.write_text('{"username": "operator"}', encoding="utf-8")
store = LocalAccountStore(path)
with pytest.raises(LocalAccountStateError):
store.load()
def test_local_account_store_rejects_invalid_json(tmp_path) -> None:
path = tmp_path / "host_local_account.json"
path.write_text("not json", encoding="utf-8")
store = LocalAccountStore(path)
with pytest.raises(LocalAccountStateError):
store.load()
def test_local_account_store_rejects_empty_credentials(tmp_path) -> None:
store = LocalAccountStore(tmp_path / "host_local_account.json")
with pytest.raises(ValueError):
store.create("", "password")
with pytest.raises(ValueError):
store.create("operator", "")
@@ -0,0 +1,58 @@
from __future__ import annotations
from pathlib import Path
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.testclient import TestClient
from host_agent.mcp_token import McpTokenStore
from host_agent.web.mcp_auth import BearerAuthMiddleware
def _make_client(tmp_path: Path) -> tuple[TestClient, str]:
store = McpTokenStore(tmp_path / "host_mcp_token.json")
token = store.load_or_create().token
async def hello(request): # type: ignore[no-untyped-def]
return JSONResponse({"ok": True})
inner = Starlette(routes=[])
inner.router.add_route("/", hello, methods=["GET"])
wrapped = Starlette()
wrapped.add_middleware(BearerAuthMiddleware, token_store=store)
wrapped.mount("/", inner)
return TestClient(wrapped), token
def test_no_header_returns_401(tmp_path: Path) -> None:
client, _ = _make_client(tmp_path)
resp = client.get("/")
assert resp.status_code == 401
assert resp.headers["WWW-Authenticate"] == "Bearer"
assert resp.json() == {"error": "invalid token"}
def test_wrong_token_returns_401(tmp_path: Path) -> None:
client, _ = _make_client(tmp_path)
resp = client.get("/", headers={"Authorization": "Bearer wrong"})
assert resp.status_code == 401
def test_correct_token_passes_through(tmp_path: Path) -> None:
client, token = _make_client(tmp_path)
resp = client.get("/", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200
assert resp.json() == {"ok": True}
def test_non_bearer_scheme_returns_401(tmp_path: Path) -> None:
client, token = _make_client(tmp_path)
resp = client.get("/", headers={"Authorization": f"Basic {token}"})
assert resp.status_code == 401
def test_header_case_insensitive(tmp_path: Path) -> None:
client, token = _make_client(tmp_path)
resp = client.get("/", headers={"authorization": f"Bearer {token}"})
assert resp.status_code == 200
@@ -0,0 +1,175 @@
from __future__ import annotations
import threading
from datetime import UTC, datetime
from host_agent.mcp_lock import McpBusyTracker
def _tracker_with_now() -> tuple[McpBusyTracker, list[datetime]]:
times: list[datetime] = []
def now() -> datetime:
return times[-1] if times else datetime(2026, 1, 1, tzinfo=UTC)
tracker = McpBusyTracker(ttl_seconds=60.0, now=now)
return tracker, times
def test_acquire_succeeds_on_empty() -> None:
tracker, _ = _tracker_with_now()
assert tracker.acquire("phone-1", "sess-a") is True
assert "phone-1" in tracker.busy_device_ids()
def test_acquire_fails_when_held_by_other_session() -> None:
tracker, _ = _tracker_with_now()
assert tracker.acquire("phone-1", "sess-a") is True
assert tracker.acquire("phone-1", "sess-b") is False
def test_acquire_is_idempotent_for_same_session() -> None:
tracker, _ = _tracker_with_now()
assert tracker.acquire("phone-1", "sess-a") is True
# Same session re-acquiring is allowed (acts as renew).
assert tracker.acquire("phone-1", "sess-a") is True
def test_renew_refreshes_last_seen() -> None:
tracker, times = _tracker_with_now()
times.append(datetime(2026, 1, 1, 12, 0, tzinfo=UTC))
tracker.acquire("phone-1", "sess-a")
initial = tracker.snapshot()[0]
times.append(datetime(2026, 1, 1, 12, 0, 30, tzinfo=UTC))
assert tracker.renew("phone-1", "sess-a") is True
refreshed = tracker.snapshot()[0]
assert refreshed.last_seen_at > initial.last_seen_at
def test_renew_fails_when_held_by_other() -> None:
tracker, _ = _tracker_with_now()
tracker.acquire("phone-1", "sess-a")
assert tracker.renew("phone-1", "sess-b") is False
def test_release_returns_freed_device_ids() -> None:
tracker, _ = _tracker_with_now()
tracker.acquire("phone-1", "sess-a")
tracker.acquire("phone-2", "sess-a")
freed = tracker.release("sess-a")
assert sorted(freed) == ["phone-1", "phone-2"]
assert tracker.busy_device_ids() == []
def test_release_only_frees_caller_session() -> None:
tracker, _ = _tracker_with_now()
tracker.acquire("phone-1", "sess-a")
tracker.acquire("phone-1", "sess-b") # fails
freed = tracker.release("sess-b")
assert freed == []
assert "phone-1" in tracker.busy_device_ids()
def test_ttl_sweeps_expired_leases() -> None:
tracker, times = _tracker_with_now()
times.append(datetime(2026, 1, 1, 12, 0, tzinfo=UTC))
tracker.acquire("phone-1", "sess-a")
# Advance past TTL without renew.
times.append(datetime(2026, 1, 1, 12, 1, 1, tzinfo=UTC)) # 61s later
assert tracker.busy_device_ids() == []
def test_renew_after_ttl_tolerates_same_session() -> None:
"""Scene 10: lease expired but session_id matches -> re-acquire."""
tracker, times = _tracker_with_now()
times.append(datetime(2026, 1, 1, 12, 0, tzinfo=UTC))
tracker.acquire("phone-1", "sess-a")
times.append(datetime(2026, 1, 1, 12, 1, 1, tzinfo=UTC)) # expired
# renew from the same session should succeed (re-acquire).
assert tracker.renew("phone-1", "sess-a") is True
assert "phone-1" in tracker.busy_device_ids()
def test_snapshot_matches_busy_device_ids() -> None:
tracker, _ = _tracker_with_now()
tracker.acquire("phone-1", "sess-a")
tracker.acquire("phone-2", "sess-a")
snap = tracker.snapshot()
assert {lease.device_id for lease in snap} == set(tracker.busy_device_ids())
def test_wait_until_usable_succeeds_when_free() -> None:
tracker, _ = _tracker_with_now()
ok = tracker.wait_until_usable("phone-1", "sess-a", timeout=1.0, poll_interval=0.01)
assert ok is True
assert "phone-1" in tracker.busy_device_ids()
def test_wait_until_usable_returns_false_on_timeout() -> None:
tracker, _ = _tracker_with_now()
tracker.acquire("phone-1", "sess-a")
ok = tracker.wait_until_usable("phone-1", "sess-b", timeout=0.1, poll_interval=0.02)
assert ok is False
def test_wait_until_usable_blocks_then_succeeds_when_released() -> None:
tracker, _ = _tracker_with_now()
tracker.acquire("phone-1", "sess-a")
def releaser() -> None:
import time
time.sleep(0.05)
tracker.release("sess-a")
t = threading.Thread(target=releaser)
t.start()
try:
ok = tracker.wait_until_usable(
"phone-1", "sess-b", timeout=2.0, poll_interval=0.02
)
assert ok is True
finally:
t.join()
def test_wait_until_usable_blocks_then_fails_when_cloud_remains_busy() -> None:
tracker, _ = _tracker_with_now()
ok = tracker.wait_until_usable(
"phone-1",
"sess-a",
timeout=0.1,
poll_interval=0.02,
cloud_busy_check=lambda: True,
)
assert ok is False
assert tracker.busy_device_ids() == []
def test_default_ttl_is_20_seconds() -> None:
"""The McpBusyTracker default TTL is 20s: short enough to recover from a
dead MCP session within a heartbeat interval without an explicit release
callback (mcp SDK 1.28.1 has no per-session shutdown hook), but long
enough that an actively-busy session does not lose its lease during
normal operator pauses."""
tracker = McpBusyTracker()
assert tracker._ttl == 20.0
def test_default_ttl_recovers_dead_session_within_one_window() -> None:
"""With the 20s default, a session that never renews its lease is
reaped within one TTL window on the next read. This is the
concrete fallback behavior for I1 (no FastMCP session-end hook)."""
times: list[datetime] = []
def now() -> datetime:
return times[-1] if times else datetime(2026, 1, 1, tzinfo=UTC)
tracker = McpBusyTracker(now=now) # default 20s TTL
times.append(datetime(2026, 1, 1, 12, 0, tzinfo=UTC))
assert tracker.acquire("phone-1", "dead-session") is True
# No renew: advance 21s. Lease should be swept on next read.
times.append(datetime(2026, 1, 1, 12, 0, 21, tzinfo=UTC))
assert tracker.busy_device_ids() == []
# New session can now acquire cleanly (no stale-busy contamination).
assert tracker.acquire("phone-1", "new-session") is True
@@ -0,0 +1,107 @@
from __future__ import annotations
import json
import os
import stat
import sys
from datetime import datetime
from pathlib import Path
import pytest
from host_agent.mcp_token import McpToken, McpTokenStore, McpTokenStoreError
def test_load_or_create_generates_when_missing(tmp_path: Path) -> None:
store = McpTokenStore(tmp_path / "host_mcp_token.json")
token = store.load_or_create()
assert token.version == 1
assert len(token.token) >= 40 # secrets.token_urlsafe(32) -> ~43 chars
assert isinstance(token.created_at, datetime)
# File now exists.
assert (tmp_path / "host_mcp_token.json").exists()
def test_load_or_create_is_idempotent(tmp_path: Path) -> None:
store = McpTokenStore(tmp_path / "host_mcp_token.json")
first = store.load_or_create()
second = McpTokenStore(tmp_path / "host_mcp_token.json").load_or_create()
assert first.token == second.token
def test_load_or_create_writes_json_schema(tmp_path: Path) -> None:
path = tmp_path / "host_mcp_token.json"
McpTokenStore(path).load_or_create()
data = json.loads(path.read_text())
assert set(data) == {"version", "token", "created_at"}
assert data["version"] == 1
assert isinstance(data["token"], str)
# created_at is ISO 8601.
datetime.fromisoformat(data["created_at"])
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX perms only")
def test_load_or_create_sets_posix_permissions(tmp_path: Path) -> None:
path = tmp_path / "host_mcp_token.json"
McpTokenStore(path).load_or_create()
mode = stat.S_IMODE(os.fstat(os.open(path, os.O_RDONLY)).st_mode)
assert mode == 0o600
def test_verify_accepts_correct_token(tmp_path: Path) -> None:
store = McpTokenStore(tmp_path / "host_mcp_token.json")
token = store.load_or_create()
assert store.verify(token.token) is True
def test_verify_rejects_wrong_token(tmp_path: Path) -> None:
store = McpTokenStore(tmp_path / "host_mcp_token.json")
store.load_or_create()
assert store.verify("wrong") is False
def test_load_or_create_raises_on_corrupt_json(tmp_path: Path) -> None:
path = tmp_path / "host_mcp_token.json"
path.write_text("{not valid json")
with pytest.raises(McpTokenStoreError):
McpTokenStore(path).load_or_create()
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX chmod enforcement only")
def test_load_or_create_raises_on_unwritable_dir(tmp_path: Path) -> None:
unwritable = tmp_path / "ro"
unwritable.mkdir()
os.chmod(unwritable, 0o500) # r-x for owner
try:
with pytest.raises(McpTokenStoreError):
McpTokenStore(unwritable / "host_mcp_token.json").load_or_create()
finally:
os.chmod(unwritable, 0o700) # restore so cleanup works
@pytest.mark.skipif(
sys.platform == "win32",
reason="POSIX atomic-rename semantics only",
)
def test_load_or_create_concurrent_calls_do_not_corrupt(
tmp_path: Path,
) -> None:
"""Two store instances racing to create: both end up reading the same token."""
import threading
path = tmp_path / "host_mcp_token.json"
results: list[McpToken] = []
barrier = threading.Barrier(2)
def worker() -> None:
barrier.wait()
store = McpTokenStore(path)
results.append(store.load_or_create())
threads = [threading.Thread(target=worker) for _ in range(2)]
for t in threads:
t.start()
for t in threads:
t.join()
assert len(results) == 2
assert results[0].token == results[1].token
@@ -6,6 +6,7 @@ from datetime import UTC, datetime
from cloud.internal_api.models import AssignmentModel, TerminalResultResponse
from host_agent.assignment import AssignmentExecutionResult
from host_agent.processor import AssignmentProcessor
from host_agent.status import AgentStatusTracker
def _assignment() -> AssignmentModel:
@@ -85,3 +86,101 @@ def test_processor_preserves_runtime_failure_reason() -> None:
}
asyncio.run(scenario())
def test_processor_reports_cancelled_status_with_reason() -> None:
async def scenario() -> None:
reports: list[dict[str, object]] = []
class CancelledExecutor:
async def run(self, assignment):
return AssignmentExecutionResult(
status="cancelled",
failure_reason="cancellation requested by control plane",
metadata={"runtime_status": "cancelled"},
)
class RecordingClient:
async def report_result(self, assignment, **kwargs):
reports.append(kwargs)
return TerminalResultResponse(status="recorded")
result = await AssignmentProcessor(
RecordingClient(), # type: ignore[arg-type]
CancelledExecutor(),
).process(_assignment())
assert result.report_status == "recorded"
assert reports[0] == {
"status": "cancelled",
"failure_reason": "cancellation requested by control plane",
"result": {"runtime_status": "cancelled"},
}
asyncio.run(scenario())
def test_status_tracker_sees_started_then_finished_even_on_raise() -> None:
async def scenario() -> None:
tracker = AgentStatusTracker()
snapshots: list[dict[str, object]] = []
class RaisingExecutor:
async def run(self, assignment):
snapshots.append(tracker.snapshot())
raise RuntimeError("executor exploded")
class RecordingClient:
async def report_result(self, assignment, **kwargs):
return TerminalResultResponse(status="recorded")
processor = AssignmentProcessor(
RecordingClient(), # type: ignore[arg-type]
RaisingExecutor(),
status_tracker=tracker,
)
try:
await processor.process(_assignment())
except RuntimeError:
pass
assert snapshots[0]["current_assignment"] is not None
assert snapshots[0]["current_assignment"]["task_id"] == "task-a"
assert tracker.snapshot()["current_assignment"] is None
asyncio.run(scenario())
def test_on_result_receives_assignment_and_result_and_swallows_exceptions() -> None:
async def scenario() -> None:
received: list[tuple[object, object]] = []
class SuccessfulExecutor:
async def run(self, assignment):
return AssignmentExecutionResult(
status="done",
failure_reason=None,
metadata={},
)
class RecordingClient:
async def report_result(self, assignment, **kwargs):
return TerminalResultResponse(status="recorded")
def on_result(assignment, result) -> None:
received.append((assignment, result))
raise RuntimeError("history recording exploded")
assignment = _assignment()
processor = AssignmentProcessor(
RecordingClient(), # type: ignore[arg-type]
SuccessfulExecutor(),
on_result=on_result,
)
result = await processor.process(assignment)
assert received == [(assignment, result)]
asyncio.run(scenario())
@@ -0,0 +1,93 @@
"""Tests for the host-agent skill sync wiring (§7.2/7.4)."""
from __future__ import annotations
import httpx
import pytest
from host_agent.config import HostAgentConfig
from host_agent.skill_sync import HostAgentSkillSync
def _config(tmp_path) -> HostAgentConfig:
return HostAgentConfig(
control_plane_url="https://cloud.example",
host_id="host-1",
token="host-token",
identity_path=tmp_path / "identity.json",
skill_sync_interval_seconds=0.01,
)
def test_skill_sync_pulls_delta_and_reports_inventory(tmp_path):
seen_inventory = {"host": None, "body": None}
def handler(request: httpx.Request) -> httpx.Response:
path = request.url.path
if path.endswith("/skills/sync"):
return httpx.Response(
200,
json={
"skills": [
{
"id": "c1",
"name": "Cloud Skill",
"kind": "knowledge",
"description": "",
"tags": [],
"revision": 1,
"created_at": "2026-01-01T00:00:00+00:00",
"updated_at": "2026-01-01T00:00:00+00:00",
"content": "body",
"steps": [],
"parameters": {},
}
],
"removed_ids": [],
"latest_version": 1,
"is_full_replace": True,
},
)
if path.endswith("/skills/inventory"):
seen_inventory["host"] = request.url.path.split("/")[4]
seen_inventory["body"] = request.read()
return httpx.Response(204)
return httpx.Response(404)
sync = HostAgentSkillSync(
_config(tmp_path),
http_client=httpx.Client(transport=httpx.MockTransport(handler)),
)
# One manual tick applies the cloud skill to the synced store.
outcomes = sync.runner.tick()
assert outcomes["host-1"].success is True
visible = sync.synced_store.list_skills({"host-1"})
assert [m.name for m in visible] == ["Cloud Skill"]
# Inventory report of an authored local skill is best-effort and payload-shaped.
from skills_learning.models import KnowledgeSkill, SkillMetadata
sync.local_store.create_local(
KnowledgeSkill(metadata=SkillMetadata(name="Local Note", kind="knowledge"), content="x")
)
sync.report_inventory_once()
assert seen_inventory["host"] == "host-1"
assert b"Local Note" in seen_inventory["body"]
# start/stop lifecycle does not raise.
sync.start()
sync.stop()
def test_skill_sync_inventory_failure_is_isolated(tmp_path):
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("/skills/inventory"):
return httpx.Response(500)
return httpx.Response(200, json={"skills": [], "removed_ids": [], "latest_version": 0, "is_full_replace": True})
sync = HostAgentSkillSync(
_config(tmp_path),
http_client=httpx.Client(transport=httpx.MockTransport(handler)),
)
# A failed inventory report must not raise.
sync.report_inventory_once()
sync.client.close()
@@ -0,0 +1,66 @@
from __future__ import annotations
from datetime import UTC, datetime
from cloud.internal_api.models import AssignmentModel
from host_agent.status import AgentStatusTracker
def _assignment() -> AssignmentModel:
return AssignmentModel(
task_id="task-a",
attempt=1,
lease_id="lease-a",
lease_expires_at=datetime(2026, 7, 12, tzinfo=UTC),
host_id="host-a",
device_id="device-a",
goal="open settings",
workflow_definition_id="workflow-a",
)
def test_mark_assignment_started_reflected_in_snapshot() -> None:
ticks = iter([datetime(2026, 7, 13, 9, 0, 0, tzinfo=UTC)])
tracker = AgentStatusTracker(now=lambda: next(ticks))
tracker.mark_assignment_started(_assignment())
snapshot = tracker.snapshot()
assert snapshot["current_assignment"] == {
"task_id": "task-a",
"device_id": "device-a",
"goal": "open settings",
"workflow_definition_id": "workflow-a",
"started_at": "2026-07-13T09:00:00+00:00",
}
def test_mark_assignment_finished_clears_current_assignment() -> None:
tracker = AgentStatusTracker(now=lambda: datetime(2026, 7, 13, 9, 0, 0, tzinfo=UTC))
tracker.mark_assignment_started(_assignment())
tracker.mark_assignment_finished()
assert tracker.snapshot()["current_assignment"] is None
def test_mark_heartbeat_reflected_in_snapshot() -> None:
tracker = AgentStatusTracker(now=lambda: datetime(2026, 7, 13, 9, 5, 0, tzinfo=UTC))
tracker.mark_heartbeat(ok=True, device_count=3)
snapshot = tracker.snapshot()
assert snapshot["last_heartbeat"] == {
"ok": True,
"device_count": 3,
"at": "2026-07-13T09:05:00+00:00",
}
def test_snapshot_defaults_to_no_assignment_or_heartbeat() -> None:
tracker = AgentStatusTracker(now=lambda: datetime(2026, 7, 13, 9, 0, 0, tzinfo=UTC))
snapshot = tracker.snapshot()
assert snapshot["current_assignment"] is None
assert snapshot["last_heartbeat"] is None
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,162 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from host_agent.local_account import LocalAccountStore
from host_agent.web.auth import SessionManager, attempt_login, change_password
class FakeClock:
def __init__(self, start: datetime) -> None:
self.current = start
def __call__(self) -> datetime:
return self.current
def advance(self, seconds: float) -> None:
self.current += timedelta(seconds=seconds)
def test_create_session_then_validate_returns_username() -> None:
clock = FakeClock(datetime(2026, 1, 1, tzinfo=UTC))
manager = SessionManager(ttl_seconds=60, now=clock)
session_token, csrf_token = manager.create_session("operator")
state = manager.validate(session_token)
assert state is not None
assert state.username == "operator"
assert state.csrf_token == csrf_token
def test_validate_unknown_token_returns_none() -> None:
manager = SessionManager(ttl_seconds=60)
assert manager.validate("does-not-exist") is None
def test_validate_after_ttl_elapsed_returns_none() -> None:
clock = FakeClock(datetime(2026, 1, 1, tzinfo=UTC))
manager = SessionManager(ttl_seconds=60, now=clock)
session_token, _ = manager.create_session("operator")
clock.advance(61)
assert manager.validate(session_token) is None
def test_validate_before_expiry_slides_expiry_forward() -> None:
clock = FakeClock(datetime(2026, 1, 1, tzinfo=UTC))
manager = SessionManager(ttl_seconds=60, now=clock)
session_token, _ = manager.create_session("operator")
clock.advance(30)
first = manager.validate(session_token)
assert first is not None
clock.advance(30)
second = manager.validate(session_token)
assert second is not None
assert second.expires_at > first.expires_at
def test_validate_csrf_true_for_right_token() -> None:
manager = SessionManager(ttl_seconds=60)
session_token, csrf_token = manager.create_session("operator")
assert manager.validate_csrf(session_token, csrf_token) is True
def test_validate_csrf_false_for_wrong_token() -> None:
manager = SessionManager(ttl_seconds=60)
session_token, _ = manager.create_session("operator")
assert manager.validate_csrf(session_token, "wrong-token") is False
def test_validate_csrf_false_for_invalid_session() -> None:
manager = SessionManager(ttl_seconds=60)
assert manager.validate_csrf("does-not-exist", "anything") is False
def test_invalidate_makes_subsequent_validate_return_none() -> None:
manager = SessionManager(ttl_seconds=60)
session_token, _ = manager.create_session("operator")
manager.invalidate(session_token)
assert manager.validate(session_token) is None
def test_attempt_login_true_for_correct_credentials(tmp_path) -> None:
store = LocalAccountStore(tmp_path / "host_local_account.json")
store.create("operator", "correct horse battery staple")
assert (
attempt_login(
store, username="operator", password="correct horse battery staple"
)
is True
)
def test_attempt_login_false_for_wrong_password(tmp_path) -> None:
store = LocalAccountStore(tmp_path / "host_local_account.json")
store.create("operator", "correct horse battery staple")
assert attempt_login(store, username="operator", password="wrong") is False
def test_attempt_login_false_when_no_account_exists(tmp_path) -> None:
store = LocalAccountStore(tmp_path / "host_local_account.json")
assert attempt_login(store, username="operator", password="anything") is False
def test_attempt_login_false_for_wrong_username(tmp_path) -> None:
store = LocalAccountStore(tmp_path / "host_local_account.json")
store.create("operator", "correct horse battery staple")
assert (
attempt_login(
store, username="someone-else", password="correct horse battery staple"
)
is False
)
def test_change_password_succeeds_and_rotates_credential(tmp_path) -> None:
store = LocalAccountStore(tmp_path / "host_local_account.json")
store.create("operator", "old password")
assert (
change_password(
store, current_password="old password", new_password="new password"
)
is True
)
assert attempt_login(store, username="operator", password="new password") is True
assert attempt_login(store, username="operator", password="old password") is False
def test_change_password_fails_with_wrong_current_password(tmp_path) -> None:
store = LocalAccountStore(tmp_path / "host_local_account.json")
store.create("operator", "old password")
assert (
change_password(store, current_password="wrong", new_password="new password")
is False
)
assert attempt_login(store, username="operator", password="old password") is True
assert attempt_login(store, username="operator", password="new password") is False
def test_change_password_fails_when_no_account_exists(tmp_path) -> None:
store = LocalAccountStore(tmp_path / "host_local_account.json")
assert (
change_password(store, current_password="anything", new_password="new password")
is False
)
@@ -0,0 +1,415 @@
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
import pytest
from cloud.internal_api.models import AssignmentModel
from device.manager import DeviceManager
from driver.base import Driver
from host_agent.mcp_lock import McpBusyTracker
from host_agent.status import AgentStatusTracker
from host_agent.web.mcp import (
McpDeviceBusyError,
_call_tool_sync,
_current_session_id,
build_mcp_server,
)
from mcp.server.fastmcp import Context
from mcp.shared.context import RequestContext
class _FakeDriver(Driver):
"""Minimal driver. connect/screenshot/tap are exercised; remaining abstract
methods are stubbed to satisfy Driver's ABC contract."""
def __init__(self) -> None:
self.taps: list[tuple[float, float]] = []
def connect(self) -> None:
return None
def disconnect(self) -> None:
return None
def screenshot(self) -> bytes:
return b"fake"
def tap(self, x: float, y: float) -> None:
self.taps.append((x, y))
def long_press(self, x: float, y: float, duration_ms: int = 1200) -> None:
return None
def swipe(
self,
start_x: float,
start_y: float,
end_x: float,
end_y: float,
duration_ms: int = 500,
) -> None:
return None
def swipe_path(
self, waypoints: list[tuple[float, float]], duration_ms: int
) -> None:
return None
def double_tap(self, x: float, y: float, interval_ms: int = 80) -> None:
return None
def input(self, text: str) -> None:
return None
def launch(self, app_id: str) -> None:
return None
def terminate(self, app_id: str) -> None:
return None
def tree(self) -> Any:
return None
def home(self) -> None:
return None
def lock(self) -> None:
return None
def unlock(self) -> None:
return None
def _make_manager_with_device(device_id: str = "phone-1") -> DeviceManager:
manager = DeviceManager()
manager.register_device(
device_id=device_id,
driver_factory=lambda: _FakeDriver(),
name=device_id,
)
manager.connect(device_id)
return manager
def test_build_mcp_server_returns_fastmcp_instance() -> None:
from mcp.server.fastmcp import FastMCP
manager = _make_manager_with_device()
tracker = McpBusyTracker()
status = AgentStatusTracker()
server = build_mcp_server(
manager=manager, mcp_busy_tracker=tracker, status_tracker=status
)
assert isinstance(server, FastMCP)
def test_call_tool_succeeds_when_device_is_free() -> None:
manager = _make_manager_with_device()
tracker = McpBusyTracker()
status = AgentStatusTracker()
server = build_mcp_server(
manager=manager, mcp_busy_tracker=tracker, status_tracker=status
)
result = _call_tool_sync(
server, "take_screenshot", {"device_id": "phone-1"}, session_id="sess-a"
)
assert result["ok"] is True
assert "phone-1" in tracker.busy_device_ids()
def test_call_tool_fails_when_cloud_uses_device() -> None:
"""AgentStatusTracker.current_assignment.device_id matches -> busy."""
manager = _make_manager_with_device()
tracker = McpBusyTracker()
status = AgentStatusTracker()
status.mark_assignment_started(
AssignmentModel(
task_id="t1",
attempt=1,
lease_id="l1",
lease_expires_at=datetime.now(UTC),
host_id="h1",
device_id="phone-1",
goal="cloud task",
)
)
server = build_mcp_server(
manager=manager, mcp_busy_tracker=tracker, status_tracker=status
)
with pytest.raises(McpDeviceBusyError) as exc:
_call_tool_sync(
server,
"take_screenshot",
{"device_id": "phone-1"},
session_id="sess-a",
)
assert exc.value.device_id == "phone-1"
assert exc.value.busy_owner == "cloud_assignment"
def test_call_tool_fails_when_another_mcp_session_holds_device() -> None:
manager = _make_manager_with_device()
tracker = McpBusyTracker()
status = AgentStatusTracker()
# Pre-acquire as a different session.
tracker.acquire("phone-1", "sess-other")
server = build_mcp_server(
manager=manager, mcp_busy_tracker=tracker, status_tracker=status
)
with pytest.raises(McpDeviceBusyError) as exc:
_call_tool_sync(
server,
"take_screenshot",
{"device_id": "phone-1"},
session_id="sess-a",
)
assert exc.value.busy_owner.startswith("mcp_session:")
def test_call_tool_renews_when_same_session_already_holds() -> None:
manager = _make_manager_with_device()
tracker = McpBusyTracker()
status = AgentStatusTracker()
server = build_mcp_server(
manager=manager, mcp_busy_tracker=tracker, status_tracker=status
)
_call_tool_sync(
server, "take_screenshot", {"device_id": "phone-1"}, session_id="sess-a"
)
# Second call from the same session should succeed.
result = _call_tool_sync(
server, "take_screenshot", {"device_id": "phone-1"}, session_id="sess-a"
)
assert result["ok"] is True
def test_list_devices_uses_display_status() -> None:
"""Connected-but-idle devices report as 'connected', not 'busy'."""
manager = _make_manager_with_device()
tracker = McpBusyTracker()
status = AgentStatusTracker()
server = build_mcp_server(
manager=manager, mcp_busy_tracker=tracker, status_tracker=status
)
result = _call_tool_sync(server, "list_devices", {}, session_id="sess-a")
assert isinstance(result, list)
assert result[0]["status"] == "connected"
def test_unknown_device_returns_semantic_error_dict() -> None:
"""take_screenshot against an unknown device returns the api-errors semantic
error dict (``ok=False, error="device not found"``) rather than raising.
Note: this test adapts the brief's exception-assertion semantics to the
actual behavior of ``call_with_semantic_errors`` in ``api/errors.py``
the brief's expectation that an exception is raised here is incorrect for
the current handler implementation."""
manager = _make_manager_with_device()
tracker = McpBusyTracker()
status = AgentStatusTracker()
server = build_mcp_server(
manager=manager, mcp_busy_tracker=tracker, status_tracker=status
)
result = _call_tool_sync(
server,
"take_screenshot",
{"device_id": "does-not-exist"},
session_id="sess-a",
)
assert isinstance(result, dict)
assert result["ok"] is False
assert "device" in result["error"].lower()
def test_manager_required_for_build_mcp_server() -> None:
"""Reinforces D12 — build_mcp_server requires manager as keyword-only."""
import inspect
sig = inspect.signature(build_mcp_server)
assert sig.parameters["manager"].kind == inspect.Parameter.KEYWORD_ONLY
def _fake_ctx(session_obj: object) -> Context:
"""Build a Context whose ``session`` attribute returns ``session_obj``.
Context's ``session`` is a property backed by ``request_context.session``;
we construct a minimal ``RequestContext`` and set it as the private
``_request_context`` field. The pydantic public API doesn't expose a
setter for ``session``, so we use ``object.__setattr__`` on the private
backing field.
"""
ctx = Context.model_construct()
request_ctx = RequestContext(
request_id="req-test",
meta=None,
session=session_obj,
lifespan_context=None,
)
object.__setattr__(ctx, "_request_context", request_ctx)
return ctx
def test_current_session_id_is_stable_across_calls_same_session() -> None:
"""Production-path identity: two tool calls from the same MCP session
must yield the same session_id so the busy tracker can renew the lease.
This exercises the ``Context.session`` code path (NOT the
``_TEST_SESSION_ID`` fallback used by ``_call_tool_sync``)."""
sentinel_session = object()
ctx = _fake_ctx(sentinel_session)
first = _current_session_id(ctx)
second = _current_session_id(ctx)
assert first == second
assert first.startswith("mcp_session:")
# Object identity of the underlying ServerSession is the key — verifies
# we use id(ctx.session) rather than e.g. ctx.request_id.
assert first == f"mcp_session:{id(sentinel_session)}"
def test_current_session_id_differs_across_sessions() -> None:
"""Two different MCP sessions (distinct ServerSession objects) must
produce distinct session_ids so the busy tracker can isolate them."""
sess_a = object()
sess_b = object()
assert _current_session_id(_fake_ctx(sess_a)) != _current_session_id(
_fake_ctx(sess_b)
)
def test_current_session_id_falls_back_when_no_context() -> None:
"""When no Context is available (e.g. outside a FastMCP request lifecycle,
or via ``_call_tool_sync`` which omits the ctx kwarg), the test
contextvars override provides the session_id."""
token = None
try:
from host_agent.web import mcp as mcp_mod
token = mcp_mod._TEST_SESSION_ID.set("test-session-xyz")
assert _current_session_id(None) == "test-session-xyz"
finally:
if token is not None:
from host_agent.web import mcp as mcp_mod
mcp_mod._TEST_SESSION_ID.reset(token)
def test_wrapped_tool_accepts_context_kwarg() -> None:
"""The wrapper registered on FastMCP must declare a ``ctx`` parameter so
FastMCP injects the live Context (and ``tool.context_kwarg`` is set to
``"ctx"``). Without this, FastMCP never injects context and we fall
back to the empty test default the production bug this PR fixes."""
manager = _make_manager_with_device()
tracker = McpBusyTracker()
status = AgentStatusTracker()
server = build_mcp_server(
manager=manager, mcp_busy_tracker=tracker, status_tracker=status
)
tool_manager = server._tool_manager # type: ignore[attr-defined]
tool = tool_manager.get_tool("take_screenshot")
assert tool is not None
assert tool.context_kwarg == "ctx"
def test_busy_error_wire_shape_is_calltoolresult_iserror() -> None:
"""Regression test for spec §7 — busy errors must be visible on the wire.
mcp SDK 1.28.1's ``Tool.run`` wraps every non-``UrlElicitationRequiredError``
exception (including ``McpError`` and our ``McpDeviceBusyError``) into
``ToolError`` (see ``mcp/server/fastmcp/tools/base.py``). The lowlevel
``call_tool`` handler then builds a ``CallToolResult(isError=True,
content=[TextContent(...)])`` (see
``mcp/server/lowlevel/server.py::_make_error_result``). There is no public
path that surfaces JSON-RPC ``-32000`` + structured ``data.busy_owner`` from
a tool call site the SDK's wire contract for tool errors is the
``isError=true`` flag plus text content. This test pins the wire shape so
any future SDK upgrade that exposes a true JSON-RPC error path is caught."""
import asyncio
import mcp.types as types
from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp.exceptions import ToolError
manager = _make_manager_with_device()
tracker = McpBusyTracker()
status = AgentStatusTracker()
tracker.acquire("phone-1", "sess-other") # different session holds the device
server: FastMCP = build_mcp_server(
manager=manager, mcp_busy_tracker=tracker, status_tracker=status
)
tool = server._tool_manager.get_tool("take_screenshot") # type: ignore[attr-defined]
assert tool is not None
sentinel_session = object()
ctx = _fake_ctx(sentinel_session)
with pytest.raises(ToolError) as tool_exc:
asyncio.run(tool.run({"device_id": "phone-1"}, context=ctx))
# ToolError text carries the original exception message verbatim,
# which is what the lowlevel handler copies into TextContent.
message = str(tool_exc.value)
assert "phone-1" in message
assert "busy" in message
assert "mcp_session:sess-oth" in message # truncated busy_owner
# The lowlevel handler converts any exception into a CallToolResult
# with isError=True (mcp SDK 1.28.1 — not a JSON-RPC error envelope).
# We invoke the SDK helper directly to lock the wire contract.
from mcp.server.lowlevel.server import Server as LowlevelServer
lowlevel = LowlevelServer("test-lowlevel")
error_result = lowlevel._make_error_result(message) # type: ignore[attr-defined]
inner = error_result.root
assert isinstance(inner, types.CallToolResult)
assert inner.isError is True
assert len(inner.content) == 1
text_block = inner.content[0]
assert isinstance(text_block, types.TextContent)
assert text_block.text == message
# And confirm the wire shape is NOT a JSON-RPC error envelope — that
# would require code=-32000 + data.busy_owner, which is not exposed
# in mcp SDK 1.28.1 for tool-call errors.
assert not hasattr(inner, "code")
assert inner.structuredContent is None
def test_busy_error_text_includes_cloud_assignment_owner() -> None:
"""Same wire-shape test for the cloud_assignment branch — verifies the
human-readable busy_owner value (the only place to surface it given the
SDK forces tool errors into CallToolResult.isError=true) is correct."""
import asyncio
from mcp.server.fastmcp.exceptions import ToolError
manager = _make_manager_with_device()
tracker = McpBusyTracker()
status = AgentStatusTracker()
status.mark_assignment_started(
AssignmentModel(
task_id="t1",
attempt=1,
lease_id="l1",
lease_expires_at=datetime.now(UTC),
host_id="h1",
device_id="phone-1",
goal="cloud task",
)
)
server = build_mcp_server(
manager=manager, mcp_busy_tracker=tracker, status_tracker=status
)
tool = server._tool_manager.get_tool("take_screenshot") # type: ignore[attr-defined]
assert tool is not None
sentinel_session = object()
ctx = _fake_ctx(sentinel_session)
with pytest.raises(ToolError) as tool_exc:
asyncio.run(tool.run({"device_id": "phone-1"}, context=ctx))
assert "cloud_assignment" in str(tool_exc.value)
assert "phone-1" in str(tool_exc.value)
assert "busy" in str(tool_exc.value)
+39 -71
View File
@@ -1,95 +1,63 @@
# Cloud Console
Independent Vue 3 + Vite single-page app for the Cloud Control Plane
(`apps/cloud-api`). Operators authenticate by pasting a pre-issued scoped
bearer token; the console stores it in `sessionStorage`, attaches
`Authorization: Bearer <token>` to every request, and clears it whenever the
Cloud API responds `401` or `403`.
The app talks only to the platform SDK surface (`/v1/...`) and consumes the
two listing endpoints added by the `cloud-console` change (`GET /v1/tasks`,
`GET /v1/tasks/{task_id}/attempts`) alongside the existing
`/v1/devices`, `/v1/hosts`, `/v1/plugins`, and `POST /v1/plugins` routes.
Vue 3 + Vite single-page app for the Cloud Control Plane (`apps/cloud-api`).
The primary flow is a Cloud user account: username/password login creates an
expiring, revocable `HttpOnly` session cookie, while the frontend sends the
separate CSRF cookie value on writes. The browser never stores the session
secret in JavaScript.
## Prerequisites
- Node.js 20+ (matching the existing `console/` SPA project)
- A running Cloud API (`apps/cloud-api`) reachable from your browser
- A bearer token issued via `CLOUD_PUBLIC_CREDENTIALS_JSON` whose scopes cover
what you intend to do from the console. Recommended least-privilege set:
- `tasks:read` — task list and attempt history views
- `pool:read` — device and host views
- `plugins:read` — plugin list
- Add `tasks:submit`/`plugins:admin` only if you need the write actions from
the same tab.
- Node.js 20+
- A current Cloud API database migration and at least one administrator created
with `device-cloud-admin users create ...`
- HTTPS for production: `CLOUD_SESSION_COOKIE_SECURE=true` is required in a
production Cloud API. Terminate TLS at the origin serving `/console/`.
## Configure the backend CORS allow-list
Accounts have fixed roles:
The Cloud API has no CORS middleware by default. Before a browser can call it
cross-origin, set `CLOUD_CONSOLE_CORS_ORIGINS` to a comma-separated allow-list
that includes the exact origin your dev server prints (scheme + host + port —
no trailing slash):
- `viewer`: task, device/host, and plugin read views
- `operator`: viewer access plus task submission APIs
- `admin`: all API scopes
```bash
# Example: allow the default Vite dev origin
export CLOUD_CONSOLE_CORS_ORIGINS="http://127.0.0.1:5173"
```
Administrators can use **Users & limits** to manage accounts, restrict task
submission to explicit Host/Device targets, configure Host self-submission and
active-task limits, and inspect non-secret Cloud-proxy usage. Daily token
budgets are enforced only for Hosts reporting `AI_PLANNER_TRANSPORT=cloud`;
direct-provider Hosts are labelled **unmetered** rather than budget compliant.
The configured proxy reservation ceiling must fit within any daily budget.
Restart `apps/cloud-api` after changing this env. Tokens are still required —
the allow-list only says which browser origins may send them.
Task readers can inspect full per-step LLM interaction history for
cloud-transport tasks in the task detail view. This history includes prompts
and resolved tool calls, excludes screenshot bytes, and is unavailable by
design for direct-provider Hosts. Retention is configured on the Cloud API via
`CLOUD_PLANNER_DECISION_LOG_RETENTION_DAYS` and its prune interval.
## Run the dev server
## Local development
```bash
cd cloud-console
cp .env.example .env.local
# Edit .env.local if your Cloud API is not at http://127.0.0.1:8001
# Point this to the local Cloud API when it is not http://127.0.0.1:8001
npm install
npm run dev
```
Vite prints a local URL (default `http://127.0.0.1:5173`). Open it, paste a
bearer token, and the task/device/host/plugin dashboards become available.
`.env.local` overrides the default base URL via `VITE_CLOUD_API_BASE_URL`
(defaults to `http://127.0.0.1:8001`).
## Build for production
For a Vite origin such as `http://127.0.0.1:5173`, configure the API with the
exact origin and disable secure cookies only in local/test mode:
```bash
npm run build # type-checks with vue-tsc, then emits dist/
npm run preview # serves the built bundle locally
export CLOUD_CONSOLE_CORS_ORIGINS="http://127.0.0.1:5173"
export CLOUD_SESSION_COOKIE_SECURE=false
```
`dist/` is a static bundle — host it behind any static file server or CDN and
point it at a deployed Cloud API via `VITE_CLOUD_API_BASE_URL` set at build
time.
The Console uses `credentials: include`. `401` returns to the login screen;
`403` remains an authorization error so an otherwise valid session is retained.
## Token handling
## Production
- The token is held in `sessionStorage` only. Closing the tab discards it.
- Every API request attaches `Authorization: Bearer <token>` and targets only
the configured `VITE_CLOUD_API_BASE_URL`.
- A `401`/`403` response clears the stored token and returns the operator to
the token-entry screen with the API's error detail.
## Project layout
```
cloud-console/
├── src/
│ ├── api.ts # API client wrapper (token storage, fetch, errors)
│ ├── types.ts # TS interfaces mirroring the REST models
│ ├── App.vue # Shell: token gate, nav, view router
│ ├── main.ts # Vue bootstrap
│ ├── style.css # Dark theme styles
│ └── views/
│ ├── TokenScreen.vue
│ ├── TasksView.vue # list + detail with attempt history
│ ├── DevicesView.vue # device pool + host registry
│ └── PluginsView.vue # registry list + registration form
├── index.html
├── package.json
├── tsconfig.json / tsconfig.node.json
└── vite.config.ts
```
`npm run build` type-checks and creates `dist/`. The repository Dockerfile
already builds this bundle into `/app/console-static` and configures the Cloud
API to serve it at the same-origin `/console/` route. No CORS configuration is
required in that deployment shape. Use `device-cloud-admin` for account
provisioning and recovery.
+986
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -7,7 +7,8 @@
"dev": "vite --host 127.0.0.1",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview --host 127.0.0.1",
"typecheck": "vue-tsc --noEmit"
"typecheck": "vue-tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@lucide/vue": "^1.23.0",
@@ -15,8 +16,10 @@
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.7",
"jsdom": "^27.1.0",
"typescript": "^6.0.3",
"vite": "^8.1.3",
"vitest": "^4.0.18",
"vue-tsc": "^3.3.6"
}
}
+123 -59
View File
@@ -3,114 +3,178 @@ import { computed, onMounted, onUnmounted, ref } from "vue";
import type { Component } from "vue";
import {
Boxes,
BookOpen,
ListChecks,
LogOut,
MonitorSmartphone,
Puzzle,
SlidersHorizontal,
UsersRound,
} from "@lucide/vue";
import {
TOKEN_INVALID_EVENT,
clearStoredToken,
getStoredToken,
AUTH_INVALID_EVENT,
getCurrentUser,
logout,
} from "./api";
import TokenScreen from "./views/TokenScreen.vue";
import { hasScope } from "./permissions";
import type { CloudUser } from "./types";
import LoginScreen from "./views/LoginScreen.vue";
import PasswordChangeScreen from "./views/PasswordChangeScreen.vue";
import TasksView from "./views/TasksView.vue";
import DevicesView from "./views/DevicesView.vue";
import PluginsView from "./views/PluginsView.vue";
import UsersView from "./views/UsersView.vue";
import LlmProvidersView from "./views/LlmProvidersView.vue";
import SkillsView from "./views/SkillsView.vue";
type ViewId = "tasks" | "devices" | "plugins";
const navItems: { id: ViewId; label: string; icon: Component }[] = [
{ id: "tasks", label: "Tasks", icon: ListChecks },
{ id: "devices", label: "Devices", icon: MonitorSmartphone },
{ id: "plugins", label: "Plugins", icon: Puzzle },
];
type ViewId = "tasks" | "devices" | "plugins" | "users" | "providers" | "skills";
const activeView = ref<ViewId>("tasks");
const tokenRejectedMessage = ref("");
const hasToken = ref(false);
const currentUser = ref<CloudUser | null>(null);
const loading = ref(true);
const authMessage = ref("");
function refreshTokenState() {
hasToken.value = getStoredToken() !== null;
}
function onTokenInvalid() {
hasToken.value = false;
tokenRejectedMessage.value =
"the cloud api rejected the stored token (401/403). paste a new token to continue.";
}
function onStorage(event: StorageEvent) {
if (event.key === null) {
// Tab-wide sessionStorage clear (some browsers fire this on logout).
refreshTokenState();
const canAdminPlugins = computed(
() =>
currentUser.value?.scopes.includes("*") ||
currentUser.value?.scopes.includes("plugins:admin"),
);
const canSubmitTasks = computed(
() =>
currentUser.value?.scopes.includes("*") ||
currentUser.value?.scopes.includes("tasks:submit"),
);
const canAdminUsers = computed(
() => Boolean(
currentUser.value?.scopes.includes("*") ||
currentUser.value?.scopes.includes("users:admin")),
);
const canAdminGovernance = computed(
() => Boolean(
currentUser.value?.scopes.includes("*") ||
currentUser.value?.scopes.includes("governance:admin")),
);
const canAdminProviders = computed(() => hasScope(currentUser.value, "llm-providers:admin"));
const canAdminSkills = computed(() => hasScope(currentUser.value, "skills:admin"));
const isAuthenticated = computed(() => currentUser.value !== null);
const currentUserLabel = computed(() =>
currentUser.value ? `${currentUser.value.display_name} (${currentUser.value.role})` : "",
);
const mustChangePassword = computed(() => currentUser.value?.must_change_password ?? false);
const navItems = computed<{ id: ViewId; label: string; icon: Component }[]>(() => {
const items: { id: ViewId; label: string; icon: Component }[] = [
{ id: "tasks", label: "Tasks", icon: ListChecks },
{ id: "devices", label: "Devices", icon: MonitorSmartphone },
{ id: "plugins", label: "Plugins", icon: Puzzle },
];
if (canAdminUsers.value || canAdminGovernance.value) {
items.push({ id: "users", label: "Users & limits", icon: UsersRound });
}
if (canAdminProviders.value) {
items.push({ id: "providers", label: "LLM providers", icon: SlidersHorizontal });
}
if (canAdminSkills.value) {
items.push({ id: "skills", label: "Skills", icon: BookOpen });
}
return items;
});
async function initializeAuthentication() {
loading.value = true;
currentUser.value = null;
try {
currentUser.value = await getCurrentUser();
} catch {
// A missing session is the normal initial state.
}
loading.value = false;
}
function signOut() {
clearStoredToken();
hasToken.value = false;
tokenRejectedMessage.value = "";
async function onAuthenticated() {
authMessage.value = "";
await initializeAuthentication();
}
function onAuthInvalid() {
currentUser.value = null;
authMessage.value = "your session expired. sign in again.";
}
async function signOut() {
try {
if (currentUser.value) await logout();
} catch {
// Local state must still be cleared when the already-expired session rejects logout.
}
currentUser.value = null;
authMessage.value = "";
}
function onPasswordChanged() {
currentUser.value = null;
authMessage.value = "password changed. sign in with the new password.";
}
onMounted(() => {
refreshTokenState();
window.addEventListener(TOKEN_INVALID_EVENT, onTokenInvalid as EventListener);
window.addEventListener("storage", onStorage as EventListener);
void initializeAuthentication();
window.addEventListener(AUTH_INVALID_EVENT, onAuthInvalid as EventListener);
});
onUnmounted(() => {
window.removeEventListener(TOKEN_INVALID_EVENT, onTokenInvalid as EventListener);
window.removeEventListener("storage", onStorage as EventListener);
window.removeEventListener(AUTH_INVALID_EVENT, onAuthInvalid as EventListener);
});
const activeComponent = computed(() => {
switch (activeView.value) {
case "tasks":
return TasksView;
case "devices":
return DevicesView;
case "plugins":
return PluginsView;
case "users":
return UsersView;
case "providers":
return LlmProvidersView;
case "skills":
return SkillsView;
default:
return TasksView;
}
return TasksView;
});
function onTokenSubmitted() {
tokenRejectedMessage.value = "";
refreshTokenState();
}
</script>
<template>
<TokenScreen
v-if="!hasToken"
:rejection-message="tokenRejectedMessage"
@submitted="onTokenSubmitted"
<div v-if="loading" class="token-screen"><p>Checking session</p></div>
<LoginScreen v-else-if="!isAuthenticated" :message="authMessage" @authenticated="onAuthenticated" />
<PasswordChangeScreen
v-else-if="mustChangePassword"
@changed="onPasswordChanged"
@sign-out="signOut"
/>
<div v-else class="app-shell">
<nav class="app-nav">
<h1>
<Boxes :size="14" />
Cloud Console
</h1>
<h1><Boxes :size="14" /> Cloud Console</h1>
<button
v-for="item in navItems"
:key="item.id"
:class="{ active: activeView === item.id }"
@click="activeView = item.id"
>
<component :is="item.icon" :size="14" />
{{ item.label }}
<component :is="item.icon" :size="14" /> {{ item.label }}
</button>
<div class="spacer" />
<button @click="signOut">
<LogOut :size="14" />
Clear token
</button>
<div class="dim">{{ currentUserLabel }}</div>
<button @click="signOut"><LogOut :size="14" /> Sign out</button>
</nav>
<main class="app-main">
<component :is="activeComponent" />
<PluginsView v-if="activeView === 'plugins'" :can-admin="canAdminPlugins" />
<UsersView
v-else-if="activeView === 'users'"
:can-admin-users="canAdminUsers"
:can-admin-governance="canAdminGovernance"
/>
<LlmProvidersView v-else-if="activeView === 'providers'" :can-admin="canAdminProviders" />
<SkillsView v-else-if="activeView === 'skills'" :can-admin="canAdminSkills" />
<component v-else :is="activeComponent" :can-submit="canSubmitTasks" />
</main>
</div>
</template>

Some files were not shown because too many files have changed in this diff Show More